aligndev 0.0.0 → 0.19.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 +166 -1
- package/bin/aligndev.mjs +3 -0
- package/dist/alignfirst-cli.d.ts +10 -0
- package/dist/alignfirst-cli.js +56 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +95 -0
- package/dist/code/claude-agent.d.ts +14 -0
- package/dist/code/claude-agent.js +168 -0
- package/dist/code/code-cli.d.ts +72 -0
- package/dist/code/code-cli.js +630 -0
- package/dist/code/codex-agent.d.ts +15 -0
- package/dist/code/codex-agent.js +168 -0
- package/dist/code/codex-rollout.d.ts +18 -0
- package/dist/code/codex-rollout.js +114 -0
- package/dist/code/coding-agent.d.ts +4 -0
- package/dist/code/coding-agent.js +6 -0
- package/dist/code/models.d.ts +14 -0
- package/dist/code/models.js +89 -0
- package/dist/code/prompt.d.ts +11 -0
- package/dist/code/prompt.js +26 -0
- package/dist/code/quota.d.ts +25 -0
- package/dist/code/quota.js +248 -0
- package/dist/code/run-agent.d.ts +60 -0
- package/dist/code/run-agent.js +212 -0
- package/dist/code/session-file.d.ts +50 -0
- package/dist/code/session-file.js +263 -0
- package/dist/command-form.d.ts +6 -0
- package/dist/command-form.js +7 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +72 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +6 -0
- package/dist/guide/code-guide.d.ts +4 -0
- package/dist/guide/code-guide.js +17 -0
- package/dist/guide/guide-cli.d.ts +3 -0
- package/dist/guide/guide-cli.js +81 -0
- package/dist/guide/render-template.d.ts +4 -0
- package/dist/guide/render-template.js +62 -0
- package/dist/guide/topics.d.ts +3 -0
- package/dist/guide/topics.js +16 -0
- package/dist/output.d.ts +3 -0
- package/dist/output.js +1 -0
- package/dist/project/discovery.d.ts +42 -0
- package/dist/project/discovery.js +287 -0
- package/dist/project/format.d.ts +6 -0
- package/dist/project/format.js +31 -0
- package/dist/project/guide.d.ts +3 -0
- package/dist/project/guide.js +69 -0
- package/dist/project/layout.d.ts +36 -0
- package/dist/project/layout.js +128 -0
- package/dist/project/markers.d.ts +18 -0
- package/dist/project/markers.js +90 -0
- package/dist/project/ports.d.ts +3 -0
- package/dist/project/ports.js +55 -0
- package/dist/project/project-cli.d.ts +17 -0
- package/dist/project/project-cli.js +227 -0
- package/dist/project/render.d.ts +10 -0
- package/dist/project/render.js +144 -0
- package/dist/project/status.d.ts +24 -0
- package/dist/project/status.js +110 -0
- package/dist/templates.d.ts +1 -0
- package/dist/templates.js +5 -0
- package/package.json +38 -3
- package/templates/guide/code.md +252 -0
- package/templates/guide/playbook/channel-handling.md +110 -0
- package/templates/guide/playbook/consultation.md +71 -0
- package/templates/guide/playbook/discord-message-tool.md +37 -0
- package/templates/guide/playbook/playbook.md +139 -0
- package/templates/guide/playbook/project-lifecycle.md +100 -0
- package/templates/guide/playbook/project-workspace-setup.md +186 -0
- package/templates/guide/playbook/slack-message-tool.md +23 -0
- package/templates/guide/playbook/working-session.md +588 -0
- package/templates/guide/project.md +33 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadCatchup, loadContext, openTicket, reserveSideTicket } from "../alignfirst-cli.js";
|
|
5
|
+
import { errorMessage } from "../errors.js";
|
|
6
|
+
import { companionInUse, readProjectReport, } from "../project/layout.js";
|
|
7
|
+
import { createAgentAdapter } from "./coding-agent.js";
|
|
8
|
+
import { resolveModels } from "./models.js";
|
|
9
|
+
import { buildPrompt, PROTOCOLS } from "./prompt.js";
|
|
10
|
+
import { buildAgentEnv, runAgent } from "./run-agent.js";
|
|
11
|
+
import { applyCompletion, findNewestSessionFile, listSessionRecords, readPidStartTime, reconcileSessionFile, resolveSessionFilePath, writeInitialSessionFile, } from "./session-file.js";
|
|
12
|
+
// Distinct from 1 (ordinary run failure) so a script can branch on an auth failure that needs an
|
|
13
|
+
// operator re-login rather than a retry.
|
|
14
|
+
const EXIT_AUTH_REQUIRED = 2;
|
|
15
|
+
const SESSION_OPTIONS = {
|
|
16
|
+
protocol: { type: "string" },
|
|
17
|
+
catchup: { type: "boolean", default: false },
|
|
18
|
+
"message-file": { type: "string" },
|
|
19
|
+
ticket: { type: "string" },
|
|
20
|
+
message: { type: "string", short: "m" },
|
|
21
|
+
model: { type: "string" },
|
|
22
|
+
meta: { type: "string" },
|
|
23
|
+
help: { type: "boolean", short: "h", default: false },
|
|
24
|
+
};
|
|
25
|
+
// Items whose companion copy the coder may edit: the companion becomes a writable directory.
|
|
26
|
+
const WRITABLE_COMPANION_ITEMS = [
|
|
27
|
+
".alignfirst.json",
|
|
28
|
+
".alignfirst.md",
|
|
29
|
+
"DEVELOPERS.md",
|
|
30
|
+
"docs",
|
|
31
|
+
".plans",
|
|
32
|
+
];
|
|
33
|
+
// Items the coder would not find in the repository: a new session gets `alignfirst context`.
|
|
34
|
+
const CONTEXT_COMPANION_ITEMS = [
|
|
35
|
+
".alignfirst.json",
|
|
36
|
+
".alignfirst.md",
|
|
37
|
+
"docs",
|
|
38
|
+
".plans",
|
|
39
|
+
];
|
|
40
|
+
const TICKET_PATH_ERROR = "Error: --ticket must be a single path segment " +
|
|
41
|
+
"(letters, digits, '.', '-', '_'); no path separators or '..'.";
|
|
42
|
+
export async function runCode(tokens, config, ctx) {
|
|
43
|
+
try {
|
|
44
|
+
const command = parseCodeArgs(tokens, ctx.forms.aligndev);
|
|
45
|
+
if (command.kind === "status")
|
|
46
|
+
return showStatus(ctx, command.target);
|
|
47
|
+
const { code } = config;
|
|
48
|
+
if (command.kind === "quota")
|
|
49
|
+
return await showQuota(ctx, code);
|
|
50
|
+
const models = resolveModels(code.agent, code.models);
|
|
51
|
+
if (command.kind === "help") {
|
|
52
|
+
ctx.stdout.write(renderHelp(code.agent, models, ctx.forms));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
loadMessage(command.args, ctx.cwd);
|
|
56
|
+
const validationError = validateSessionArgs(command.args, models);
|
|
57
|
+
if (validationError) {
|
|
58
|
+
ctx.stderr.write(`${validationError}\n`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
return await runSession(command.args, code, ctx);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
ctx.stderr.write(`${errorMessage(error)}\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function showStatus(ctx, target) {
|
|
69
|
+
const tree = sessionTreeOf(readReport(ctx), ctx.cwd);
|
|
70
|
+
const sessionFilePath = resolveStatusTargetSessionFile(tree, target);
|
|
71
|
+
const completion = reconcileSessionFile(sessionFilePath);
|
|
72
|
+
ctx.stdout.write(renderSessionStatus(tree.cwd, sessionFilePath, completion.frontmatter));
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
function readReport(ctx) {
|
|
76
|
+
const report = readProjectReport(ctx.alignfirstCommand, ctx.cwd, ctx.env);
|
|
77
|
+
if ("error" in report)
|
|
78
|
+
throw new Error(report.error);
|
|
79
|
+
return report;
|
|
80
|
+
}
|
|
81
|
+
function sessionTreeOf(report, cwd) {
|
|
82
|
+
return {
|
|
83
|
+
cwd: realpathSync(cwd),
|
|
84
|
+
sessionsDir: report.locations._aligndev.path,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function showQuota(ctx, code) {
|
|
88
|
+
const report = await ctx.quotaReader(code.agent, {
|
|
89
|
+
cwd: ctx.cwd,
|
|
90
|
+
env: ctx.env,
|
|
91
|
+
unset: code.unset,
|
|
92
|
+
});
|
|
93
|
+
ctx.stdout.write(`${report.trimEnd()}\n`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
function resolveStatusTargetSessionFile(tree, target) {
|
|
97
|
+
if (target.kind === "file")
|
|
98
|
+
return resolveStatusSessionFile(tree, target.sessionFile);
|
|
99
|
+
if (target.kind === "meta")
|
|
100
|
+
return resolveMetaSessionFile(tree, target.meta);
|
|
101
|
+
const dir = join(tree.sessionsDir, ...(target.kind === "ticket" ? [target.ticket] : []), "_aligndev");
|
|
102
|
+
const sessionFilePath = findNewestSessionFile(dir);
|
|
103
|
+
if (sessionFilePath === undefined) {
|
|
104
|
+
throw new Error(`Error: no session file under ${displayPath(tree.cwd, dir)}/.`);
|
|
105
|
+
}
|
|
106
|
+
return resolveStatusSessionFile(tree, sessionFilePath);
|
|
107
|
+
}
|
|
108
|
+
// A run tagged with `--meta <key>` is found by that key alone: the session tree is shared across
|
|
109
|
+
// worktrees, so its newest run may belong to another thread.
|
|
110
|
+
function resolveMetaSessionFile(tree, meta) {
|
|
111
|
+
const matches = listSessionRecords(tree.sessionsDir).filter((record) => record.frontmatter.meta === meta);
|
|
112
|
+
if (matches.length === 0)
|
|
113
|
+
throw new Error(`Error: no session file with meta "${meta}".`);
|
|
114
|
+
const newest = matches.reduce((a, b) => a.frontmatter.startedAt >= b.frontmatter.startedAt ? a : b);
|
|
115
|
+
return resolveStatusSessionFile(tree, newest.path);
|
|
116
|
+
}
|
|
117
|
+
function loadMessage(args, cwd) {
|
|
118
|
+
if (args.messageFile === undefined)
|
|
119
|
+
return;
|
|
120
|
+
if (args.message !== undefined) {
|
|
121
|
+
throw new Error("Error: --message and --message-file are mutually exclusive.");
|
|
122
|
+
}
|
|
123
|
+
args.message = readFileSync(args.messageFile === "-" ? 0 : resolve(cwd, args.messageFile), "utf8");
|
|
124
|
+
}
|
|
125
|
+
function resolveStatusSessionFile(tree, input) {
|
|
126
|
+
const sessionFilePath = resolve(tree.cwd, input);
|
|
127
|
+
const sessionsDir = displayPath(tree.cwd, tree.sessionsDir);
|
|
128
|
+
if (!isSessionFilePath(tree.sessionsDir, sessionFilePath)) {
|
|
129
|
+
throw new Error(`Error: status requires a session file under ${sessionsDir}/_aligndev/ or ` +
|
|
130
|
+
`${sessionsDir}/<ticket>/_aligndev/.`);
|
|
131
|
+
}
|
|
132
|
+
if (!existsSync(sessionFilePath)) {
|
|
133
|
+
throw new Error(`Error: session file not found: ${displayPath(tree.cwd, sessionFilePath)}`);
|
|
134
|
+
}
|
|
135
|
+
if (!isSessionFilePath(realpathSync(tree.sessionsDir), realpathSync(sessionFilePath))) {
|
|
136
|
+
throw new Error(`Error: the session file resolves outside ${sessionsDir}/.`);
|
|
137
|
+
}
|
|
138
|
+
return sessionFilePath;
|
|
139
|
+
}
|
|
140
|
+
// `_aligndev/<name>.md` or `<ticket>/_aligndev/<name>.md`, relative to the sessions directory.
|
|
141
|
+
function isSessionFilePath(sessionsDir, path) {
|
|
142
|
+
const childPath = relative(sessionsDir, path);
|
|
143
|
+
if (isAbsolute(childPath) || extname(path) !== ".md")
|
|
144
|
+
return false;
|
|
145
|
+
const segments = childPath.split(sep);
|
|
146
|
+
if (segments.length === 2)
|
|
147
|
+
return segments[0] === "_aligndev";
|
|
148
|
+
return segments.length === 3 && segments[0] !== ".." && segments[1] === "_aligndev";
|
|
149
|
+
}
|
|
150
|
+
// Relative to the working directory when inside it, absolute otherwise.
|
|
151
|
+
function displayPath(cwd, path) {
|
|
152
|
+
const childPath = relative(cwd, path);
|
|
153
|
+
if (childPath === "" ||
|
|
154
|
+
childPath === ".." ||
|
|
155
|
+
childPath.startsWith(`..${sep}`) ||
|
|
156
|
+
isAbsolute(childPath)) {
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
return childPath;
|
|
160
|
+
}
|
|
161
|
+
function renderSessionStatus(cwd, sessionFilePath, frontmatter) {
|
|
162
|
+
return [
|
|
163
|
+
`sessionFile: ${displayPath(cwd, sessionFilePath)}`,
|
|
164
|
+
`sessionId: ${frontmatter.sessionId ?? ""}`,
|
|
165
|
+
`status: ${frontmatter.status}`,
|
|
166
|
+
`pid: ${frontmatter.pid ?? ""}`,
|
|
167
|
+
`startedAt: ${frontmatter.startedAt}`,
|
|
168
|
+
`endedAt: ${frontmatter.endedAt ?? ""}`,
|
|
169
|
+
`exitReason: ${frontmatter.exitReason ?? ""}`,
|
|
170
|
+
`contextTokens: ${frontmatter.contextTokens ?? ""}`,
|
|
171
|
+
`contextCompacted: ${frontmatter.contextCompacted}`,
|
|
172
|
+
`contextTokensError: ${frontmatter.contextTokensError ?? ""}`,
|
|
173
|
+
`meta: ${frontmatter.meta ?? ""}`,
|
|
174
|
+
"",
|
|
175
|
+
].join("\n");
|
|
176
|
+
}
|
|
177
|
+
export function parseCodeArgs(tokens, aligndev) {
|
|
178
|
+
const [command, ...rest] = tokens;
|
|
179
|
+
switch (command) {
|
|
180
|
+
case undefined:
|
|
181
|
+
throw new Error(`Error: no command given. Run \`${aligndev} code --help\`.`);
|
|
182
|
+
case "--help":
|
|
183
|
+
case "-h":
|
|
184
|
+
return { kind: "help" };
|
|
185
|
+
case "new":
|
|
186
|
+
return parseNewCommand(rest);
|
|
187
|
+
case "resume":
|
|
188
|
+
return parseResumeCommand(rest);
|
|
189
|
+
case "status":
|
|
190
|
+
return parseStatusCommand(rest);
|
|
191
|
+
case "quota":
|
|
192
|
+
return parseBareCommand(rest, "quota");
|
|
193
|
+
default:
|
|
194
|
+
throw new Error(`Error: unknown command "${command}". Run \`${aligndev} code --help\`.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function parseStatusCommand(tokens) {
|
|
198
|
+
const { values, positionals } = parseArgs({
|
|
199
|
+
args: tokens,
|
|
200
|
+
options: {
|
|
201
|
+
ticket: { type: "string" },
|
|
202
|
+
"no-ticket": { type: "boolean", default: false },
|
|
203
|
+
meta: { type: "string" },
|
|
204
|
+
help: { type: "boolean", short: "h", default: false },
|
|
205
|
+
},
|
|
206
|
+
strict: true,
|
|
207
|
+
allowPositionals: true,
|
|
208
|
+
});
|
|
209
|
+
if (values.help)
|
|
210
|
+
return { kind: "help" };
|
|
211
|
+
const targetCount = positionals.length +
|
|
212
|
+
Number(values.ticket !== undefined) +
|
|
213
|
+
Number(values["no-ticket"]) +
|
|
214
|
+
Number(values.meta !== undefined);
|
|
215
|
+
if (targetCount !== 1) {
|
|
216
|
+
throw new Error("Error: `aligndev code status` takes exactly one of <session-file>, --ticket <id>, --no-ticket " +
|
|
217
|
+
"or --meta <key>.");
|
|
218
|
+
}
|
|
219
|
+
if (values.meta !== undefined)
|
|
220
|
+
return { kind: "status", target: { kind: "meta", meta: values.meta } };
|
|
221
|
+
if (values.ticket !== undefined) {
|
|
222
|
+
if (!isPathSafeTicket(values.ticket))
|
|
223
|
+
throw new Error(TICKET_PATH_ERROR);
|
|
224
|
+
return { kind: "status", target: { kind: "ticket", ticket: values.ticket } };
|
|
225
|
+
}
|
|
226
|
+
if (values["no-ticket"])
|
|
227
|
+
return { kind: "status", target: { kind: "noTicket" } };
|
|
228
|
+
return { kind: "status", target: { kind: "file", sessionFile: positionals[0] } };
|
|
229
|
+
}
|
|
230
|
+
function parseNewCommand(tokens) {
|
|
231
|
+
const { values } = parseArgs({
|
|
232
|
+
args: tokens,
|
|
233
|
+
options: { ...SESSION_OPTIONS, "no-ticket": { type: "boolean", default: false } },
|
|
234
|
+
strict: true,
|
|
235
|
+
});
|
|
236
|
+
if (values.help)
|
|
237
|
+
return { kind: "help" };
|
|
238
|
+
return {
|
|
239
|
+
kind: "session",
|
|
240
|
+
args: {
|
|
241
|
+
ticket: values.ticket,
|
|
242
|
+
noTicket: values["no-ticket"],
|
|
243
|
+
protocol: values.protocol,
|
|
244
|
+
catchup: values.catchup,
|
|
245
|
+
messageFile: values["message-file"],
|
|
246
|
+
message: values.message,
|
|
247
|
+
model: values.model,
|
|
248
|
+
meta: values.meta,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function parseResumeCommand(tokens) {
|
|
253
|
+
const { values, positionals } = parseArgs({
|
|
254
|
+
args: tokens,
|
|
255
|
+
options: SESSION_OPTIONS,
|
|
256
|
+
strict: true,
|
|
257
|
+
allowPositionals: true,
|
|
258
|
+
});
|
|
259
|
+
if (values.help)
|
|
260
|
+
return { kind: "help" };
|
|
261
|
+
if (positionals.length !== 1) {
|
|
262
|
+
throw new Error("Error: `aligndev code resume` takes exactly one <sessionId>.");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
kind: "session",
|
|
266
|
+
args: {
|
|
267
|
+
resume: positionals[0],
|
|
268
|
+
ticket: values.ticket,
|
|
269
|
+
noTicket: false,
|
|
270
|
+
protocol: values.protocol,
|
|
271
|
+
catchup: values.catchup,
|
|
272
|
+
messageFile: values["message-file"],
|
|
273
|
+
message: values.message,
|
|
274
|
+
model: values.model,
|
|
275
|
+
meta: values.meta,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function parseBareCommand(tokens, kind) {
|
|
280
|
+
const { values } = parseArgs({
|
|
281
|
+
args: tokens,
|
|
282
|
+
options: { help: { type: "boolean", short: "h", default: false } },
|
|
283
|
+
strict: true,
|
|
284
|
+
});
|
|
285
|
+
return values.help ? { kind: "help" } : { kind };
|
|
286
|
+
}
|
|
287
|
+
export function validateSessionArgs(args, models) {
|
|
288
|
+
const isNew = args.resume === undefined;
|
|
289
|
+
const hasMessage = args.message !== undefined && args.message.trim() !== "";
|
|
290
|
+
if (args.protocol !== undefined && !PROTOCOLS.includes(args.protocol)) {
|
|
291
|
+
return `Error: --protocol must be one of: ${PROTOCOLS.join(", ")}.`;
|
|
292
|
+
}
|
|
293
|
+
if (args.model !== undefined && !models.includes(args.model)) {
|
|
294
|
+
return `Error: --model must be one of: ${models.join(", ")}.`;
|
|
295
|
+
}
|
|
296
|
+
if (args.protocol === undefined && !hasMessage && args.catchup !== true) {
|
|
297
|
+
return "Error: --message is required when --protocol is not specified.";
|
|
298
|
+
}
|
|
299
|
+
if (!isNew && args.catchup === true) {
|
|
300
|
+
return "Error: --catchup is for `new` only; a resumed session already holds the history.";
|
|
301
|
+
}
|
|
302
|
+
if (args.ticket !== undefined && args.noTicket) {
|
|
303
|
+
return "Error: --ticket and --no-ticket are mutually exclusive.";
|
|
304
|
+
}
|
|
305
|
+
if (args.noTicket && args.protocol === undefined) {
|
|
306
|
+
return "Error: --no-ticket requires --protocol.";
|
|
307
|
+
}
|
|
308
|
+
if (isNew && args.protocol !== undefined && args.ticket === undefined && !args.noTicket) {
|
|
309
|
+
return "Error: --ticket or --no-ticket is required with `new --protocol`.";
|
|
310
|
+
}
|
|
311
|
+
if (["spec", "aad"].includes(args.protocol ?? "") && !hasMessage) {
|
|
312
|
+
return `Error: --protocol ${args.protocol} requires --message.`;
|
|
313
|
+
}
|
|
314
|
+
if (args.ticket !== undefined && !isPathSafeTicket(args.ticket)) {
|
|
315
|
+
return TICKET_PATH_ERROR;
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// The ticket becomes a `.plans/<ticket>/_aligndev/…` path segment. Ticket formats vary by
|
|
320
|
+
// consumer repo (numeric here, but e.g. `AB-123` elsewhere), so allow a permissive charset while
|
|
321
|
+
// blocking path separators and `..` traversal that could escape `.plans/`.
|
|
322
|
+
function isPathSafeTicket(ticket) {
|
|
323
|
+
return /^[A-Za-z0-9._-]+$/.test(ticket) && ticket !== "." && !ticket.includes("..");
|
|
324
|
+
}
|
|
325
|
+
// `aligndev code` always runs the selected coding agent in the foreground and blocks until it exits.
|
|
326
|
+
// The assistant backgrounds it with its own platform's facility (OpenClaw's `exec` tool, a coding
|
|
327
|
+
// agent's background task) — aligndev owns no backgrounding or callback of its own. The per-run session
|
|
328
|
+
// file is the durable result handoff: on completion the frontmatter carries the session id and
|
|
329
|
+
// status, and the `---- Result ----` block carries the outcome for a waking caller (or a human).
|
|
330
|
+
async function runSession(args, code, ctx) {
|
|
331
|
+
const { cwd, env, stdout, stderr, forms, alignfirstCommand, modelResolver } = ctx;
|
|
332
|
+
const { agent } = code;
|
|
333
|
+
const report = readReport(ctx);
|
|
334
|
+
const tree = sessionTreeOf(report, cwd);
|
|
335
|
+
const plans = report.locations[".plans"];
|
|
336
|
+
if (!plans.exists) {
|
|
337
|
+
stderr.write(`Error: no .plans/ directory at ${displayPath(tree.cwd, plans.path)}. ` +
|
|
338
|
+
`Create it, or run \`${forms.alignfirst} plans setup <clone>\`.\n`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
const records = listSessionRecords(tree.sessionsDir);
|
|
342
|
+
const guardError = checkLaunchGuards(args, agent, tree.cwd, records);
|
|
343
|
+
if (guardError) {
|
|
344
|
+
stderr.write(`${guardError}\n`);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
const now = new Date();
|
|
348
|
+
const ticket = args.noTicket
|
|
349
|
+
? reserveSideTicket(alignfirstCommand, cwd, env)
|
|
350
|
+
: resolveTicket(args, records);
|
|
351
|
+
if (ticket !== undefined && !args.noTicket && args.resume === undefined) {
|
|
352
|
+
openTicket(alignfirstCommand, cwd, ticket, env);
|
|
353
|
+
}
|
|
354
|
+
let catchupContent;
|
|
355
|
+
if (args.catchup === true) {
|
|
356
|
+
if (ticket === undefined) {
|
|
357
|
+
throw new Error("Error: --catchup requires a resolved ticket; provide --ticket <id>.");
|
|
358
|
+
}
|
|
359
|
+
catchupContent = loadCatchup(alignfirstCommand, cwd, ticket, env);
|
|
360
|
+
}
|
|
361
|
+
const contextContent = args.resume === undefined && companionInUse(report, CONTEXT_COMPANION_ITEMS)
|
|
362
|
+
? loadContext(alignfirstCommand, cwd, env)
|
|
363
|
+
: undefined;
|
|
364
|
+
const sessionFilePath = resolveSessionFilePath(tree.sessionsDir, ticket, now);
|
|
365
|
+
writeInitialSessionFile(sessionFilePath, buildFrontmatter(args, agent, now, tree.cwd, ticket));
|
|
366
|
+
stdout.write(`Session file: ${displayPath(tree.cwd, sessionFilePath)}\n\n`);
|
|
367
|
+
let executableModel;
|
|
368
|
+
try {
|
|
369
|
+
executableModel = await modelResolver(agent, args.model, {
|
|
370
|
+
cwd,
|
|
371
|
+
env: buildAgentEnv(env, code.unset),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
const message = errorMessage(error);
|
|
376
|
+
applyCompletion(sessionFilePath, {
|
|
377
|
+
status: "failed",
|
|
378
|
+
endedAt: new Date().toISOString(),
|
|
379
|
+
exitReason: "error",
|
|
380
|
+
sessionId: null,
|
|
381
|
+
result: message,
|
|
382
|
+
});
|
|
383
|
+
stderr.write(`${message}\n`);
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const result = await runAgent(buildRunConfig({
|
|
387
|
+
args,
|
|
388
|
+
code,
|
|
389
|
+
report,
|
|
390
|
+
ticket,
|
|
391
|
+
cwd,
|
|
392
|
+
sessionFilePath,
|
|
393
|
+
env,
|
|
394
|
+
executableModel,
|
|
395
|
+
catchupContent,
|
|
396
|
+
contextContent,
|
|
397
|
+
alignfirst: forms.alignfirst,
|
|
398
|
+
}), createAgentAdapter(agent), stdout);
|
|
399
|
+
if (args.resume === undefined && result.sessionId) {
|
|
400
|
+
stdout.write(`\nSession ID: ${result.sessionId}\n`);
|
|
401
|
+
}
|
|
402
|
+
if (result.authRequired) {
|
|
403
|
+
stderr.write("aligndev code: coding agent not authenticated — an administrator must re-login on the host " +
|
|
404
|
+
`${agent === "claude" ? "(`claude`, then `/login`)" : "(`codex login`)"}.\n`);
|
|
405
|
+
return EXIT_AUTH_REQUIRED;
|
|
406
|
+
}
|
|
407
|
+
return result.status === "succeeded" ? 0 : 1;
|
|
408
|
+
}
|
|
409
|
+
// Fail-fast launch guards, run against the (healed) session records before anything is written.
|
|
410
|
+
// Returns the error to print, or `undefined` when the launch may proceed.
|
|
411
|
+
export function checkLaunchGuards(args, agent, realCwd, records) {
|
|
412
|
+
if (args.resume !== undefined) {
|
|
413
|
+
// A resumed run writes a new session file carrying the same sessionId as the original, so one
|
|
414
|
+
// id can match several records — a running status on any of them blocks.
|
|
415
|
+
const matches = records.filter((r) => r.frontmatter.sessionId === args.resume);
|
|
416
|
+
if (matches.length === 0)
|
|
417
|
+
return unknownResumeError(args.resume, records);
|
|
418
|
+
const running = matches.find((r) => r.frontmatter.status === "running");
|
|
419
|
+
if (running) {
|
|
420
|
+
return (`Error: session ${args.resume} is still running (pid ${running.frontmatter.pid}); ` +
|
|
421
|
+
"wait for it to finish or kill it.");
|
|
422
|
+
}
|
|
423
|
+
const latest = [...matches].sort((a, b) => b.frontmatter.startedAt.localeCompare(a.frontmatter.startedAt))[0];
|
|
424
|
+
if (latest.frontmatter.agent === null) {
|
|
425
|
+
return (`Error: session ${args.resume} predates agent-aware sessions and cannot be resumed; ` +
|
|
426
|
+
"start a new session.");
|
|
427
|
+
}
|
|
428
|
+
if (latest.frontmatter.agent !== agent) {
|
|
429
|
+
return (`Error: session ${args.resume} belongs to agent ${latest.frontmatter.agent}, but the ` +
|
|
430
|
+
`selected agent is ${agent}.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Protocol runs only: plain messages (answers, questions, plan executions) may run at any time.
|
|
434
|
+
if (args.protocol !== undefined) {
|
|
435
|
+
const busy = records.find((r) => r.frontmatter.status === "running" && r.frontmatter.cwd === realCwd);
|
|
436
|
+
if (busy) {
|
|
437
|
+
return (`Error: a protocol run is already active in this worktree (${busy.path}, ` +
|
|
438
|
+
`pid ${busy.frontmatter.pid}); one protocol run at a time per worktree.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
function unknownResumeError(resume, records) {
|
|
444
|
+
if (records.length === 0) {
|
|
445
|
+
return `Error: unknown session id ${resume}; no session records exist.`;
|
|
446
|
+
}
|
|
447
|
+
const recent = [...records]
|
|
448
|
+
.sort((a, b) => b.frontmatter.startedAt.localeCompare(a.frontmatter.startedAt))
|
|
449
|
+
.slice(0, 5)
|
|
450
|
+
.map(({ frontmatter: f }) => {
|
|
451
|
+
return ` ${f.sessionId ?? "(no id)"} ${f.status} ${f.startedAt} ticket ${f.ticket ?? "-"}`;
|
|
452
|
+
});
|
|
453
|
+
return `Error: unknown session id ${resume}. Known recent sessions:\n${recent.join("\n")}`;
|
|
454
|
+
}
|
|
455
|
+
// The effective ticket scopes the session file to `<ticket>/_aligndev/`, lands in the
|
|
456
|
+
// frontmatter, and reaches the agent in the prompt. Exported for tests. Precedence: explicit
|
|
457
|
+
// `--ticket`, then the resumed session's records, then a `.plans/<ticket>/` path in the message.
|
|
458
|
+
export function resolveTicket(args, records) {
|
|
459
|
+
if (args.ticket !== undefined)
|
|
460
|
+
return args.ticket;
|
|
461
|
+
if (args.resume !== undefined)
|
|
462
|
+
return inheritTicketFromResume(args.resume, records);
|
|
463
|
+
if (args.message !== undefined)
|
|
464
|
+
return inferTicketFromMessage(args.message);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// Latest record of the resumed session that carries a ticket — the resumed run keeps writing
|
|
468
|
+
// under the same ticket directory, and its frontmatter keeps the ticket for later resumes.
|
|
469
|
+
function inheritTicketFromResume(resume, records) {
|
|
470
|
+
const ticketed = records.filter((r) => r.frontmatter.sessionId === resume && r.frontmatter.ticket !== null);
|
|
471
|
+
const latest = ticketed.sort((a, b) => b.frontmatter.startedAt.localeCompare(a.frontmatter.startedAt))[0];
|
|
472
|
+
return latest?.frontmatter.ticket ?? undefined;
|
|
473
|
+
}
|
|
474
|
+
// A message like `Execute the plan: .plans/2/B2-plan.md` names its ticket. `_`-prefixed segments
|
|
475
|
+
// (e.g. `_aligndev`) are not tickets; several distinct candidates mean the message is ambiguous.
|
|
476
|
+
function inferTicketFromMessage(message) {
|
|
477
|
+
const candidates = new Set();
|
|
478
|
+
for (const match of message.matchAll(/\.plans\/([A-Za-z0-9._-]+)\//g)) {
|
|
479
|
+
const segment = match[1];
|
|
480
|
+
if (!segment.startsWith("_") && isPathSafeTicket(segment))
|
|
481
|
+
candidates.add(segment);
|
|
482
|
+
}
|
|
483
|
+
if (candidates.size !== 1)
|
|
484
|
+
return;
|
|
485
|
+
return [...candidates][0];
|
|
486
|
+
}
|
|
487
|
+
function buildFrontmatter(args, agent, now, realCwd, ticket) {
|
|
488
|
+
return {
|
|
489
|
+
status: "running",
|
|
490
|
+
agent,
|
|
491
|
+
protocol: args.protocol ?? null,
|
|
492
|
+
ticket: ticket ?? null,
|
|
493
|
+
model: args.model ?? null,
|
|
494
|
+
sessionId: null,
|
|
495
|
+
command: formatCommand(args),
|
|
496
|
+
meta: args.meta ?? null,
|
|
497
|
+
pid: process.pid,
|
|
498
|
+
pidStartTime: readPidStartTime(process.pid),
|
|
499
|
+
cwd: realCwd,
|
|
500
|
+
startedAt: now.toISOString(),
|
|
501
|
+
endedAt: null,
|
|
502
|
+
exitReason: null,
|
|
503
|
+
contextTokens: null,
|
|
504
|
+
contextCompacted: false,
|
|
505
|
+
contextTokensError: null,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function formatCommand(args) {
|
|
509
|
+
const parts = [
|
|
510
|
+
"aligndev",
|
|
511
|
+
"code",
|
|
512
|
+
...(args.resume === undefined ? ["new"] : ["resume", args.resume]),
|
|
513
|
+
];
|
|
514
|
+
if (args.protocol !== undefined)
|
|
515
|
+
parts.push("--protocol", args.protocol);
|
|
516
|
+
if (args.ticket !== undefined)
|
|
517
|
+
parts.push("--ticket", args.ticket);
|
|
518
|
+
if (args.noTicket)
|
|
519
|
+
parts.push("--no-ticket");
|
|
520
|
+
if (args.catchup === true)
|
|
521
|
+
parts.push("--catchup");
|
|
522
|
+
if (args.model !== undefined)
|
|
523
|
+
parts.push("--model", args.model);
|
|
524
|
+
if (args.messageFile !== undefined) {
|
|
525
|
+
parts.push("--message-file", JSON.stringify(args.messageFile));
|
|
526
|
+
}
|
|
527
|
+
else if (args.message !== undefined) {
|
|
528
|
+
parts.push("--message", JSON.stringify(args.message));
|
|
529
|
+
}
|
|
530
|
+
if (args.meta !== undefined)
|
|
531
|
+
parts.push("--meta", JSON.stringify(args.meta));
|
|
532
|
+
return parts.join(" ");
|
|
533
|
+
}
|
|
534
|
+
export function buildRunConfig(input) {
|
|
535
|
+
const { args, code, report } = input;
|
|
536
|
+
return {
|
|
537
|
+
prompt: buildPrompt({
|
|
538
|
+
protocol: args.protocol,
|
|
539
|
+
ticket: input.ticket,
|
|
540
|
+
message: args.message,
|
|
541
|
+
catchupContent: input.catchupContent,
|
|
542
|
+
contextContent: input.contextContent,
|
|
543
|
+
alignfirst: input.alignfirst,
|
|
544
|
+
}),
|
|
545
|
+
sessionFilePath: input.sessionFilePath,
|
|
546
|
+
cwd: input.cwd,
|
|
547
|
+
resume: args.resume,
|
|
548
|
+
executableModel: input.executableModel,
|
|
549
|
+
skipPermissions: code.skipPermissions,
|
|
550
|
+
additionalDirectories: !code.skipPermissions &&
|
|
551
|
+
report.companion !== null &&
|
|
552
|
+
companionInUse(report, WRITABLE_COMPANION_ITEMS)
|
|
553
|
+
? [report.companion.dir]
|
|
554
|
+
: [],
|
|
555
|
+
unset: code.unset,
|
|
556
|
+
env: input.env,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function renderHelp(agent, models, forms) {
|
|
560
|
+
const { aligndev, alignfirst } = forms;
|
|
561
|
+
const permissionMode = agent === "claude"
|
|
562
|
+
? "--permission-mode auto (dangerous opt-out: --dangerously-skip-permissions)"
|
|
563
|
+
: "--sandbox workspace-write (dangerous opt-out: --dangerously-bypass-approvals-and-sandbox)";
|
|
564
|
+
const modelBehavior = agent === "codex"
|
|
565
|
+
? "Codex aliases astra, sol, terra, and luna resolve on demand; configured full slugs pass through."
|
|
566
|
+
: "Claude model values pass through unchanged.";
|
|
567
|
+
const requires = forms.viaNpx
|
|
568
|
+
? "Requires: the alignfirst CLI, run through npx, for the project layout, side tickets and the\ndelegated protocols."
|
|
569
|
+
: "Requires: the alignfirst CLI on PATH (npm install -g alignfirst), for the project layout, side\ntickets and the delegated protocols.";
|
|
570
|
+
return `aligndev code — run a coding agent through AlignFirst protocols.
|
|
571
|
+
|
|
572
|
+
Usage:
|
|
573
|
+
${aligndev} code new --protocol <protocol> (--ticket <id> | --no-ticket) [--message "..."]
|
|
574
|
+
${aligndev} code new --catchup --ticket <id> [--protocol <protocol>] [--message-file <path|->]
|
|
575
|
+
${aligndev} code new --message "..."
|
|
576
|
+
${aligndev} code resume <sessionId> [--protocol <protocol>] [--message "..."]
|
|
577
|
+
${aligndev} code status (<session-file> | --ticket <id> | --no-ticket | --meta <key>)
|
|
578
|
+
${aligndev} code quota
|
|
579
|
+
${aligndev} code -h, --help
|
|
580
|
+
|
|
581
|
+
Commands:
|
|
582
|
+
new Start a new session; prints its Session ID at the end.
|
|
583
|
+
resume <sessionId> Continue an existing session.
|
|
584
|
+
status Reconcile and show one run's durable status: the given file, or the newest
|
|
585
|
+
run of the ticket, of no-ticket work, or of the --meta key. Includes
|
|
586
|
+
contextTokens, the context-window occupancy the run ended on, and
|
|
587
|
+
contextCompacted. Does not start an agent.
|
|
588
|
+
quota Show the selected coding agent's account limits and reset times.
|
|
589
|
+
|
|
590
|
+
Options (status):
|
|
591
|
+
--ticket <id> Newest run of that ticket.
|
|
592
|
+
--no-ticket Newest run of no-ticket work.
|
|
593
|
+
--meta <key> Newest run tagged with \`--meta <key>\`, wherever it sits.
|
|
594
|
+
|
|
595
|
+
Options (new, resume):
|
|
596
|
+
--protocol <p> One of: ${PROTOCOLS.join(", ")}.
|
|
597
|
+
--ticket <id> Ticket ID. \`new --protocol\` requires it or a side ticket through
|
|
598
|
+
--no-ticket.
|
|
599
|
+
--no-ticket Side ticket, for work without a ticket: reserves the next one through
|
|
600
|
+
\`${alignfirst} ticket --side\` and passes it to the agent. new only,
|
|
601
|
+
requires --protocol.
|
|
602
|
+
--catchup Load the ticket history before the message. new only.
|
|
603
|
+
-m, --message "..." Message to send. Required for spec/aad, or without protocol/catchup.
|
|
604
|
+
--message-file <path> Read the message from a UTF-8 file, or stdin with -. Exclusive with -m.
|
|
605
|
+
--model <model> Model for a new session: one of ${models.join(", ")}. Omit to use the
|
|
606
|
+
default model.
|
|
607
|
+
--meta "..." Opaque handoff string, stored verbatim in the session file frontmatter
|
|
608
|
+
(\`meta:\`). aligndev never interprets it; a later reader of the session file
|
|
609
|
+
(e.g. the caller reporting the run's outcome) can use it.
|
|
610
|
+
|
|
611
|
+
${requires}
|
|
612
|
+
|
|
613
|
+
Config (~/.config/alignfirst/aligndev.config.json):
|
|
614
|
+
code.agent Required coding agent: claude or codex (selected: ${agent}).
|
|
615
|
+
code.models List replacing the models accepted by --model.
|
|
616
|
+
code.skipPermissions true to run the coding agent with permission prompts disabled.
|
|
617
|
+
code.unset Env vars to strip from the coding agent child.
|
|
618
|
+
|
|
619
|
+
Selected-agent permissions: ${permissionMode}
|
|
620
|
+
The normal mode also makes the project's companion directory writable when it is in use.
|
|
621
|
+
${modelBehavior}
|
|
622
|
+
|
|
623
|
+
${aligndev} code runs a coding agent in the foreground and blocks until it finishes, streaming the
|
|
624
|
+
transcript to stdout and to a session file under .plans/ or its companion. Coding runs can be
|
|
625
|
+
very long: always run it as a background task. Your platform does the backgrounding; never
|
|
626
|
+
detach it.
|
|
627
|
+
|
|
628
|
+
Run \`${aligndev} guide code\` for the full delegation guide.
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentAdapter, AgentProtocolState, RunConfig } from "./run-agent.js";
|
|
2
|
+
export declare function createCodexAdapter(): AgentAdapter;
|
|
3
|
+
export declare function buildCodexArgs(config: RunConfig): string[];
|
|
4
|
+
export declare function createCodexState(): AgentProtocolState;
|
|
5
|
+
export declare function interpretCodexLine(line: string, state: AgentProtocolState): string | undefined;
|
|
6
|
+
export declare function assessCodexState(state: AgentProtocolState): {
|
|
7
|
+
succeeded: boolean;
|
|
8
|
+
sessionId: string | undefined;
|
|
9
|
+
result: string | undefined;
|
|
10
|
+
error: string | undefined;
|
|
11
|
+
authEvidence: boolean;
|
|
12
|
+
contextTokens: number | undefined;
|
|
13
|
+
contextCompacted: boolean;
|
|
14
|
+
contextTokensError: string | undefined;
|
|
15
|
+
};
|