@mingchuno/agent-workflows 0.1.0 → 0.3.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/README.md +37 -6
- package/dist/src/adapters/agents.js +6 -3
- package/dist/src/adapters/hosting.js +16 -10
- package/dist/src/adapters/sdk-protocol.d.ts +3 -3
- package/dist/src/adapters/sdk-protocol.js +9 -7
- package/dist/src/attribution.d.ts +9 -0
- package/dist/src/attribution.js +57 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +80 -25
- package/dist/src/config.d.ts +24 -30
- package/dist/src/config.js +32 -27
- package/dist/src/defaults.d.ts +2 -0
- package/dist/src/defaults.js +2 -0
- package/dist/src/domain.d.ts +31 -4
- package/dist/src/domain.js +10 -3
- package/dist/src/evidence.d.ts +54 -0
- package/dist/src/evidence.js +214 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/invocation.d.ts +25 -0
- package/dist/src/invocation.js +166 -0
- package/dist/src/operations.d.ts +8 -2
- package/dist/src/operations.js +100 -136
- package/dist/src/prompts.d.ts +28 -0
- package/dist/src/prompts.js +63 -0
- package/dist/src/recovery.d.ts +19 -0
- package/dist/src/recovery.js +99 -0
- package/dist/src/runner.d.ts +7 -0
- package/dist/src/runner.js +170 -18
- package/dist/src/runtime/process.d.ts +2 -0
- package/dist/src/runtime/process.js +41 -12
- package/dist/src/store.d.ts +21 -2
- package/dist/src/store.js +122 -1
- package/dist/src/tui/actions.d.ts +16 -0
- package/dist/src/tui/actions.js +23 -0
- package/dist/src/tui/constants.d.ts +6 -0
- package/dist/src/tui/constants.js +3 -0
- package/dist/src/{tui-data.d.ts → tui/data.d.ts} +10 -7
- package/dist/src/tui/data.js +146 -0
- package/dist/src/tui/dialogs.d.ts +17 -0
- package/dist/src/tui/dialogs.js +149 -0
- package/dist/src/tui/format.d.ts +7 -0
- package/dist/src/tui/format.js +62 -0
- package/dist/src/tui/index.d.ts +3 -0
- package/dist/src/tui/index.js +2 -0
- package/dist/src/tui/layout.d.ts +25 -0
- package/dist/src/tui/layout.js +36 -0
- package/dist/src/tui/log-file.d.ts +26 -0
- package/dist/src/tui/log-file.js +156 -0
- package/dist/src/tui/log.d.ts +11 -0
- package/dist/src/tui/log.js +90 -0
- package/dist/src/tui/monitor.d.ts +10 -0
- package/dist/src/tui/monitor.js +284 -0
- package/dist/src/tui/notifications.d.ts +23 -0
- package/dist/src/tui/notifications.js +104 -0
- package/dist/src/tui/text.d.ts +3 -0
- package/dist/src/tui/text.js +10 -0
- package/dist/src/tui/use-log-controller.d.ts +27 -0
- package/dist/src/tui/use-log-controller.js +192 -0
- package/dist/src/tui/views.d.ts +25 -0
- package/dist/src/tui/views.js +327 -0
- package/dist/src/workspace.js +21 -8
- package/docs/api.md +132 -8
- package/docs/architecture.md +21 -4
- package/docs/configuration.md +181 -8
- package/docs/database.md +7 -0
- package/docs/operations.md +160 -5
- package/docs/providers.md +58 -2
- package/docs/releases.md +34 -79
- package/examples/config.ts +6 -6
- package/examples/run.ts +4 -1
- package/package.json +4 -2
- package/dist/src/tui-data.js +0 -89
- package/dist/src/tui.d.ts +0 -5
- package/dist/src/tui.js +0 -69
- package/docs/acceptance.md +0 -35
package/dist/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { defaultStageTimeoutMs, defaultValidationTimeoutMs, } from "./defaults.js";
|
|
2
3
|
export const profileSchema = z.strictObject({
|
|
3
4
|
provider: z.enum(["codex", "copilot"]),
|
|
4
5
|
model: z.string().min(1).optional(),
|
|
@@ -10,12 +11,24 @@ export const profileSchema = z.strictObject({
|
|
|
10
11
|
})
|
|
11
12
|
.optional(),
|
|
12
13
|
});
|
|
13
|
-
export const stageSchema = z
|
|
14
|
+
export const stageSchema = z
|
|
15
|
+
.strictObject({
|
|
14
16
|
profile: profileSchema.partial().optional(),
|
|
15
|
-
prompt: z
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
prompt: z
|
|
18
|
+
.string()
|
|
19
|
+
.refine((text) => text.trim().length > 0, "Prompt must be nonblank")
|
|
20
|
+
.optional(),
|
|
21
|
+
promptFile: z
|
|
22
|
+
.string()
|
|
23
|
+
.refine((text) => text.trim().length > 0, "Prompt file path must be nonblank")
|
|
24
|
+
.optional(),
|
|
25
|
+
timeoutMs: z.number().int().positive().default(defaultStageTimeoutMs),
|
|
26
|
+
}, {
|
|
27
|
+
error: (issue) => issue.code === "unrecognized_keys" && issue.keys.includes("skills")
|
|
28
|
+
? "Stage skills was removed; configure skills in your agent runtime and request them in prompt"
|
|
29
|
+
: undefined,
|
|
30
|
+
})
|
|
31
|
+
.refine((stage) => stage.prompt === undefined || stage.promptFile === undefined, "Specify either prompt or promptFile, never both");
|
|
19
32
|
export const projectSchema = z.strictObject({
|
|
20
33
|
id: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
|
21
34
|
checkout: z.string().min(1),
|
|
@@ -40,34 +53,26 @@ export const projectSchema = z.strictObject({
|
|
|
40
53
|
.array(z.strictObject({
|
|
41
54
|
command: z.string().min(1),
|
|
42
55
|
args: z.array(z.string()).default([]),
|
|
43
|
-
timeoutMs: z
|
|
56
|
+
timeoutMs: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.positive()
|
|
60
|
+
.default(defaultValidationTimeoutMs),
|
|
44
61
|
}))
|
|
45
62
|
.default([]),
|
|
46
|
-
|
|
63
|
+
includeAgentCoAuthors: z.boolean().default(true),
|
|
47
64
|
agent: profileSchema,
|
|
48
65
|
stages: z
|
|
49
66
|
.strictObject({
|
|
50
|
-
implementation: stageSchema.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
skills: [],
|
|
58
|
-
timeoutMs: 1_800_000,
|
|
59
|
-
})),
|
|
60
|
-
review: stageSchema.default(() => ({
|
|
61
|
-
prompt: "",
|
|
62
|
-
skills: [],
|
|
63
|
-
timeoutMs: 1_800_000,
|
|
64
|
-
})),
|
|
67
|
+
implementation: stageSchema.prefault({}),
|
|
68
|
+
publication: stageSchema.prefault({}),
|
|
69
|
+
review: stageSchema.prefault({}),
|
|
70
|
+
}, {
|
|
71
|
+
error: (issue) => issue.code === "unrecognized_keys" && issue.keys.includes("writing")
|
|
72
|
+
? "Stage writing was renamed to publication; update projects.stages.writing"
|
|
73
|
+
: undefined,
|
|
65
74
|
})
|
|
66
|
-
.
|
|
67
|
-
implementation: { prompt: "", skills: [], timeoutMs: 1_800_000 },
|
|
68
|
-
writing: { prompt: "", skills: [], timeoutMs: 1_800_000 },
|
|
69
|
-
review: { prompt: "", skills: [], timeoutMs: 1_800_000 },
|
|
70
|
-
})),
|
|
75
|
+
.prefault({}),
|
|
71
76
|
});
|
|
72
77
|
export const configSchema = z.strictObject({
|
|
73
78
|
id: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
package/dist/src/domain.d.ts
CHANGED
|
@@ -21,14 +21,16 @@ export declare const publicationSchema: z.ZodObject<{
|
|
|
21
21
|
}, z.core.$strict>;
|
|
22
22
|
export type Publication = z.infer<typeof publicationSchema>;
|
|
23
23
|
export declare const reviewSchema: z.ZodObject<{
|
|
24
|
+
complete: z.ZodBoolean;
|
|
25
|
+
limitations: z.ZodArray<z.ZodString>;
|
|
24
26
|
summary: z.ZodString;
|
|
25
27
|
findings: z.ZodArray<z.ZodObject<{
|
|
26
28
|
body: z.ZodString;
|
|
27
|
-
path: z.
|
|
28
|
-
line: z.
|
|
29
|
+
path: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
30
|
+
line: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
|
29
31
|
}, z.core.$strict>>;
|
|
30
32
|
}, z.core.$strict>;
|
|
31
|
-
export type Review = z.
|
|
33
|
+
export type Review = z.input<typeof reviewSchema>;
|
|
32
34
|
export interface ValidationResult {
|
|
33
35
|
command: string;
|
|
34
36
|
args: string[];
|
|
@@ -45,6 +47,11 @@ export interface Snapshot {
|
|
|
45
47
|
paths: string[];
|
|
46
48
|
files: Record<string, string | null>;
|
|
47
49
|
}
|
|
50
|
+
export interface ContributionCandidate {
|
|
51
|
+
provider: string;
|
|
52
|
+
beforeFiles: Record<string, string | null>;
|
|
53
|
+
afterFiles: Record<string, string | null>;
|
|
54
|
+
}
|
|
48
55
|
export interface Workspace {
|
|
49
56
|
check(project: Project): Promise<void>;
|
|
50
57
|
prepare(project: Project, branch: string, signal?: AbortSignal): Promise<Snapshot>;
|
|
@@ -84,7 +91,7 @@ export interface AgentInvocation {
|
|
|
84
91
|
cwd: string;
|
|
85
92
|
prompt: string;
|
|
86
93
|
profile: AgentProfile;
|
|
87
|
-
|
|
94
|
+
outputSchema?: unknown;
|
|
88
95
|
processFile?: string;
|
|
89
96
|
readOnly: boolean;
|
|
90
97
|
signal: AbortSignal;
|
|
@@ -104,6 +111,22 @@ export interface AgentAdapter {
|
|
|
104
111
|
invoke(invocation: AgentInvocation): Promise<string>;
|
|
105
112
|
}
|
|
106
113
|
export type Outcome = "queued" | "running" | "completed" | "failed" | "blocked" | "cancelled" | "no-change" | "ineligible";
|
|
114
|
+
export interface ExecutionRecord {
|
|
115
|
+
/** DBOS workflow identity; publication markers continue to use the run ID. */
|
|
116
|
+
id: string;
|
|
117
|
+
recoveryOf?: string;
|
|
118
|
+
startStep?: number;
|
|
119
|
+
reusedSteps?: string[];
|
|
120
|
+
fingerprint: string;
|
|
121
|
+
recoverySupported: boolean;
|
|
122
|
+
createdAt: string;
|
|
123
|
+
startedAt?: string;
|
|
124
|
+
finishedAt?: string;
|
|
125
|
+
outcome: Outcome;
|
|
126
|
+
phase: string;
|
|
127
|
+
failedStep?: number;
|
|
128
|
+
error?: string;
|
|
129
|
+
}
|
|
107
130
|
export interface RunRecord {
|
|
108
131
|
id: string;
|
|
109
132
|
projectId: string;
|
|
@@ -122,10 +145,14 @@ export interface RunRecord {
|
|
|
122
145
|
snapshot?: Snapshot;
|
|
123
146
|
validation?: ValidationResult[];
|
|
124
147
|
publication?: Publication;
|
|
148
|
+
contributionCandidates?: ContributionCandidate[];
|
|
149
|
+
contributingProviders?: string[];
|
|
125
150
|
change?: ChangeRequest;
|
|
126
151
|
review?: Review;
|
|
127
152
|
reviewHead?: string;
|
|
128
153
|
error?: string;
|
|
154
|
+
failedStep?: number;
|
|
155
|
+
executions?: ExecutionRecord[];
|
|
129
156
|
}
|
|
130
157
|
export declare class BlockedError extends Error {
|
|
131
158
|
constructor(message: string);
|
package/dist/src/domain.js
CHANGED
|
@@ -4,13 +4,20 @@ export const publicationSchema = z.strictObject({
|
|
|
4
4
|
title: z.string().trim().min(1).max(240),
|
|
5
5
|
description: z.string().trim().min(1).max(60000),
|
|
6
6
|
});
|
|
7
|
-
export const reviewSchema = z
|
|
7
|
+
export const reviewSchema = z
|
|
8
|
+
.strictObject({
|
|
9
|
+
complete: z.boolean(),
|
|
10
|
+
limitations: z.array(z.string().trim().min(1)),
|
|
8
11
|
summary: z.string().min(1),
|
|
9
12
|
findings: z.array(z.strictObject({
|
|
10
13
|
body: z.string().min(1),
|
|
11
|
-
path: z.string().
|
|
12
|
-
line: z.number().int().positive().
|
|
14
|
+
path: z.string().nullable().default(null),
|
|
15
|
+
line: z.number().int().positive().nullable().default(null),
|
|
13
16
|
})),
|
|
17
|
+
})
|
|
18
|
+
.refine((review) => review.complete || review.limitations.length > 0, {
|
|
19
|
+
message: "Incomplete review requires at least one inspection limitation",
|
|
20
|
+
path: ["limitations"],
|
|
14
21
|
});
|
|
15
22
|
export class BlockedError extends Error {
|
|
16
23
|
constructor(message) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Project } from "./config.js";
|
|
2
|
+
import { type Snapshot } from "./domain.js";
|
|
3
|
+
export declare const evidenceLimits: {
|
|
4
|
+
readonly chunkBytes: number;
|
|
5
|
+
readonly totalBytes: number;
|
|
6
|
+
};
|
|
7
|
+
interface Artifact {
|
|
8
|
+
path: string;
|
|
9
|
+
sha256: string;
|
|
10
|
+
bytes: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ChangeEvidence {
|
|
13
|
+
index: string;
|
|
14
|
+
identity: string;
|
|
15
|
+
files: Artifact[];
|
|
16
|
+
changedPaths: number;
|
|
17
|
+
base: string;
|
|
18
|
+
head?: string;
|
|
19
|
+
snapshot?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Split even single long lines, preserving UTF-8 and exact reconstruction. */
|
|
22
|
+
export declare function chunkText(text: string): string[];
|
|
23
|
+
export declare class EvidenceWriter {
|
|
24
|
+
readonly directory: string;
|
|
25
|
+
readonly files: Artifact[];
|
|
26
|
+
private bytes;
|
|
27
|
+
constructor(directory: string);
|
|
28
|
+
write(name: string, content: string): Promise<Artifact>;
|
|
29
|
+
index(contents: string, metadata: Record<string, unknown>): Promise<Artifact>;
|
|
30
|
+
chunks(prefix: string, content: string): Promise<{
|
|
31
|
+
path: string;
|
|
32
|
+
sha256: string;
|
|
33
|
+
bytes: number;
|
|
34
|
+
ordinal: number;
|
|
35
|
+
byteOffset: number;
|
|
36
|
+
startLine: number;
|
|
37
|
+
}[]>;
|
|
38
|
+
}
|
|
39
|
+
interface CaptureOptions {
|
|
40
|
+
project: Project;
|
|
41
|
+
directory: string;
|
|
42
|
+
snapshot: Snapshot;
|
|
43
|
+
revisions?: {
|
|
44
|
+
base: string;
|
|
45
|
+
head: string;
|
|
46
|
+
};
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
export declare function captureEvidence(options: CaptureOptions): Promise<ChangeEvidence>;
|
|
50
|
+
export declare function verifyEvidence(evidence: ChangeEvidence): Promise<void>;
|
|
51
|
+
export declare function evidenceContext(evidence: ChangeEvidence): string;
|
|
52
|
+
/** Resolve through existing ancestors, rejecting source-tree writes before mkdir. */
|
|
53
|
+
export declare function assertEvidenceDirectory(checkout: string, requested: string): Promise<string>;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { BlockedError } from "./domain.js";
|
|
4
|
+
import { sha256 } from "./prompts.js";
|
|
5
|
+
import { command, maxCapturedOutputBytes } from "./runtime/process.js";
|
|
6
|
+
export const evidenceLimits = {
|
|
7
|
+
chunkBytes: 64 * 1024,
|
|
8
|
+
totalBytes: maxCapturedOutputBytes,
|
|
9
|
+
};
|
|
10
|
+
/** Split even single long lines, preserving UTF-8 and exact reconstruction. */
|
|
11
|
+
export function chunkText(text) {
|
|
12
|
+
const buffer = Buffer.from(text);
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let start = 0; start < buffer.length;) {
|
|
15
|
+
let end = Math.min(start + evidenceLimits.chunkBytes, buffer.length);
|
|
16
|
+
while (end < buffer.length && (buffer[end] & 0xc0) === 0x80)
|
|
17
|
+
end--;
|
|
18
|
+
chunks.push(buffer.subarray(start, end).toString("utf8"));
|
|
19
|
+
start = end;
|
|
20
|
+
}
|
|
21
|
+
return chunks;
|
|
22
|
+
}
|
|
23
|
+
export class EvidenceWriter {
|
|
24
|
+
directory;
|
|
25
|
+
files = [];
|
|
26
|
+
bytes = 0;
|
|
27
|
+
constructor(directory) {
|
|
28
|
+
this.directory = directory;
|
|
29
|
+
}
|
|
30
|
+
async write(name, content) {
|
|
31
|
+
const bytes = Buffer.byteLength(content);
|
|
32
|
+
const total = this.bytes + bytes;
|
|
33
|
+
if (total > evidenceLimits.totalBytes)
|
|
34
|
+
throw new BlockedError(`Change evidence size ${total} bytes exceeds limit ${evidenceLimits.totalBytes} bytes`);
|
|
35
|
+
if (bytes > evidenceLimits.chunkBytes)
|
|
36
|
+
throw new BlockedError(`Evidence chunk size ${bytes} exceeds limit ${evidenceLimits.chunkBytes}`);
|
|
37
|
+
const artifact = {
|
|
38
|
+
path: resolve(this.directory, name),
|
|
39
|
+
sha256: sha256(content),
|
|
40
|
+
bytes,
|
|
41
|
+
};
|
|
42
|
+
await writeFile(artifact.path, content, { mode: 0o600, flag: "wx" });
|
|
43
|
+
this.bytes = total;
|
|
44
|
+
this.files.push(artifact);
|
|
45
|
+
return artifact;
|
|
46
|
+
}
|
|
47
|
+
async index(contents, metadata) {
|
|
48
|
+
let pages = await this.chunks("index", contents);
|
|
49
|
+
let depth = 0;
|
|
50
|
+
const serialize = () => JSON.stringify({
|
|
51
|
+
version: 1,
|
|
52
|
+
...metadata,
|
|
53
|
+
indexDepth: depth,
|
|
54
|
+
instructions: "At depth 0, concatenate pages as JSONL change entries. At greater depth, concatenate pages as a JSON array of page references and descend one level. Read ordered patch chunks; hunk headers carry original line numbers. Untracked content starts at line 1. Report incomplete inspection explicitly.",
|
|
55
|
+
pages,
|
|
56
|
+
});
|
|
57
|
+
while (Buffer.byteLength(serialize()) > evidenceLimits.chunkBytes) {
|
|
58
|
+
depth++;
|
|
59
|
+
pages = await this.chunks(`catalog-${depth}`, JSON.stringify(pages));
|
|
60
|
+
}
|
|
61
|
+
return this.write("index.json", serialize());
|
|
62
|
+
}
|
|
63
|
+
async chunks(prefix, content) {
|
|
64
|
+
const result = [];
|
|
65
|
+
let byteOffset = 0, line = 1;
|
|
66
|
+
for (const [ordinal, part] of chunkText(content).entries()) {
|
|
67
|
+
result.push({
|
|
68
|
+
...(await this.write(`${prefix}-${ordinal}.txt`, part)),
|
|
69
|
+
ordinal,
|
|
70
|
+
byteOffset,
|
|
71
|
+
startLine: line,
|
|
72
|
+
});
|
|
73
|
+
byteOffset += Buffer.byteLength(part);
|
|
74
|
+
line += part.split("\n").length - 1;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function captureEvidence(options) {
|
|
80
|
+
const { project, snapshot, revisions, signal } = options;
|
|
81
|
+
const checkout = await realpath(project.checkout);
|
|
82
|
+
const directory = await assertEvidenceDirectory(checkout, options.directory);
|
|
83
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
const writer = new EvidenceWriter(directory);
|
|
85
|
+
const git = async (...args) => (await command("git", args, { cwd: checkout, signal, strictUtf8: true }))
|
|
86
|
+
.stdout;
|
|
87
|
+
const diff = (...args) => git("--literal-pathspecs", "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--full-index", ...args);
|
|
88
|
+
const split = (text) => text.split("\0").filter(Boolean);
|
|
89
|
+
const untracked = revisions
|
|
90
|
+
? []
|
|
91
|
+
: split(await git("ls-files", "--others", "--exclude-standard", "-z"));
|
|
92
|
+
const paths = revisions
|
|
93
|
+
? split(await diff("--name-only", "-z", revisions.base, revisions.head))
|
|
94
|
+
: [
|
|
95
|
+
...new Set([
|
|
96
|
+
...snapshot.paths,
|
|
97
|
+
...split(await diff("--cached", "--name-only", "-z")),
|
|
98
|
+
...split(await diff("--name-only", "-z")),
|
|
99
|
+
...untracked,
|
|
100
|
+
]),
|
|
101
|
+
].sort();
|
|
102
|
+
const entries = [];
|
|
103
|
+
for (const [number, path] of paths.entries()) {
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
if (untracked.includes(path)) {
|
|
106
|
+
const content = await readFile(resolve(checkout, path));
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
if (!content.includes(0))
|
|
110
|
+
text = new TextDecoder("utf-8", {
|
|
111
|
+
fatal: true,
|
|
112
|
+
ignoreBOM: true,
|
|
113
|
+
}).decode(content);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* Binary metadata is intentional. */
|
|
117
|
+
}
|
|
118
|
+
entries.push({
|
|
119
|
+
path,
|
|
120
|
+
kind: "untracked",
|
|
121
|
+
change: "added",
|
|
122
|
+
sha256: sha256(content),
|
|
123
|
+
bytes: content.length,
|
|
124
|
+
binary: text === undefined,
|
|
125
|
+
reason: text === undefined
|
|
126
|
+
? "Binary or non-UTF-8 content; metadata only"
|
|
127
|
+
: undefined,
|
|
128
|
+
chunks: text === undefined ? [] : await writer.chunks(`file-${number}`, text),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const ranges = revisions
|
|
132
|
+
? [{ kind: "published", args: [revisions.base, revisions.head] }]
|
|
133
|
+
: [
|
|
134
|
+
{ kind: "staged", args: ["--cached", snapshot.head] },
|
|
135
|
+
{ kind: "unstaged", args: [] },
|
|
136
|
+
];
|
|
137
|
+
for (const range of ranges) {
|
|
138
|
+
const patch = await diff(...range.args, "--", path);
|
|
139
|
+
if (!patch)
|
|
140
|
+
continue;
|
|
141
|
+
const blobs = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/m.exec(patch);
|
|
142
|
+
entries.push({
|
|
143
|
+
path,
|
|
144
|
+
kind: range.kind,
|
|
145
|
+
change: /^new file mode /m.test(patch)
|
|
146
|
+
? "added"
|
|
147
|
+
: /^deleted file mode /m.test(patch)
|
|
148
|
+
? "deleted"
|
|
149
|
+
: "modified",
|
|
150
|
+
blobs: blobs ? { before: blobs[1], after: blobs[2] } : undefined,
|
|
151
|
+
sha256: sha256(patch),
|
|
152
|
+
binary: /^Binary files /m.test(patch),
|
|
153
|
+
reason: /^Binary files /m.test(patch)
|
|
154
|
+
? "Git binary change; metadata only"
|
|
155
|
+
: undefined,
|
|
156
|
+
// Patch headers/hunks preserve original old/new line numbers. Offsets support split long lines.
|
|
157
|
+
chunks: await writer.chunks(`patch-${number}-${range.kind}`, patch),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const identity = {
|
|
162
|
+
base: revisions?.base ?? snapshot.head,
|
|
163
|
+
...(revisions
|
|
164
|
+
? { head: revisions.head }
|
|
165
|
+
: { snapshot: snapshot.fingerprint }),
|
|
166
|
+
};
|
|
167
|
+
const index = await writer.index(entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { ...identity, changedPaths: paths.length });
|
|
168
|
+
const evidence = {
|
|
169
|
+
...identity,
|
|
170
|
+
index: index.path,
|
|
171
|
+
identity: index.sha256,
|
|
172
|
+
files: writer.files,
|
|
173
|
+
changedPaths: paths.length,
|
|
174
|
+
};
|
|
175
|
+
await verifyEvidence(evidence);
|
|
176
|
+
return evidence;
|
|
177
|
+
}
|
|
178
|
+
export async function verifyEvidence(evidence) {
|
|
179
|
+
for (const file of evidence.files) {
|
|
180
|
+
const content = await readFile(file.path).catch((cause) => {
|
|
181
|
+
throw new BlockedError(`Required change evidence unavailable: ${file.path}: ${String(cause)}`);
|
|
182
|
+
});
|
|
183
|
+
if (content.length !== file.bytes || sha256(content) !== file.sha256)
|
|
184
|
+
throw new BlockedError(`Change evidence changed: ${file.path}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function evidenceContext(evidence) {
|
|
188
|
+
return `Change evidence index: ${evidence.index}\nIdentity: ${evidence.identity}\nBase: ${evidence.base}\nHead: ${evidence.head ?? "verified dirty snapshot"}\nChanged paths: ${evidence.changedPaths}\nRead the index and its ordered artifacts incrementally. Use read-only tools and do not edit files. Required evidence must be readable; never infer missing content. Binary content is metadata-only.`;
|
|
189
|
+
}
|
|
190
|
+
/** Resolve through existing ancestors, rejecting source-tree writes before mkdir. */
|
|
191
|
+
export async function assertEvidenceDirectory(checkout, requested) {
|
|
192
|
+
const canonicalCheckout = await realpath(checkout);
|
|
193
|
+
let ancestor = resolve(requested);
|
|
194
|
+
const suffix = [];
|
|
195
|
+
while (true) {
|
|
196
|
+
try {
|
|
197
|
+
const canonical = await realpath(ancestor);
|
|
198
|
+
const destination = resolve(canonical, ...suffix);
|
|
199
|
+
const location = relative(canonicalCheckout, destination);
|
|
200
|
+
if (!location ||
|
|
201
|
+
(!isAbsolute(location) &&
|
|
202
|
+
location !== ".." &&
|
|
203
|
+
!location.startsWith(`..${sep}`)))
|
|
204
|
+
throw new BlockedError("State directory for change evidence must be outside the managed checkout");
|
|
205
|
+
return destination;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.code !== "ENOENT")
|
|
209
|
+
throw error;
|
|
210
|
+
suffix.unshift(relative(dirname(ancestor), ancestor));
|
|
211
|
+
ancestor = dirname(ancestor);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Stage } from "./config.js";
|
|
3
|
+
import { type ContributionCandidate, type RunRecord, type Snapshot } from "./domain.js";
|
|
4
|
+
import { type ChangeEvidence } from "./evidence.js";
|
|
5
|
+
import type { OperationDependencies } from "./operations.js";
|
|
6
|
+
export interface InvocationTask {
|
|
7
|
+
defaultPrompt: string;
|
|
8
|
+
context?: (run: RunRecord) => string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
outputContract?: z.ZodType;
|
|
11
|
+
evidence?: ChangeEvidence;
|
|
12
|
+
}
|
|
13
|
+
interface StageExecution {
|
|
14
|
+
run: RunRecord;
|
|
15
|
+
name: string;
|
|
16
|
+
stage: Stage;
|
|
17
|
+
task: InvocationTask;
|
|
18
|
+
stepId: number;
|
|
19
|
+
dependencies: OperationDependencies;
|
|
20
|
+
saveImplementationSnapshot: (runId: string, snapshot: Snapshot, provider: string) => Promise<ContributionCandidate | undefined>;
|
|
21
|
+
acceptContribution: (runId: string, candidate: ContributionCandidate) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** One logical stage; only returned format errors admit a second response attempt. */
|
|
24
|
+
export declare function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }: StageExecution): Promise<string>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { resolveProfile } from "./config.js";
|
|
6
|
+
import { BlockedError, } from "./domain.js";
|
|
7
|
+
import { verifyEvidence } from "./evidence.js";
|
|
8
|
+
import { resolveStagePrompt, sha256 } from "./prompts.js";
|
|
9
|
+
const maxInvocationAttempts = 2;
|
|
10
|
+
/** One logical stage; only returned format errors admit a second response attempt. */
|
|
11
|
+
export async function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }) {
|
|
12
|
+
const { store, project, agents, signal, workspace, redact } = dependencies;
|
|
13
|
+
const previous = (await store.invocations(run.id)).filter((item) => item.stepId === stepId);
|
|
14
|
+
if (previous.length) {
|
|
15
|
+
for (const record of previous) {
|
|
16
|
+
if (record.outcome !== "running")
|
|
17
|
+
continue;
|
|
18
|
+
record.outcome = "interrupted";
|
|
19
|
+
record.finishedAt = new Date().toISOString();
|
|
20
|
+
if (!record.sessionId)
|
|
21
|
+
record.sessionState = "unavailable";
|
|
22
|
+
await store.saveInvocation(record);
|
|
23
|
+
}
|
|
24
|
+
throw new BlockedError(`Interrupted agent stage ${name}; inspect existing sessions before explicit retry`);
|
|
25
|
+
}
|
|
26
|
+
if (!run.snapshot)
|
|
27
|
+
throw new BlockedError("Missing workspace snapshot");
|
|
28
|
+
await workspace.verify(project, run.snapshot);
|
|
29
|
+
if (task.evidence)
|
|
30
|
+
await verifyEvidence(task.evidence);
|
|
31
|
+
const resolved = resolveStagePrompt(stage, task.defaultPrompt, dependencies.promptBaseDirectory);
|
|
32
|
+
const outputSchema = task.outputContract
|
|
33
|
+
? z.toJSONSchema(task.outputContract)
|
|
34
|
+
: undefined;
|
|
35
|
+
const contract = outputSchema
|
|
36
|
+
? `Return ONLY JSON satisfying this application-owned schema:\n${JSON.stringify(outputSchema)}`
|
|
37
|
+
: "";
|
|
38
|
+
const fullPrompt = [
|
|
39
|
+
resolved.content,
|
|
40
|
+
`Run context:\n${task.context?.(run) ?? ""}`,
|
|
41
|
+
task.readOnly
|
|
42
|
+
? "Inspection only. Do not modify files, commit, push, or publish."
|
|
43
|
+
: "Do not commit, push, or publish.",
|
|
44
|
+
contract,
|
|
45
|
+
].join("\n\n");
|
|
46
|
+
const profile = resolveProfile(project.agent, stage.profile);
|
|
47
|
+
const adapter = agents[profile.provider];
|
|
48
|
+
if (!adapter)
|
|
49
|
+
throw new Error(`Missing agent adapter ${profile.provider}`);
|
|
50
|
+
const deadline = Date.now() + stage.timeoutMs;
|
|
51
|
+
const invocationSignal = AbortSignal.any([
|
|
52
|
+
signal,
|
|
53
|
+
AbortSignal.timeout(stage.timeoutMs),
|
|
54
|
+
]);
|
|
55
|
+
const effective = await adapter.validate(profile, invocationSignal);
|
|
56
|
+
const directory = join(dependencies.artifacts, run.id);
|
|
57
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
58
|
+
let correction = "";
|
|
59
|
+
let pendingContribution;
|
|
60
|
+
for (let attempt = 1; attempt <= maxInvocationAttempts; attempt++) {
|
|
61
|
+
invocationSignal.throwIfAborted();
|
|
62
|
+
const expected = (await store.run(run.id)).snapshot;
|
|
63
|
+
await workspace.verify(project, expected);
|
|
64
|
+
if (task.evidence)
|
|
65
|
+
await verifyEvidence(task.evidence);
|
|
66
|
+
const id = randomUUID();
|
|
67
|
+
const prompt = fullPrompt + correction;
|
|
68
|
+
const readOnly = task.readOnly === true || attempt === maxInvocationAttempts;
|
|
69
|
+
const record = {
|
|
70
|
+
id,
|
|
71
|
+
runId: run.id,
|
|
72
|
+
projectId: project.id,
|
|
73
|
+
step: name,
|
|
74
|
+
stepId,
|
|
75
|
+
attempt,
|
|
76
|
+
provider: profile.provider,
|
|
77
|
+
sessionId: null,
|
|
78
|
+
sessionState: "pending",
|
|
79
|
+
requested: profile,
|
|
80
|
+
effective,
|
|
81
|
+
prompt: redact(prompt),
|
|
82
|
+
taskPrompt: { ...resolved, content: redact(resolved.content) },
|
|
83
|
+
outputContract: outputSchema
|
|
84
|
+
? sha256(JSON.stringify(outputSchema))
|
|
85
|
+
: undefined,
|
|
86
|
+
evidence: task.evidence,
|
|
87
|
+
outcome: "running",
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
log: join(directory, `${id}.jsonl`),
|
|
90
|
+
};
|
|
91
|
+
await store.saveInvocation(record);
|
|
92
|
+
try {
|
|
93
|
+
invocationSignal.throwIfAborted();
|
|
94
|
+
const output = await adapter.invoke({
|
|
95
|
+
id,
|
|
96
|
+
runId: run.id,
|
|
97
|
+
step: name,
|
|
98
|
+
cwd: project.checkout,
|
|
99
|
+
prompt,
|
|
100
|
+
profile,
|
|
101
|
+
outputSchema,
|
|
102
|
+
processFile: record.log + ".process.json",
|
|
103
|
+
readOnly,
|
|
104
|
+
signal: invocationSignal,
|
|
105
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
106
|
+
session: async (sessionId) => {
|
|
107
|
+
record.sessionId = sessionId;
|
|
108
|
+
record.sessionState = "available";
|
|
109
|
+
await store.saveInvocation(record);
|
|
110
|
+
},
|
|
111
|
+
event: async (event) => {
|
|
112
|
+
await appendFile(record.log, redact(JSON.stringify(event)) + "\n", {
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
await appendFile(record.log, "", { mode: 0o600 });
|
|
118
|
+
invocationSignal.throwIfAborted();
|
|
119
|
+
if (readOnly)
|
|
120
|
+
await workspace.verify(project, expected);
|
|
121
|
+
else
|
|
122
|
+
pendingContribution = await saveImplementationSnapshot(run.id, expected, profile.provider);
|
|
123
|
+
if (task.evidence)
|
|
124
|
+
await verifyEvidence(task.evidence);
|
|
125
|
+
invocationSignal.throwIfAborted();
|
|
126
|
+
// Only returned output validation failures qualify for correction.
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = task.outputContract
|
|
130
|
+
? task.outputContract.parse(JSON.parse(output))
|
|
131
|
+
: undefined;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!(error instanceof SyntaxError) && !(error instanceof z.ZodError))
|
|
135
|
+
throw error;
|
|
136
|
+
record.outcome = "invalid-output";
|
|
137
|
+
record.validationError = redact(String(error));
|
|
138
|
+
await appendFile(record.log, redact(JSON.stringify({
|
|
139
|
+
type: "invalid-output",
|
|
140
|
+
output,
|
|
141
|
+
error: String(error),
|
|
142
|
+
})) + "\n");
|
|
143
|
+
if (attempt === maxInvocationAttempts)
|
|
144
|
+
throw new Error(`Invalid output after one correction: ${String(error)}`);
|
|
145
|
+
correction = `\n\nCorrect the prior response format in this fresh inspection-only session. Do not modify files.\nPrior invalid response:\n${output}\nValidation errors:\n${String(error)}`;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (pendingContribution)
|
|
149
|
+
await acceptContribution(run.id, pendingContribution);
|
|
150
|
+
record.outcome = "completed";
|
|
151
|
+
return redact(task.outputContract ? JSON.stringify(parsed) : output);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (record.outcome === "running")
|
|
155
|
+
record.outcome = "failed";
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
record.finishedAt = new Date().toISOString();
|
|
160
|
+
if (!record.sessionId)
|
|
161
|
+
record.sessionState = "unavailable";
|
|
162
|
+
await store.saveInvocation(record);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw new Error("Format correction exhausted");
|
|
166
|
+
}
|