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.
Files changed (73) hide show
  1. package/README.md +166 -1
  2. package/bin/aligndev.mjs +3 -0
  3. package/dist/alignfirst-cli.d.ts +10 -0
  4. package/dist/alignfirst-cli.js +56 -0
  5. package/dist/cli.d.ts +15 -0
  6. package/dist/cli.js +95 -0
  7. package/dist/code/claude-agent.d.ts +14 -0
  8. package/dist/code/claude-agent.js +168 -0
  9. package/dist/code/code-cli.d.ts +72 -0
  10. package/dist/code/code-cli.js +630 -0
  11. package/dist/code/codex-agent.d.ts +15 -0
  12. package/dist/code/codex-agent.js +168 -0
  13. package/dist/code/codex-rollout.d.ts +18 -0
  14. package/dist/code/codex-rollout.js +114 -0
  15. package/dist/code/coding-agent.d.ts +4 -0
  16. package/dist/code/coding-agent.js +6 -0
  17. package/dist/code/models.d.ts +14 -0
  18. package/dist/code/models.js +89 -0
  19. package/dist/code/prompt.d.ts +11 -0
  20. package/dist/code/prompt.js +26 -0
  21. package/dist/code/quota.d.ts +25 -0
  22. package/dist/code/quota.js +248 -0
  23. package/dist/code/run-agent.d.ts +60 -0
  24. package/dist/code/run-agent.js +212 -0
  25. package/dist/code/session-file.d.ts +50 -0
  26. package/dist/code/session-file.js +263 -0
  27. package/dist/command-form.d.ts +6 -0
  28. package/dist/command-form.js +7 -0
  29. package/dist/config.d.ts +22 -0
  30. package/dist/config.js +72 -0
  31. package/dist/errors.d.ts +2 -0
  32. package/dist/errors.js +6 -0
  33. package/dist/guide/code-guide.d.ts +4 -0
  34. package/dist/guide/code-guide.js +17 -0
  35. package/dist/guide/guide-cli.d.ts +3 -0
  36. package/dist/guide/guide-cli.js +81 -0
  37. package/dist/guide/render-template.d.ts +4 -0
  38. package/dist/guide/render-template.js +62 -0
  39. package/dist/guide/topics.d.ts +3 -0
  40. package/dist/guide/topics.js +16 -0
  41. package/dist/output.d.ts +3 -0
  42. package/dist/output.js +1 -0
  43. package/dist/project/discovery.d.ts +42 -0
  44. package/dist/project/discovery.js +287 -0
  45. package/dist/project/format.d.ts +6 -0
  46. package/dist/project/format.js +31 -0
  47. package/dist/project/guide.d.ts +3 -0
  48. package/dist/project/guide.js +69 -0
  49. package/dist/project/layout.d.ts +36 -0
  50. package/dist/project/layout.js +128 -0
  51. package/dist/project/markers.d.ts +18 -0
  52. package/dist/project/markers.js +90 -0
  53. package/dist/project/ports.d.ts +3 -0
  54. package/dist/project/ports.js +55 -0
  55. package/dist/project/project-cli.d.ts +17 -0
  56. package/dist/project/project-cli.js +227 -0
  57. package/dist/project/render.d.ts +10 -0
  58. package/dist/project/render.js +144 -0
  59. package/dist/project/status.d.ts +24 -0
  60. package/dist/project/status.js +110 -0
  61. package/dist/templates.d.ts +1 -0
  62. package/dist/templates.js +5 -0
  63. package/package.json +38 -3
  64. package/templates/guide/code.md +252 -0
  65. package/templates/guide/playbook/channel-handling.md +110 -0
  66. package/templates/guide/playbook/consultation.md +71 -0
  67. package/templates/guide/playbook/discord-message-tool.md +37 -0
  68. package/templates/guide/playbook/playbook.md +139 -0
  69. package/templates/guide/playbook/project-lifecycle.md +100 -0
  70. package/templates/guide/playbook/project-workspace-setup.md +186 -0
  71. package/templates/guide/playbook/slack-message-tool.md +23 -0
  72. package/templates/guide/playbook/working-session.md +588 -0
  73. 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
+ };