@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/agents/contract-reviewer.md +11 -0
- package/agents/design-explorer.md +18 -0
- package/agents/diagnosis-worker.md +12 -0
- package/agents/implementation-worker.md +18 -0
- package/config/.env.example +14 -0
- package/licenses/pi-subagents-MIT.txt +21 -0
- package/package.json +69 -0
- package/prompts/abel-design.md +181 -0
- package/prompts/abel-diagnose.md +34 -0
- package/prompts/abel-implement.md +132 -0
- package/prompts/abel-init.md +42 -0
- package/skills/_shared/http-client.mjs +89 -0
- package/skills/_shared/load-config.mjs +64 -0
- package/skills/abel-workflow/SKILL.md +82 -0
- package/skills/context7-auto-research/SKILL.md +32 -0
- package/skills/context7-auto-research/context7.mjs +132 -0
- package/skills/git-commit/SKILL.md +37 -0
- package/skills/grok-search/SKILL.md +39 -0
- package/skills/grok-search/grok-search.mjs +414 -0
- package/src/activation.ts +44 -0
- package/src/agent-registry.ts +54 -0
- package/src/child-session.ts +253 -0
- package/src/contracts.ts +375 -0
- package/src/drain.ts +20 -0
- package/src/empty-resource-loader.ts +38 -0
- package/src/file-snapshot.ts +113 -0
- package/src/index.ts +76 -0
- package/src/parent-provider.ts +85 -0
- package/src/patch.ts +113 -0
- package/src/result-store.ts +40 -0
- package/src/runtime.ts +305 -0
- package/src/scheduler.ts +304 -0
- package/src/scoped-tools.ts +282 -0
- package/src/submit-tool.ts +135 -0
- package/src/worker.ts +119 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// File-level snapshots: content hashes for observed files, deterministic
|
|
2
|
+
// directory-entry manifests for observed directories, and explicit absent
|
|
3
|
+
// markers for proposed new paths. Currency is decided per bound path; no
|
|
4
|
+
// global workspace revision participates.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface FileBound {
|
|
10
|
+
kind: "file";
|
|
11
|
+
sha256: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DirBound {
|
|
16
|
+
kind: "dir";
|
|
17
|
+
manifest: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AbsentBound {
|
|
21
|
+
kind: "absent";
|
|
22
|
+
absent: true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Bound = Record<string, FileBound | DirBound | AbsentBound>;
|
|
26
|
+
|
|
27
|
+
function sha256(bytes: Buffer): string {
|
|
28
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Snapshot one existing regular file under root. */
|
|
32
|
+
export function snapshotFile(root: string, relPath: string): FileBound | null {
|
|
33
|
+
const abs = resolve(root, relPath);
|
|
34
|
+
const st = statSync(abs, { throwIfNoEntry: false });
|
|
35
|
+
if (!st?.isFile()) return null;
|
|
36
|
+
const bytes = readFileSync(abs);
|
|
37
|
+
return { sha256: sha256(bytes), bytes: bytes.length, kind: "file" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deterministic directory-entry manifest: hash of sorted name/type entries. */
|
|
41
|
+
export function snapshotDirManifest(
|
|
42
|
+
root: string,
|
|
43
|
+
relDir: string,
|
|
44
|
+
): DirBound | null {
|
|
45
|
+
const abs = resolve(root, relDir);
|
|
46
|
+
const st = statSync(abs, { throwIfNoEntry: false });
|
|
47
|
+
if (!st?.isDirectory()) return null;
|
|
48
|
+
const entries = readdirSync(abs, { withFileTypes: true })
|
|
49
|
+
.map((e) => `${e.name}:${e.isDirectory() ? "dir" : "file"}`)
|
|
50
|
+
.sort();
|
|
51
|
+
return { manifest: sha256(Buffer.from(entries.join("\n"))), kind: "dir" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a bound map for the given file paths, observed directories, and absent markers. */
|
|
55
|
+
export function snapshotFiles(
|
|
56
|
+
root: string,
|
|
57
|
+
files: string[],
|
|
58
|
+
opts: { absent?: string[] } = {},
|
|
59
|
+
): Bound {
|
|
60
|
+
const bound: Bound = {};
|
|
61
|
+
for (const p of [...new Set(files)]) {
|
|
62
|
+
const entry = snapshotFile(root, p);
|
|
63
|
+
if (entry) bound[p] = entry;
|
|
64
|
+
else bound[p] = { kind: "absent", absent: true };
|
|
65
|
+
}
|
|
66
|
+
for (const p of opts.absent ?? [])
|
|
67
|
+
bound[p] = { kind: "absent", absent: true };
|
|
68
|
+
return bound;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build a bound map for observed directories (deterministic manifests). */
|
|
72
|
+
export function snapshotDirManifests(root: string, dirs: string[]): Bound {
|
|
73
|
+
const bound: Bound = {};
|
|
74
|
+
for (const p of [...new Set(dirs)]) {
|
|
75
|
+
const entry = snapshotDirManifest(root, p);
|
|
76
|
+
if (entry) bound[p] = entry;
|
|
77
|
+
else bound[p] = { kind: "absent", absent: true };
|
|
78
|
+
}
|
|
79
|
+
return bound;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Recapture each bound path and report whether the bound state is current. */
|
|
83
|
+
export function isCurrent(root: string, bound: Bound): boolean {
|
|
84
|
+
for (const [rel, expected] of Object.entries(bound)) {
|
|
85
|
+
if (expected.kind === "dir") {
|
|
86
|
+
const now = snapshotDirManifest(root, rel);
|
|
87
|
+
if (!now || now.manifest !== expected.manifest) return false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const abs = resolve(root, rel);
|
|
91
|
+
const exists = existsSync(abs);
|
|
92
|
+
if (expected.kind === "file") {
|
|
93
|
+
if (!exists) return false;
|
|
94
|
+
const st = statSync(abs);
|
|
95
|
+
if (!st.isFile()) return false;
|
|
96
|
+
const now = snapshotFile(root, rel);
|
|
97
|
+
if (
|
|
98
|
+
!now ||
|
|
99
|
+
now.sha256 !== expected.sha256 ||
|
|
100
|
+
now.bytes !== expected.bytes
|
|
101
|
+
)
|
|
102
|
+
return false;
|
|
103
|
+
} else if (expected.absent) {
|
|
104
|
+
if (exists) return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Merge bound maps (read observations + write targets). */
|
|
111
|
+
export function mergeBounds(...bounds: Bound[]): Bound {
|
|
112
|
+
return Object.assign({}, ...bounds);
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Private workflow orchestration extension. Registers the abel_dispatch tool
|
|
2
|
+
// on load, then keeps it inactive by default by removing only that name from
|
|
3
|
+
// the active set at session start. Eligible-stage activation is wired by the
|
|
4
|
+
// workflow routing (abel-design/implement/diagnose provenance) in the prompts
|
|
5
|
+
// integration; abel-init and ordinary prompts never activate dispatch.
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { deactivateTool } from "./activation.ts";
|
|
8
|
+
import { ACTIONS } from "./contracts.ts";
|
|
9
|
+
import { Runtime } from "./runtime.ts";
|
|
10
|
+
|
|
11
|
+
export const DISPATCH_TOOL = "abel_dispatch";
|
|
12
|
+
|
|
13
|
+
export default function register(pi: ExtensionAPI): void {
|
|
14
|
+
const runtime = new Runtime();
|
|
15
|
+
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: DISPATCH_TOOL,
|
|
18
|
+
label: "Abel Dispatch",
|
|
19
|
+
description:
|
|
20
|
+
"Private Abel workflow delegation: run bounded read-only evidence or Worker phase requests, apply or discard retained results, cancel work, or finish the stage. Inactive unless an eligible Abel stage verified its invocation.",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
action: { type: "string", enum: [...ACTIONS] },
|
|
25
|
+
request: {
|
|
26
|
+
type: "object",
|
|
27
|
+
description: "Request envelope for action=run",
|
|
28
|
+
},
|
|
29
|
+
resultId: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Retained result id for apply/discard",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["action"],
|
|
35
|
+
},
|
|
36
|
+
async execute(
|
|
37
|
+
_toolCallId: string,
|
|
38
|
+
params: { action?: string; request?: unknown; resultId?: string },
|
|
39
|
+
_signal: AbortSignal | undefined,
|
|
40
|
+
_onUpdate: unknown,
|
|
41
|
+
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
|
|
42
|
+
) {
|
|
43
|
+
const action = typeof params?.action === "string" ? params.action : "";
|
|
44
|
+
const result = await runtime.execute(action, params, ctx);
|
|
45
|
+
if (result.ok) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
48
|
+
details: result,
|
|
49
|
+
usage: result.usage,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: result.error }],
|
|
54
|
+
details: result,
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
} as never);
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", () => {
|
|
61
|
+
// Restore the default: dispatch is inactive unless a verified stage
|
|
62
|
+
// reactivates it through the activation helpers.
|
|
63
|
+
const active = pi.getActiveTools();
|
|
64
|
+
if (active.includes(DISPATCH_TOOL)) {
|
|
65
|
+
pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on("session_shutdown", () => {
|
|
70
|
+
runtime.drain();
|
|
71
|
+
const active = pi.getActiveTools();
|
|
72
|
+
if (active.includes(DISPATCH_TOOL)) {
|
|
73
|
+
pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InMemoryCredentialStore,
|
|
3
|
+
type Model,
|
|
4
|
+
type Provider,
|
|
5
|
+
} from "@earendil-works/pi-ai";
|
|
6
|
+
import {
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
ModelRuntime,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export async function runtimeForProvider(
|
|
12
|
+
provider: Provider,
|
|
13
|
+
): Promise<ModelRuntime> {
|
|
14
|
+
const runtime = await ModelRuntime.create({
|
|
15
|
+
credentials: new InMemoryCredentialStore(),
|
|
16
|
+
modelsPath: null,
|
|
17
|
+
refreshOnCreate: false,
|
|
18
|
+
});
|
|
19
|
+
runtime.registerNativeProvider(provider);
|
|
20
|
+
return runtime;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function phaseProvider(
|
|
24
|
+
parent: Provider,
|
|
25
|
+
auth: {
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
headers?: Record<string, string | null>;
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
},
|
|
31
|
+
): Provider {
|
|
32
|
+
return {
|
|
33
|
+
...parent,
|
|
34
|
+
baseUrl: auth.baseUrl ?? parent.baseUrl,
|
|
35
|
+
headers: { ...parent.headers, ...auth.headers },
|
|
36
|
+
auth: {
|
|
37
|
+
apiKey: {
|
|
38
|
+
name: "Abel phase-local parent auth",
|
|
39
|
+
resolve: async () => ({
|
|
40
|
+
auth: {
|
|
41
|
+
apiKey: auth.apiKey,
|
|
42
|
+
headers: auth.headers,
|
|
43
|
+
baseUrl: auth.baseUrl,
|
|
44
|
+
},
|
|
45
|
+
env: auth.env,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
stream(model, context, options) {
|
|
50
|
+
return parent.stream(model, context, {
|
|
51
|
+
...options,
|
|
52
|
+
apiKey: auth.apiKey,
|
|
53
|
+
headers: { ...auth.headers, ...options?.headers },
|
|
54
|
+
env: { ...auth.env, ...options?.env },
|
|
55
|
+
maxRetries: 0,
|
|
56
|
+
} as never);
|
|
57
|
+
},
|
|
58
|
+
streamSimple(model, context, options) {
|
|
59
|
+
return parent.streamSimple(model, context, {
|
|
60
|
+
...options,
|
|
61
|
+
apiKey: auth.apiKey,
|
|
62
|
+
headers: { ...auth.headers, ...options?.headers },
|
|
63
|
+
env: { ...auth.env, ...options?.env },
|
|
64
|
+
maxRetries: 0,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runtimeFromContext(
|
|
71
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
72
|
+
): Promise<{ modelRuntime: ModelRuntime; model: Model<string> }> {
|
|
73
|
+
if (!ctx.model) throw new Error("parent model is unavailable");
|
|
74
|
+
const parent = ctx.modelRegistry.getProvider(ctx.model.provider);
|
|
75
|
+
if (!parent) throw new Error("parent Provider is unavailable");
|
|
76
|
+
const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
77
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
78
|
+
const provider = phaseProvider(parent, resolved);
|
|
79
|
+
const modelRuntime = await runtimeForProvider(provider);
|
|
80
|
+
const model = {
|
|
81
|
+
...ctx.model,
|
|
82
|
+
baseUrl: resolved.baseUrl ?? ctx.model.baseUrl,
|
|
83
|
+
} as Model<string>;
|
|
84
|
+
return { modelRuntime, model };
|
|
85
|
+
}
|
package/src/patch.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { diffWritePaths } from "./contracts.ts";
|
|
3
|
+
import { isCurrent } from "./file-snapshot.ts";
|
|
4
|
+
import type { ResultStore } from "./result-store.ts";
|
|
5
|
+
|
|
6
|
+
interface CommandResult {
|
|
7
|
+
code: number;
|
|
8
|
+
stdout: Buffer;
|
|
9
|
+
stderr: Buffer;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function git(
|
|
13
|
+
root: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
input: Buffer,
|
|
16
|
+
): Promise<CommandResult> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn("git", args, {
|
|
19
|
+
cwd: root,
|
|
20
|
+
shell: false,
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
const stdout: Buffer[] = [];
|
|
24
|
+
const stderr: Buffer[] = [];
|
|
25
|
+
child.stdout.on("data", (b: Buffer) => stdout.push(b));
|
|
26
|
+
child.stderr.on("data", (b: Buffer) => stderr.push(b));
|
|
27
|
+
child.on("error", reject);
|
|
28
|
+
child.on("close", (code) =>
|
|
29
|
+
resolve({
|
|
30
|
+
code: code ?? 1,
|
|
31
|
+
stdout: Buffer.concat(stdout),
|
|
32
|
+
stderr: Buffer.concat(stderr),
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
child.stdin.end(input);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function applyRetainedPatch(input: {
|
|
40
|
+
root: string;
|
|
41
|
+
id: string;
|
|
42
|
+
store: ResultStore;
|
|
43
|
+
}): Promise<
|
|
44
|
+
| { ok: true; targets: string[]; checkExitCode: 0; applyExitCode: 0 }
|
|
45
|
+
| { ok: false; error: string }
|
|
46
|
+
> {
|
|
47
|
+
const retained = input.store.get(input.id);
|
|
48
|
+
if (!retained) return { ok: false, error: "retained result not found" };
|
|
49
|
+
if (retained.root !== input.root)
|
|
50
|
+
return { ok: false, error: "retained result root mismatch" };
|
|
51
|
+
if (!isCurrent(input.root, retained.snapshot))
|
|
52
|
+
return { ok: false, error: "stale file snapshot" };
|
|
53
|
+
|
|
54
|
+
let targets: string[];
|
|
55
|
+
try {
|
|
56
|
+
targets = diffWritePaths(retained.diff.toString("utf8")).paths;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return { ok: false, error: (error as Error).message };
|
|
59
|
+
}
|
|
60
|
+
if (targets.some((path) => !retained.writeSet.includes(path))) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: "patch target is outside the declared write set",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const numstat = await git(
|
|
68
|
+
input.root,
|
|
69
|
+
["apply", "--numstat", "-z", "--recount", "-"],
|
|
70
|
+
retained.diff,
|
|
71
|
+
);
|
|
72
|
+
if (numstat.code !== 0 || numstat.stdout.includes(Buffer.from("-\t-\t"))) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `git numstat screening failed: ${numstat.stderr.toString("utf8")}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const summary = await git(
|
|
79
|
+
input.root,
|
|
80
|
+
["apply", "--summary", "--recount", "-"],
|
|
81
|
+
retained.diff,
|
|
82
|
+
);
|
|
83
|
+
if (
|
|
84
|
+
summary.code !== 0 ||
|
|
85
|
+
/rename|copy|mode change|create mode 160000/i.test(
|
|
86
|
+
summary.stdout.toString("utf8"),
|
|
87
|
+
)
|
|
88
|
+
) {
|
|
89
|
+
return { ok: false, error: "git summary screening rejected the patch" };
|
|
90
|
+
}
|
|
91
|
+
const check = await git(
|
|
92
|
+
input.root,
|
|
93
|
+
["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
|
|
94
|
+
retained.diff,
|
|
95
|
+
);
|
|
96
|
+
if (check.code !== 0)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `git apply --check failed: ${check.stderr.toString("utf8")}`,
|
|
100
|
+
};
|
|
101
|
+
const apply = await git(
|
|
102
|
+
input.root,
|
|
103
|
+
["apply", "--recount", "--whitespace=nowarn", "-"],
|
|
104
|
+
retained.diff,
|
|
105
|
+
);
|
|
106
|
+
if (apply.code !== 0)
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `git apply failed: ${apply.stderr.toString("utf8")}`,
|
|
110
|
+
};
|
|
111
|
+
input.store.discard(input.id);
|
|
112
|
+
return { ok: true, targets, checkExitCode: 0, applyExitCode: 0 };
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Bound } from "./file-snapshot.ts";
|
|
3
|
+
import { snapshotFiles } from "./file-snapshot.ts";
|
|
4
|
+
|
|
5
|
+
export interface RetainedResult {
|
|
6
|
+
id: string;
|
|
7
|
+
diff: Buffer;
|
|
8
|
+
writeSet: string[];
|
|
9
|
+
root: string;
|
|
10
|
+
snapshot: Bound;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ResultStore {
|
|
14
|
+
private readonly results = new Map<string, RetainedResult>();
|
|
15
|
+
|
|
16
|
+
retain(input: { diff: string; writeSet: string[]; root: string }): string {
|
|
17
|
+
const id = randomUUID();
|
|
18
|
+
this.results.set(id, {
|
|
19
|
+
id,
|
|
20
|
+
diff: Buffer.from(input.diff, "utf8"),
|
|
21
|
+
writeSet: [...new Set(input.writeSet)],
|
|
22
|
+
root: input.root,
|
|
23
|
+
snapshot: snapshotFiles(input.root, input.writeSet),
|
|
24
|
+
});
|
|
25
|
+
return id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(id: string): RetainedResult | undefined {
|
|
29
|
+
return this.results.get(id);
|
|
30
|
+
}
|
|
31
|
+
discard(id: string): boolean {
|
|
32
|
+
return this.results.delete(id);
|
|
33
|
+
}
|
|
34
|
+
clear(): void {
|
|
35
|
+
this.results.clear();
|
|
36
|
+
}
|
|
37
|
+
get size(): number {
|
|
38
|
+
return this.results.size;
|
|
39
|
+
}
|
|
40
|
+
}
|