@paleo/alcode 0.1.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 +23 -0
- package/bin/alcode.mjs +3 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +193 -0
- package/dist/guide.d.ts +2 -0
- package/dist/guide.js +15 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +27 -0
- package/dist/run-claude.d.ts +32 -0
- package/dist/run-claude.js +268 -0
- package/dist/session-file.d.ts +31 -0
- package/dist/session-file.js +122 -0
- package/package.json +47 -0
- package/templates/guide-run-generic.md +3 -0
- package/templates/guide-run-openclaw.md +4 -0
- package/templates/guide-wake-generic.md +1 -0
- package/templates/guide-wake-openclaw.md +1 -0
- package/templates/guide.md +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @paleo/alcode
|
|
2
|
+
|
|
3
|
+
Run a coding agent through [AlignFirst](https://github.com/paleo/alignfirst) protocols from the terminal. `alcode` wraps a coding-agent CLI for non-interactive use: it invokes a protocol (`spec`, `plan`, `aad`, …), streams the run to a per-call session file under `.plans/`, and returns the result.
|
|
4
|
+
|
|
5
|
+
Run `alcode --guide` for the full delegation guide. When an OpenClaw agent is the caller, run `alcode --openclaw-guide` instead: the same manual, with the OpenClaw-specific run instructions (`exec` with `background: true` + `timeout: 0`, and the completion-wake procedure).
|
|
6
|
+
|
|
7
|
+
## Execution model
|
|
8
|
+
|
|
9
|
+
`alcode` runs the coding agent as a direct **foreground** child of its own process: it streams a live transcript to stdout and to a per-run session file (with a YAML frontmatter status lifecycle `running` → `succeeded`/`failed`), and blocks until the agent exits. It never backgrounds or detaches itself.
|
|
10
|
+
|
|
11
|
+
Coding runs can be very long: the caller always runs `alcode` as a background task and owns the backgrounding. Under OpenClaw the agent invokes `alcode` through the `exec` tool with `background: true` and `timeout: 0`. OpenClaw wakes the agent on exit (via `tools.exec.notifyOnExit`), which then reads the session file for the result. This keeps the run's lifecycle owned by one supervisor (the caller) instead of a detached process phoning back in. The session file is the durable result handoff: frontmatter `sessionId` + status, and the `---- Result ----` block.
|
|
12
|
+
|
|
13
|
+
If `alcode` is terminated, its signal handlers seal the session file (`status: failed`, `exitReason: terminated`) so it never stays frozen at `running`, then send `SIGTERM` to the coding-agent child, giving it a short grace to tear down its own subprocesses before a `SIGKILL` backstop guarantees no orphan is left behind. Only a `SIGKILL` of `alcode` itself (uncatchable) can leave a stale `running` status.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
alcode --new --protocol spec --ticket AB-123 --message "Feature description"
|
|
19
|
+
alcode --resume <sessionId> --protocol plan
|
|
20
|
+
alcode --new --message "Execute the plan: .plans/AB-123/A2-plan.md"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
See `alcode --help` for all flags and environment variables.
|
package/bin/alcode.mjs
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type RunConfig, type RunOutput } from "./run-claude.js";
|
|
2
|
+
export interface MainOptions {
|
|
3
|
+
argv?: string[];
|
|
4
|
+
stdout?: RunOutput;
|
|
5
|
+
stderr?: RunOutput;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
}
|
|
9
|
+
export declare function main(options?: MainOptions): Promise<number>;
|
|
10
|
+
export declare function buildRunConfig(parsed: AlcodeArgs, cwd: string, sessionFilePath: string, env: NodeJS.ProcessEnv): RunConfig;
|
|
11
|
+
export interface AlcodeArgs {
|
|
12
|
+
isNew: boolean;
|
|
13
|
+
resume?: string;
|
|
14
|
+
ticket?: string;
|
|
15
|
+
protocol?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
guide: boolean;
|
|
19
|
+
openclawGuide: boolean;
|
|
20
|
+
help: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function parseAlcodeArgs(argv: string[]): AlcodeArgs;
|
|
23
|
+
export declare function validateArgs(args: AlcodeArgs): string | undefined;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { renderGuide } from "./guide.js";
|
|
4
|
+
import { assertPlansGate, resolveSessionFilePath, writeInitialSessionFile, } from "./session-file.js";
|
|
5
|
+
import { buildPrompt, PROTOCOLS } from "./prompt.js";
|
|
6
|
+
import { runClaude } from "./run-claude.js";
|
|
7
|
+
export async function main(options) {
|
|
8
|
+
const argv = options?.argv ?? process.argv;
|
|
9
|
+
const stdout = options?.stdout ?? process.stdout;
|
|
10
|
+
const stderr = options?.stderr ?? process.stderr;
|
|
11
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
12
|
+
const env = options?.env ?? process.env;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = parseAlcodeArgs(argv);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
if (parsed.help) {
|
|
22
|
+
stdout.write(renderHelp());
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (parsed.guide || parsed.openclawGuide) {
|
|
26
|
+
stdout.write(`${renderGuide(parsed.openclawGuide ? "openclaw" : "generic")}\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
const validationError = validateArgs(parsed);
|
|
30
|
+
if (validationError) {
|
|
31
|
+
stderr.write(`${validationError}\n`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
return runSession(parsed, { cwd, env, stdout, stderr });
|
|
35
|
+
}
|
|
36
|
+
// alcode always runs `claude` in the foreground and blocks until it exits. When OpenClaw drives
|
|
37
|
+
// alcode, it wraps this call in its own `exec` tool (which backgrounds after `yieldMs` and wakes
|
|
38
|
+
// the agent on exit) — alcode owns no backgrounding or callback of its own. The per-run session
|
|
39
|
+
// file under `.plans/` is the durable result handoff: on completion the frontmatter carries the
|
|
40
|
+
// session id and status, and the `---- Result ----` block carries the outcome for a waking agent
|
|
41
|
+
// (or a human).
|
|
42
|
+
async function runSession(parsed, ctx) {
|
|
43
|
+
const { cwd, env, stdout, stderr } = ctx;
|
|
44
|
+
const gateError = assertPlansGate(cwd);
|
|
45
|
+
if (gateError) {
|
|
46
|
+
stderr.write(`${gateError}\n`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const sessionFilePath = resolveSessionFilePath(cwd, parsed.ticket, now);
|
|
51
|
+
writeInitialSessionFile(sessionFilePath, buildFrontmatter(parsed, now));
|
|
52
|
+
stdout.write(`Session file: ${relative(cwd, sessionFilePath)}\n\n`);
|
|
53
|
+
const result = await runClaude(buildRunConfig(parsed, cwd, sessionFilePath, env), stdout);
|
|
54
|
+
if (parsed.isNew && result.sessionId) {
|
|
55
|
+
stdout.write(`\nSession ID: ${result.sessionId}\n`);
|
|
56
|
+
}
|
|
57
|
+
return result.status === "succeeded" ? 0 : 1;
|
|
58
|
+
}
|
|
59
|
+
function buildFrontmatter(parsed, now) {
|
|
60
|
+
return {
|
|
61
|
+
status: "running",
|
|
62
|
+
protocol: parsed.protocol ?? null,
|
|
63
|
+
ticket: parsed.ticket ?? null,
|
|
64
|
+
model: parsed.model ?? null,
|
|
65
|
+
sessionId: null,
|
|
66
|
+
command: formatCommand(parsed),
|
|
67
|
+
startedAt: now.toISOString(),
|
|
68
|
+
endedAt: null,
|
|
69
|
+
exitReason: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function buildRunConfig(parsed, cwd, sessionFilePath, env) {
|
|
73
|
+
return {
|
|
74
|
+
prompt: buildPrompt(parsed),
|
|
75
|
+
sessionFilePath,
|
|
76
|
+
cwd,
|
|
77
|
+
isNew: parsed.isNew,
|
|
78
|
+
resume: parsed.resume,
|
|
79
|
+
model: parsed.model,
|
|
80
|
+
skipPermissions: env.ALIGNFIRST_CODE_SKIP_PERMISSIONS === "1",
|
|
81
|
+
unset: (env.ALIGNFIRST_CODE_UNSET ?? "").split(","),
|
|
82
|
+
env,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function formatCommand(parsed) {
|
|
86
|
+
const parts = ["alcode"];
|
|
87
|
+
if (parsed.isNew)
|
|
88
|
+
parts.push("--new");
|
|
89
|
+
if (parsed.resume)
|
|
90
|
+
parts.push("--resume", parsed.resume);
|
|
91
|
+
if (parsed.protocol)
|
|
92
|
+
parts.push("--protocol", parsed.protocol);
|
|
93
|
+
if (parsed.ticket)
|
|
94
|
+
parts.push("--ticket", parsed.ticket);
|
|
95
|
+
if (parsed.model)
|
|
96
|
+
parts.push("--model", parsed.model);
|
|
97
|
+
if (parsed.message)
|
|
98
|
+
parts.push("--message", JSON.stringify(parsed.message));
|
|
99
|
+
return parts.join(" ");
|
|
100
|
+
}
|
|
101
|
+
export function parseAlcodeArgs(argv) {
|
|
102
|
+
const { values } = parseArgs({
|
|
103
|
+
args: argv.slice(2),
|
|
104
|
+
options: {
|
|
105
|
+
new: { type: "boolean", default: false },
|
|
106
|
+
resume: { type: "string" },
|
|
107
|
+
ticket: { type: "string" },
|
|
108
|
+
protocol: { type: "string" },
|
|
109
|
+
message: { type: "string" },
|
|
110
|
+
model: { type: "string" },
|
|
111
|
+
guide: { type: "boolean", default: false },
|
|
112
|
+
"openclaw-guide": { type: "boolean", default: false },
|
|
113
|
+
help: { type: "boolean", default: false },
|
|
114
|
+
},
|
|
115
|
+
strict: true,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
isNew: values.new === true,
|
|
119
|
+
resume: values.resume,
|
|
120
|
+
ticket: values.ticket,
|
|
121
|
+
protocol: values.protocol,
|
|
122
|
+
message: values.message,
|
|
123
|
+
model: values.model,
|
|
124
|
+
guide: values.guide === true,
|
|
125
|
+
openclawGuide: values["openclaw-guide"] === true,
|
|
126
|
+
help: values.help === true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function validateArgs(args) {
|
|
130
|
+
const isResume = args.resume !== undefined;
|
|
131
|
+
if (args.isNew && isResume)
|
|
132
|
+
return "Error: --new and --resume are mutually exclusive.";
|
|
133
|
+
if (!args.isNew && !isResume)
|
|
134
|
+
return "Error: at least one of --new or --resume is required.";
|
|
135
|
+
if (args.protocol !== undefined && !PROTOCOLS.includes(args.protocol)) {
|
|
136
|
+
return `Error: --protocol must be one of: ${PROTOCOLS.join(", ")}.`;
|
|
137
|
+
}
|
|
138
|
+
if (!args.protocol && !args.message) {
|
|
139
|
+
return "Error: --message is required when --protocol is not specified.";
|
|
140
|
+
}
|
|
141
|
+
if (args.isNew && args.protocol && !args.ticket) {
|
|
142
|
+
return "Error: --ticket is required with --new and --protocol.";
|
|
143
|
+
}
|
|
144
|
+
if (["spec", "aad"].includes(args.protocol ?? "") && !args.message) {
|
|
145
|
+
return `Error: --protocol ${args.protocol} requires --message.`;
|
|
146
|
+
}
|
|
147
|
+
if (args.ticket !== undefined && !args.isNew) {
|
|
148
|
+
return "Error: --ticket is only valid with --new.";
|
|
149
|
+
}
|
|
150
|
+
if (args.ticket !== undefined && !isPathSafeTicket(args.ticket)) {
|
|
151
|
+
return ("Error: --ticket must be a single path segment " +
|
|
152
|
+
"(letters, digits, '.', '-', '_'); no path separators or '..'.");
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// The ticket becomes a `.plans/<ticket>/coding-sessions/…` path segment. Ticket formats vary by
|
|
157
|
+
// consumer repo (numeric here, but e.g. `AB-123` elsewhere), so allow a permissive charset while
|
|
158
|
+
// blocking path separators and `..` traversal that could escape `.plans/`.
|
|
159
|
+
function isPathSafeTicket(ticket) {
|
|
160
|
+
return /^[A-Za-z0-9._-]+$/.test(ticket) && ticket !== "." && !ticket.includes("..");
|
|
161
|
+
}
|
|
162
|
+
function renderHelp() {
|
|
163
|
+
return `alcode — run a coding agent through AlignFirst protocols.
|
|
164
|
+
|
|
165
|
+
Usage:
|
|
166
|
+
alcode --new --protocol <protocol> --ticket <id> [--message "..."]
|
|
167
|
+
alcode --new --message "..."
|
|
168
|
+
alcode --resume <sessionId> [--protocol <protocol>] [--message "..."]
|
|
169
|
+
alcode --guide
|
|
170
|
+
alcode --openclaw-guide
|
|
171
|
+
alcode --help
|
|
172
|
+
|
|
173
|
+
Modes:
|
|
174
|
+
--new Start a new session.
|
|
175
|
+
--resume <sessionId> Continue an existing session.
|
|
176
|
+
|
|
177
|
+
Options:
|
|
178
|
+
--protocol <p> One of: ${PROTOCOLS.join(", ")}.
|
|
179
|
+
--ticket <id> Ticket ID. Required with --new + --protocol.
|
|
180
|
+
--message "..." Message to send. Required for spec, aad, and when no --protocol.
|
|
181
|
+
--model <model> Model override.
|
|
182
|
+
|
|
183
|
+
Env:
|
|
184
|
+
ALIGNFIRST_CODE_SKIP_PERMISSIONS 1 to run the coding agent with permission prompts disabled.
|
|
185
|
+
ALIGNFIRST_CODE_UNSET Comma-list of env vars to strip from the coding agent child.
|
|
186
|
+
|
|
187
|
+
alcode runs a coding agent in the foreground and blocks until it finishes, streaming the
|
|
188
|
+
transcript to stdout and to a session file under .plans/. Coding runs can be very long: always
|
|
189
|
+
run alcode as a background task. Your platform does the backgrounding; never detach alcode.
|
|
190
|
+
|
|
191
|
+
Run \`alcode --guide\` for the full delegation guide (\`alcode --openclaw-guide\` under OpenClaw).
|
|
192
|
+
`;
|
|
193
|
+
}
|
package/dist/guide.d.ts
ADDED
package/dist/guide.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// templates/guide.md is the shared skeleton; the per-variant fragments fill its tags:
|
|
3
|
+
// {{TITLE-SUFFIX}} — appended to the H1 to label the variant
|
|
4
|
+
// {{RUN}} — how to background the run on the caller's platform
|
|
5
|
+
// {{WAKE}} — how the completion wake arrives, introducing the shared steps
|
|
6
|
+
export function renderGuide(variant) {
|
|
7
|
+
return readTemplate("guide.md")
|
|
8
|
+
.replaceAll("{{TITLE-SUFFIX}}", variant === "openclaw" ? " (OpenClaw)" : "")
|
|
9
|
+
.replaceAll("{{RUN}}", readTemplate(`guide-run-${variant}.md`).trimEnd())
|
|
10
|
+
.replaceAll("{{WAKE}}", readTemplate(`guide-wake-${variant}.md`).trimEnd())
|
|
11
|
+
.trimEnd();
|
|
12
|
+
}
|
|
13
|
+
function readTemplate(name) {
|
|
14
|
+
return readFileSync(new URL(`../templates/${name}`, import.meta.url), "utf-8");
|
|
15
|
+
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const PROTOCOLS: readonly ["spec", "plan", "aad", "description", "read", "review", "merge"];
|
|
2
|
+
export type Protocol = (typeof PROTOCOLS)[number];
|
|
3
|
+
export declare const PROTOCOL_LABELS: Record<string, string>;
|
|
4
|
+
export interface PromptInput {
|
|
5
|
+
protocol?: string;
|
|
6
|
+
ticket?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildPrompt(input: PromptInput): string;
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const PROTOCOLS = ["spec", "plan", "aad", "description", "read", "review", "merge"];
|
|
2
|
+
export const PROTOCOL_LABELS = {
|
|
3
|
+
spec: "spec",
|
|
4
|
+
aad: "AAD",
|
|
5
|
+
plan: "plan",
|
|
6
|
+
description: "description",
|
|
7
|
+
review: "review",
|
|
8
|
+
merge: "merge",
|
|
9
|
+
};
|
|
10
|
+
export function buildPrompt(input) {
|
|
11
|
+
const { protocol, ticket, message } = input;
|
|
12
|
+
if (!protocol)
|
|
13
|
+
return message ?? "";
|
|
14
|
+
if (protocol === "read")
|
|
15
|
+
return buildReadPrompt(ticket, message);
|
|
16
|
+
return buildProtocolPrompt(PROTOCOL_LABELS[protocol], ticket, message);
|
|
17
|
+
}
|
|
18
|
+
function buildReadPrompt(ticket, message) {
|
|
19
|
+
const ticketPart = ticket ? ` for ticket ${ticket}` : "";
|
|
20
|
+
const messagePart = message ? `\n\n${message}` : "";
|
|
21
|
+
return `Use the *alignfirst* skill to determine the TASK_DIR${ticketPart}. Then read every \`*spec.md\` and \`*summary.md\` file in the TASK_DIR.${messagePart}`;
|
|
22
|
+
}
|
|
23
|
+
function buildProtocolPrompt(label, ticket, message) {
|
|
24
|
+
const ticketPart = ticket ? ` Ticket ID = ${ticket}.` : "";
|
|
25
|
+
const messagePart = message ? `\n\n${message}` : "";
|
|
26
|
+
return `Run the _${label}_ protocol from the *alignfirst* skill.${ticketPart}${messagePart}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type CompletionUpdate } from "./session-file.js";
|
|
2
|
+
export interface RunConfig {
|
|
3
|
+
prompt: string;
|
|
4
|
+
sessionFilePath: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
isNew: boolean;
|
|
7
|
+
resume?: string;
|
|
8
|
+
model?: string;
|
|
9
|
+
skipPermissions: boolean;
|
|
10
|
+
unset: string[];
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
}
|
|
13
|
+
export interface RunOutput {
|
|
14
|
+
write(text: string): void;
|
|
15
|
+
}
|
|
16
|
+
export interface RunResult {
|
|
17
|
+
status: "succeeded" | "failed";
|
|
18
|
+
sessionId: string | null;
|
|
19
|
+
result: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function runClaude(config: RunConfig, out: RunOutput): Promise<RunResult>;
|
|
22
|
+
export declare function buildTerminationUpdate(signal: NodeJS.Signals, state: StreamState, now: Date): CompletionUpdate;
|
|
23
|
+
export declare function buildClaudeArgs(config: RunConfig): string[];
|
|
24
|
+
export declare function buildClaudeEnv(baseEnv: NodeJS.ProcessEnv, unset: string[]): NodeJS.ProcessEnv;
|
|
25
|
+
export interface StreamState {
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
result?: string;
|
|
28
|
+
isError: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function createStreamState(): StreamState;
|
|
31
|
+
export declare function parseEventLine(line: string): unknown;
|
|
32
|
+
export declare function renderEvent(event: unknown, state: StreamState): string | undefined;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendTranscript, applyCompletion } from "./session-file.js";
|
|
3
|
+
// Grace period between the SIGTERM that lets `claude` tear down its own children and the SIGKILL
|
|
4
|
+
// backstop that guarantees alcode never leaves a dangling child.
|
|
5
|
+
const TERMINATION_GRACE_MS = 2000;
|
|
6
|
+
// Runs `claude` as a direct foreground child of this process: parses its NDJSON stream, mirrors a
|
|
7
|
+
// human-readable transcript to both the session file and `out` as it arrives, and on exit rewrites
|
|
8
|
+
// the session file's terminal frontmatter and returns the outcome. OpenClaw backgrounds *alcode*
|
|
9
|
+
// via its own `exec` tool and wakes on alcode's exit — alcode itself never detaches.
|
|
10
|
+
export async function runClaude(config, out) {
|
|
11
|
+
const state = createStreamState();
|
|
12
|
+
const outcome = await spawnClaude(config, state, out);
|
|
13
|
+
const failed = state.isError || outcome.exitCode !== 0 || state.result === undefined;
|
|
14
|
+
const result = state.result ?? failureMessage(outcome);
|
|
15
|
+
applyCompletion(config.sessionFilePath, {
|
|
16
|
+
status: failed ? "failed" : "succeeded",
|
|
17
|
+
endedAt: new Date().toISOString(),
|
|
18
|
+
exitReason: failed ? "error" : "completed",
|
|
19
|
+
sessionId: state.sessionId ?? null,
|
|
20
|
+
result,
|
|
21
|
+
});
|
|
22
|
+
return { status: failed ? "failed" : "succeeded", sessionId: state.sessionId ?? null, result };
|
|
23
|
+
}
|
|
24
|
+
function spawnClaude(config, state, out) {
|
|
25
|
+
const child = spawn("claude", buildClaudeArgs(config), {
|
|
26
|
+
cwd: config.cwd,
|
|
27
|
+
env: buildClaudeEnv(config.env, config.unset),
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
});
|
|
30
|
+
const detachChildGuards = guardChildLifecycle(child, config.sessionFilePath, state);
|
|
31
|
+
let buffer = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
child.stdout.setEncoding("utf-8");
|
|
34
|
+
child.stdout.on("data", (chunk) => {
|
|
35
|
+
buffer += chunk;
|
|
36
|
+
buffer = drainLines(buffer, (line) => emitEvent(config.sessionFilePath, line, state, out));
|
|
37
|
+
});
|
|
38
|
+
child.stderr.setEncoding("utf-8");
|
|
39
|
+
child.stderr.on("data", (chunk) => {
|
|
40
|
+
stderr += chunk;
|
|
41
|
+
});
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
if (buffer.trim())
|
|
45
|
+
emitEvent(config.sessionFilePath, buffer, state, out);
|
|
46
|
+
detachChildGuards();
|
|
47
|
+
resolve({ exitCode: code, stderr });
|
|
48
|
+
});
|
|
49
|
+
child.on("error", (err) => {
|
|
50
|
+
detachChildGuards();
|
|
51
|
+
resolve({ exitCode: 1, stderr: err.message });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// A direct `kill <alcode>` (not a process-group kill) would otherwise orphan the foreground
|
|
56
|
+
// `claude`. On a catchable signal, seal the session file (`status: failed`, `exitReason:
|
|
57
|
+
// terminated`) so it never stays frozen at `running`, then SIGTERM the child and give it a short
|
|
58
|
+
// grace to tear down *its own* children (tool subprocesses, dev servers) before a SIGKILL backstop
|
|
59
|
+
// guarantees nothing dangles. The synchronous `exit` handler can't await, so it does a last-resort
|
|
60
|
+
// SIGKILL. A SIGKILL of alcode itself remains the one uncatchable case. Returns a teardown that
|
|
61
|
+
// removes the guards once the child is gone (so alcode can exit normally afterward).
|
|
62
|
+
function guardChildLifecycle(child, sessionFilePath, state) {
|
|
63
|
+
const isAlive = () => child.exitCode === null && child.signalCode === null;
|
|
64
|
+
const signalChild = (signal) => {
|
|
65
|
+
if (!isAlive())
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
child.kill(signal);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// The child is already gone; nothing to clean up.
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const forceKill = () => signalChild("SIGKILL");
|
|
75
|
+
const onSignal = async (signal) => {
|
|
76
|
+
try {
|
|
77
|
+
applyCompletion(sessionFilePath, buildTerminationUpdate(signal, state, new Date()));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Sealing the session file is best-effort; the file may be gone or malformed.
|
|
81
|
+
}
|
|
82
|
+
signalChild("SIGTERM");
|
|
83
|
+
await waitForChildExit(child, TERMINATION_GRACE_MS);
|
|
84
|
+
forceKill();
|
|
85
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
86
|
+
};
|
|
87
|
+
const onSigint = () => onSignal("SIGINT");
|
|
88
|
+
const onSigterm = () => onSignal("SIGTERM");
|
|
89
|
+
process.on("SIGINT", onSigint);
|
|
90
|
+
process.on("SIGTERM", onSigterm);
|
|
91
|
+
process.on("exit", forceKill);
|
|
92
|
+
return () => {
|
|
93
|
+
process.off("SIGINT", onSigint);
|
|
94
|
+
process.off("SIGTERM", onSigterm);
|
|
95
|
+
process.off("exit", forceKill);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Resolves when the child exits or the grace period elapses, whichever comes first — so a SIGTERM
|
|
99
|
+
// that `claude` honors lets alcode exit promptly, while a hung child still hits the SIGKILL backstop.
|
|
100
|
+
function waitForChildExit(child, graceMs) {
|
|
101
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const onExit = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve();
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
child.off("exit", onExit);
|
|
110
|
+
resolve();
|
|
111
|
+
}, graceMs);
|
|
112
|
+
child.once("exit", onExit);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function buildTerminationUpdate(signal, state, now) {
|
|
116
|
+
return {
|
|
117
|
+
status: "failed",
|
|
118
|
+
endedAt: now.toISOString(),
|
|
119
|
+
exitReason: "terminated",
|
|
120
|
+
sessionId: state.sessionId ?? null,
|
|
121
|
+
result: `Terminated by ${signal} before completion.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function buildClaudeArgs(config) {
|
|
125
|
+
const args = [config.prompt, "-p", "--output-format", "stream-json", "--verbose"];
|
|
126
|
+
if (config.skipPermissions) {
|
|
127
|
+
args.push("--dangerously-skip-permissions");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
args.push("--permission-mode", "auto");
|
|
131
|
+
}
|
|
132
|
+
if (config.resume)
|
|
133
|
+
args.push("--resume", config.resume);
|
|
134
|
+
if (config.model)
|
|
135
|
+
args.push("--model", config.model);
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
// Strip every ALIGNFIRST_CODE_* var plus any name in the caller's ALIGNFIRST_CODE_UNSET list, so
|
|
139
|
+
// wrapper env never leaks into the claude child.
|
|
140
|
+
export function buildClaudeEnv(baseEnv, unset) {
|
|
141
|
+
const env = { ...baseEnv };
|
|
142
|
+
for (const name of unset) {
|
|
143
|
+
const trimmed = name.trim();
|
|
144
|
+
if (trimmed)
|
|
145
|
+
delete env[trimmed];
|
|
146
|
+
}
|
|
147
|
+
for (const key of Object.keys(env)) {
|
|
148
|
+
if (key.startsWith("ALIGNFIRST_CODE_"))
|
|
149
|
+
delete env[key];
|
|
150
|
+
}
|
|
151
|
+
return env;
|
|
152
|
+
}
|
|
153
|
+
function drainLines(buffer, onLine) {
|
|
154
|
+
const lines = buffer.split("\n");
|
|
155
|
+
const rest = lines.pop() ?? "";
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (line.trim())
|
|
158
|
+
onLine(line);
|
|
159
|
+
}
|
|
160
|
+
return rest;
|
|
161
|
+
}
|
|
162
|
+
function emitEvent(sessionFilePath, line, state, out) {
|
|
163
|
+
const rendered = renderEvent(parseEventLine(line), state);
|
|
164
|
+
if (rendered === undefined)
|
|
165
|
+
return;
|
|
166
|
+
appendTranscript(sessionFilePath, `${rendered}\n`);
|
|
167
|
+
out.write(`${rendered}\n`);
|
|
168
|
+
}
|
|
169
|
+
export function createStreamState() {
|
|
170
|
+
return { isError: false };
|
|
171
|
+
}
|
|
172
|
+
export function parseEventLine(line) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(line);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { type: "unparsed", raw: line };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Mutates `state` (session id, final result, error flag) and returns a human-readable line to
|
|
181
|
+
// append, or `undefined` when the event contributes nothing to the transcript body.
|
|
182
|
+
export function renderEvent(event, state) {
|
|
183
|
+
if (!isRecord(event))
|
|
184
|
+
return;
|
|
185
|
+
captureSessionId(event, state);
|
|
186
|
+
switch (event.type) {
|
|
187
|
+
case "system":
|
|
188
|
+
return event.subtype === "init" ? `[init] session ${asString(event.session_id)}` : undefined;
|
|
189
|
+
case "assistant":
|
|
190
|
+
return renderMessageContent(event);
|
|
191
|
+
case "user":
|
|
192
|
+
return renderMessageContent(event);
|
|
193
|
+
case "result":
|
|
194
|
+
captureResult(event, state);
|
|
195
|
+
return undefined;
|
|
196
|
+
case "unparsed":
|
|
197
|
+
return asString(event.raw);
|
|
198
|
+
default:
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function captureSessionId(event, state) {
|
|
203
|
+
const id = asString(event.session_id);
|
|
204
|
+
if (id)
|
|
205
|
+
state.sessionId = id;
|
|
206
|
+
}
|
|
207
|
+
function captureResult(event, state) {
|
|
208
|
+
state.isError = event.is_error === true;
|
|
209
|
+
const result = asString(event.result);
|
|
210
|
+
if (result !== undefined)
|
|
211
|
+
state.result = result;
|
|
212
|
+
}
|
|
213
|
+
function renderMessageContent(event) {
|
|
214
|
+
const message = event.message;
|
|
215
|
+
if (!isRecord(message) || !Array.isArray(message.content))
|
|
216
|
+
return;
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const block of message.content) {
|
|
219
|
+
const rendered = renderBlock(block);
|
|
220
|
+
if (rendered)
|
|
221
|
+
parts.push(rendered);
|
|
222
|
+
}
|
|
223
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
224
|
+
}
|
|
225
|
+
function renderBlock(block) {
|
|
226
|
+
if (!isRecord(block))
|
|
227
|
+
return;
|
|
228
|
+
switch (block.type) {
|
|
229
|
+
case "text":
|
|
230
|
+
return asString(block.text);
|
|
231
|
+
case "tool_use":
|
|
232
|
+
return `[tool: ${asString(block.name) ?? "?"}] ${compactJson(block.input)}`;
|
|
233
|
+
case "tool_result":
|
|
234
|
+
return `[tool result] ${truncate(renderToolResult(block.content), 500)}`;
|
|
235
|
+
default:
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function renderToolResult(content) {
|
|
240
|
+
if (typeof content === "string")
|
|
241
|
+
return content;
|
|
242
|
+
if (Array.isArray(content)) {
|
|
243
|
+
return content.map((block) => (isRecord(block) ? (asString(block.text) ?? "") : "")).join("");
|
|
244
|
+
}
|
|
245
|
+
return compactJson(content);
|
|
246
|
+
}
|
|
247
|
+
function failureMessage(outcome) {
|
|
248
|
+
const stderr = outcome.stderr.trim();
|
|
249
|
+
return stderr || `claude exited with code ${outcome.exitCode ?? "unknown"}`;
|
|
250
|
+
}
|
|
251
|
+
// --- Shared helpers ---
|
|
252
|
+
function isRecord(value) {
|
|
253
|
+
return typeof value === "object" && value !== null;
|
|
254
|
+
}
|
|
255
|
+
function asString(value) {
|
|
256
|
+
return typeof value === "string" ? value : undefined;
|
|
257
|
+
}
|
|
258
|
+
function compactJson(value) {
|
|
259
|
+
try {
|
|
260
|
+
return truncate(JSON.stringify(value) ?? "", 500);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function truncate(text, max) {
|
|
267
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
268
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SessionFrontmatter {
|
|
2
|
+
status: "running" | "succeeded" | "failed";
|
|
3
|
+
protocol: string | null;
|
|
4
|
+
ticket: string | null;
|
|
5
|
+
model: string | null;
|
|
6
|
+
sessionId: string | null;
|
|
7
|
+
command: string;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
endedAt: string | null;
|
|
10
|
+
exitReason: string | null;
|
|
11
|
+
}
|
|
12
|
+
export declare const RESULT_MARKER = "\n---- Result ----\n";
|
|
13
|
+
export declare function assertPlansGate(cwd: string): string | undefined;
|
|
14
|
+
export declare function resolveSessionFilePath(cwd: string, ticket: string | undefined, now: Date, fileExists?: (path: string) => boolean): string;
|
|
15
|
+
export declare function writeInitialSessionFile(sessionFilePath: string, frontmatter: SessionFrontmatter): void;
|
|
16
|
+
export interface CompletionUpdate {
|
|
17
|
+
status: "succeeded" | "failed";
|
|
18
|
+
endedAt: string;
|
|
19
|
+
exitReason: "completed" | "error" | "terminated";
|
|
20
|
+
sessionId: string | null;
|
|
21
|
+
result: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function applyCompletion(sessionFilePath: string, update: CompletionUpdate): void;
|
|
24
|
+
export declare function appendTranscript(sessionFilePath: string, text: string): void;
|
|
25
|
+
export interface SessionCompletion {
|
|
26
|
+
frontmatter: SessionFrontmatter;
|
|
27
|
+
result: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export declare function readCompletion(sessionFilePath: string): SessionCompletion;
|
|
30
|
+
export declare function serializeFrontmatter(frontmatter: SessionFrontmatter): string;
|
|
31
|
+
export declare function parseFrontmatter(block: string): SessionFrontmatter;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
export const RESULT_MARKER = "\n---- Result ----\n";
|
|
4
|
+
// The CWD must be an AlignFirst-managed project (a `.plans/` dir marks it). Returns an error message
|
|
5
|
+
// when the gate fails, `undefined` when it passes. `--guide`/`--help` bypass this.
|
|
6
|
+
export function assertPlansGate(cwd) {
|
|
7
|
+
if (existsSync(join(cwd, ".plans")))
|
|
8
|
+
return;
|
|
9
|
+
return ("Error: no `.plans/` directory found in the current directory. " +
|
|
10
|
+
"Run alcode from the root of an AlignFirst-managed project.");
|
|
11
|
+
}
|
|
12
|
+
export function resolveSessionFilePath(cwd, ticket, now, fileExists = existsSync) {
|
|
13
|
+
const dir = ticket
|
|
14
|
+
? join(cwd, ".plans", ticket, "coding-sessions")
|
|
15
|
+
: join(cwd, ".plans", "_coding-sessions");
|
|
16
|
+
const stamp = formatStamp(now);
|
|
17
|
+
let candidate = join(dir, `${stamp}.md`);
|
|
18
|
+
let suffix = 2;
|
|
19
|
+
while (fileExists(candidate)) {
|
|
20
|
+
candidate = join(dir, `${stamp}-${suffix}.md`);
|
|
21
|
+
++suffix;
|
|
22
|
+
}
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
// Writes the initial `running` header. Present before the run starts so an interrupted run still
|
|
26
|
+
// leaves an auditable record; the terminal frontmatter rewrite happens on completion.
|
|
27
|
+
export function writeInitialSessionFile(sessionFilePath, frontmatter) {
|
|
28
|
+
const header = `${serializeFrontmatter(frontmatter)}\n`;
|
|
29
|
+
mkdirSync(dirname(sessionFilePath), { recursive: true });
|
|
30
|
+
writeFileSync(sessionFilePath, header);
|
|
31
|
+
}
|
|
32
|
+
// Appends the result block, then rewrites the frontmatter in place. Append-first keeps the tailed
|
|
33
|
+
// transcript intact until the single terminal rewrite.
|
|
34
|
+
export function applyCompletion(sessionFilePath, update) {
|
|
35
|
+
const content = readFileSync(sessionFilePath, "utf-8");
|
|
36
|
+
const { frontmatter, body } = splitSessionFile(content);
|
|
37
|
+
const updated = {
|
|
38
|
+
...frontmatter,
|
|
39
|
+
status: update.status,
|
|
40
|
+
endedAt: update.endedAt,
|
|
41
|
+
exitReason: update.exitReason,
|
|
42
|
+
sessionId: update.sessionId ?? frontmatter.sessionId,
|
|
43
|
+
};
|
|
44
|
+
const resultBlock = `${RESULT_MARKER}\n${update.result}\n`;
|
|
45
|
+
writeFileSync(sessionFilePath, `${serializeFrontmatter(updated)}\n${body}${resultBlock}`);
|
|
46
|
+
}
|
|
47
|
+
export function appendTranscript(sessionFilePath, text) {
|
|
48
|
+
writeFileSync(sessionFilePath, text, { flag: "a" });
|
|
49
|
+
}
|
|
50
|
+
// Reads back a completed (or in-progress) session file — the durable result handoff a waking
|
|
51
|
+
// OpenClaw agent or a human reads: frontmatter status/sessionId plus the `---- Result ----` block.
|
|
52
|
+
export function readCompletion(sessionFilePath) {
|
|
53
|
+
const content = readFileSync(sessionFilePath, "utf-8");
|
|
54
|
+
const { frontmatter } = splitSessionFile(content);
|
|
55
|
+
// `lastIndexOf`, not `indexOf`: `applyCompletion` always appends the real result block last, so a
|
|
56
|
+
// `---- Result ----` echoed earlier in the transcript body (e.g. the agent printing a session
|
|
57
|
+
// file) must not truncate the actual result.
|
|
58
|
+
const markerIndex = content.lastIndexOf(RESULT_MARKER);
|
|
59
|
+
const result = markerIndex === -1 ? undefined : content.slice(markerIndex + RESULT_MARKER.length).trim();
|
|
60
|
+
return { frontmatter, result };
|
|
61
|
+
}
|
|
62
|
+
// --- Frontmatter serialization (dependency-free, round-trips with parseFrontmatter) ---
|
|
63
|
+
export function serializeFrontmatter(frontmatter) {
|
|
64
|
+
const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${serializeValue(value)}`);
|
|
65
|
+
return `---\n${lines.join("\n")}\n---\n`;
|
|
66
|
+
}
|
|
67
|
+
function serializeValue(value) {
|
|
68
|
+
if (value === null)
|
|
69
|
+
return "";
|
|
70
|
+
return needsQuote(value) ? JSON.stringify(value) : value;
|
|
71
|
+
}
|
|
72
|
+
function needsQuote(value) {
|
|
73
|
+
return value === "" || /[:"#]/.test(value) || value !== value.trim();
|
|
74
|
+
}
|
|
75
|
+
function splitSessionFile(content) {
|
|
76
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
77
|
+
if (!match)
|
|
78
|
+
throw new Error("Session file is missing its frontmatter block.");
|
|
79
|
+
return { frontmatter: parseFrontmatter(match[1]), body: content.slice(match[0].length) };
|
|
80
|
+
}
|
|
81
|
+
export function parseFrontmatter(block) {
|
|
82
|
+
const map = {};
|
|
83
|
+
for (const line of block.split("\n")) {
|
|
84
|
+
const index = line.indexOf(":");
|
|
85
|
+
if (index === -1)
|
|
86
|
+
continue;
|
|
87
|
+
const key = line.slice(0, index).trim();
|
|
88
|
+
map[key] = parseValue(line.slice(index + 1).trim());
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
status: map.status ?? "running",
|
|
92
|
+
protocol: map.protocol ?? null,
|
|
93
|
+
ticket: map.ticket ?? null,
|
|
94
|
+
model: map.model ?? null,
|
|
95
|
+
sessionId: map.sessionId ?? null,
|
|
96
|
+
command: map.command ?? "",
|
|
97
|
+
startedAt: map.startedAt ?? "",
|
|
98
|
+
endedAt: map.endedAt ?? null,
|
|
99
|
+
exitReason: map.exitReason ?? null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function parseValue(raw) {
|
|
103
|
+
if (raw === "")
|
|
104
|
+
return null;
|
|
105
|
+
if (!raw.startsWith('"'))
|
|
106
|
+
return raw;
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A partially-written or hand-edited file may hold an unterminated/invalid quoted value; fall
|
|
112
|
+
// back to the raw text so the session file stays readable and sealable instead of crashing.
|
|
113
|
+
return raw;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Local `YYYYMMDD-HHMMSS`.
|
|
117
|
+
function formatStamp(now) {
|
|
118
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
119
|
+
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
120
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
121
|
+
return `${date}-${time}`;
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paleo/alcode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "CC0-1.0",
|
|
5
|
+
"author": "Thomas MUR",
|
|
6
|
+
"description": "Run a coding agent through AlignFirst protocols, with a durable session file per run.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"ai",
|
|
9
|
+
"agent",
|
|
10
|
+
"alignfirst",
|
|
11
|
+
"cli",
|
|
12
|
+
"claude"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/paleo/alignfirst.git",
|
|
17
|
+
"directory": "packages/alcode"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.11.0"
|
|
21
|
+
},
|
|
22
|
+
"packageManager": "npm@11.12.1",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"alcode": "bin/alcode.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"dist",
|
|
30
|
+
"templates"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"clear": "rimraf dist/*",
|
|
38
|
+
"lint": "biome check",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "~24.12.4",
|
|
43
|
+
"rimraf": "~6.1.3",
|
|
44
|
+
"typescript": "~6.0.3",
|
|
45
|
+
"vitest": "~4.1.7"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Under OpenClaw, background it through the `exec` tool:
|
|
2
|
+
|
|
3
|
+
- Pass `background: true` and `timeout: 0` (no kill timer). Never rely on the auto-yield or a finite timeout.
|
|
4
|
+
- Set the exec `workdir` to the project root as an **absolute** path (`~` is not expanded there), or `cd` into the project inside the command itself.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
When your platform tells you the backgrounded `alcode` exited, do exactly this:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
OpenClaw wakes this session when the backgrounded `alcode` exits. The wake may arrive as a **plain heartbeat poll with no message text**. Do not wait for an explicit "exec finished" notice. Whenever you are woken, or receive any heartbeat, while an `alcode` run is pending, do exactly this:
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# AlignFirst Delegation Guide{{TITLE-SUFFIX}}
|
|
2
|
+
|
|
3
|
+
Run a coding agent through AlignFirst protocols with the `alcode` CLI. It wraps a coding-agent CLI for non-interactive use: it invokes a protocol, streams the run to a session file, and returns the result.
|
|
4
|
+
|
|
5
|
+
**Never implement, investigate, or modify the codebase yourself while delegating. Your role is to delegate and guide the agent.**
|
|
6
|
+
|
|
7
|
+
Run `alcode` from the root of the target project, so the agent works in the right repo. The project must contain a `.plans/` directory.
|
|
8
|
+
|
|
9
|
+
## How it runs
|
|
10
|
+
|
|
11
|
+
`alcode` runs the coding agent in the **foreground** and blocks until it finishes, streaming the transcript to stdout as it arrives. It never backgrounds or detaches itself.
|
|
12
|
+
|
|
13
|
+
Coding runs can be long (several hours is fine): **always run `alcode` as a background task**, so you stay free while it works. The backgrounding is **your platform's** job, never alcode's own.
|
|
14
|
+
|
|
15
|
+
{{RUN}}
|
|
16
|
+
|
|
17
|
+
As soon as the run is backgrounded, tell the user the coding agent is now working in the background and that you will report back when it finishes (e.g. *"Le coding agent tourne en arrière-plan — je te préviens dès que c'est terminé."*). Then go available. Do **not** poll.
|
|
18
|
+
|
|
19
|
+
Every run writes a session file under `.plans/`: `.plans/<ticket>/coding-sessions/<stamp>.md`, or `.plans/_coding-sessions/<stamp>.md` without a ticket. This file is the durable record of the run. Its frontmatter carries `status` (`running` → `succeeded`/`failed`) and the `sessionId`, and the `---- Result ----` block holds the outcome.
|
|
20
|
+
|
|
21
|
+
## After a background run completes
|
|
22
|
+
|
|
23
|
+
{{WAKE}}
|
|
24
|
+
|
|
25
|
+
1. **Read the run's session file** (the path `alcode` printed on its first line, under `coding-sessions/`). Its frontmatter holds `status` (`succeeded` / `failed`) and the session id; the `---- Result ----` block holds the outcome.
|
|
26
|
+
2. **Report the outcome to the user** where the work was requested. Send one concise message: succeeded or failed, plus a one-line summary of the result for the audience.
|
|
27
|
+
3. Do **not** re-verify the repo, re-run the coding agent, fetch/merge branches, or inspect `git`. The coding agent already did the work and the session file is authoritative. Relay its outcome, nothing more.
|
|
28
|
+
|
|
29
|
+
If the session file says the run failed, report that plainly and propose the next step; don't silently retry.
|
|
30
|
+
|
|
31
|
+
## CLI reference
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
alcode --new --protocol <protocol> --ticket <id> [--message "..."]
|
|
35
|
+
alcode --new --message "..."
|
|
36
|
+
alcode --resume <sessionId> [--protocol <protocol>] [--message "..."]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Flag | Description |
|
|
40
|
+
|------|-------------|
|
|
41
|
+
| `--new` | Start a new session. |
|
|
42
|
+
| `--resume <id>` | Continue an existing session. |
|
|
43
|
+
| `--protocol <p>` | One of `spec`, `plan`, `aad`, `description`, `read`, `review`, `merge`. Optional. |
|
|
44
|
+
| `--ticket <id>` | Ticket ID. Required with `--new` + `--protocol`. |
|
|
45
|
+
| `--message "..."` | Message to send. Required for `spec`, `aad`, and when no `--protocol`. |
|
|
46
|
+
| `--model <model>` | Model override. |
|
|
47
|
+
|
|
48
|
+
For `--new` runs, the `Session ID:` is printed to stdout and written to the session file frontmatter (the durable source of truth). Save it to resume the conversation later.
|
|
49
|
+
|
|
50
|
+
**No protocol:** the message is sent as-is (no AlignFirst command). Use it to answer the agent's questions in an existing session, execute a plan in a new session, or ask a question:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
alcode --resume <sessionId> --message "Your answer"
|
|
54
|
+
alcode --new --message "Execute the plan: \`.plans/AB-123/A2-plan.md\`"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
When asking a question (not executing a plan) with `--new` and no protocol, the agent will try to implement by default. End the message with a constraint: *"Do not implement anything. We need to talk first."*
|
|
58
|
+
|
|
59
|
+
## Spec-Plan-Execute workflow
|
|
60
|
+
|
|
61
|
+
The default workflow. Always start with it, except for very insignificant tasks.
|
|
62
|
+
|
|
63
|
+
1. **Spec** — `alcode --new --protocol spec --ticket AB-123 --message "Feature description"`. The agent investigates and asks questions; save the session id. Iterate until it writes the spec file.
|
|
64
|
+
2. **Plan** — `alcode --resume <sessionId> --protocol plan`. The agent writes the plan file.
|
|
65
|
+
3. **Execute** — `alcode --new --message "Execute the plan: \`.plans/AB-123/A2-plan.md\`"`. The agent implements and writes a summary file.
|
|
66
|
+
4. **Commit** — use the suggested commit message from the spec file.
|
|
67
|
+
|
|
68
|
+
## Light workflow (AAD)
|
|
69
|
+
|
|
70
|
+
For one-shot changes or follow-up adjustments right after executing a plan. The agent investigates, discusses, then implements in one session.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
alcode --new --protocol aad --ticket AB-123 --message "Task description"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Answer questions as in the spec flow. The agent implements and writes a summary file, which carries a suggested commit message.
|
|
77
|
+
|
|
78
|
+
## Other protocols
|
|
79
|
+
|
|
80
|
+
- **description** — `alcode --new --protocol description --ticket AB-123`. Writes a PR/MR description for committed work. No discussion.
|
|
81
|
+
- **read** — `alcode --new --protocol read --ticket AB-123 [--message "..."]`. Loads the ticket's spec and summary files into context; with a message, answers it against that context.
|
|
82
|
+
- **review** — `alcode --new --protocol review --ticket AB-123`. Reviews the current branch against the base and writes a review file.
|
|
83
|
+
- **merge** — `alcode --new --protocol merge --ticket AB-123`. Resolves conflicts and summarizes tricky resolutions. Pass the incoming branch via `--message` to start the merge.
|
|
84
|
+
|
|
85
|
+
## Answering agent questions
|
|
86
|
+
|
|
87
|
+
During spec and AAD sessions the agent asks questions before proceeding. Resume **without a protocol** to answer. Answer all questions in one message, numbered to match:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
alcode --resume <sessionId> --message \
|
|
91
|
+
"1 - Explore the codebase and give me your opinion.
|
|
92
|
+
2 - Is that a good design? We need the cleanest code possible.
|
|
93
|
+
3 - Yes, it should be optional."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Technical questions** — architecture, patterns, existing behavior, anything answerable by reading the code. Never escalate these to the user. Push the agent to investigate: *"Explore the codebase to find out, and give me your opinion."*, *"Do not rush. Take the time to fully understand the situation first."*, *"What would be the most elegant way to do it?"*, *"Check if a similar pattern is already implemented elsewhere in the codebase."*
|
|
97
|
+
|
|
98
|
+
**Functional or UX questions** — product behavior, user-facing decisions, business rules. These need human judgement: escalate to your user, then relay the answer.
|
|
99
|
+
|
|
100
|
+
When in doubt, ask the agent to explore first. Escalate only when the question truly cannot be answered from the codebase.
|