agentplane 0.1.6 → 0.1.7

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 (111) hide show
  1. package/assets/AGENTS.md +1 -1
  2. package/assets/agents/ORCHESTRATOR.json +1 -1
  3. package/assets/agents/UPGRADER.json +1 -1
  4. package/dist/cli/run-cli.d.ts.map +1 -1
  5. package/dist/cli/run-cli.js +22 -7
  6. package/dist/commands/branch/index.d.ts +60 -0
  7. package/dist/commands/branch/index.d.ts.map +1 -0
  8. package/dist/commands/branch/index.js +511 -0
  9. package/dist/commands/guard/index.d.ts +67 -0
  10. package/dist/commands/guard/index.d.ts.map +1 -0
  11. package/dist/commands/guard/index.js +367 -0
  12. package/dist/commands/hooks/index.d.ts +18 -0
  13. package/dist/commands/hooks/index.d.ts.map +1 -0
  14. package/dist/commands/hooks/index.js +290 -0
  15. package/dist/commands/pr/index.d.ts +46 -0
  16. package/dist/commands/pr/index.d.ts.map +1 -0
  17. package/dist/commands/pr/index.js +854 -0
  18. package/dist/commands/shared/git-diff.d.ts +9 -0
  19. package/dist/commands/shared/git-diff.d.ts.map +1 -0
  20. package/dist/commands/shared/git-diff.js +41 -0
  21. package/dist/commands/shared/git-ops.d.ts +24 -0
  22. package/dist/commands/shared/git-ops.d.ts.map +1 -0
  23. package/dist/commands/shared/git-ops.js +181 -0
  24. package/dist/commands/shared/git-worktree.d.ts +8 -0
  25. package/dist/commands/shared/git-worktree.d.ts.map +1 -0
  26. package/dist/commands/shared/git-worktree.js +48 -0
  27. package/dist/commands/shared/git.d.ts +4 -0
  28. package/dist/commands/shared/git.d.ts.map +1 -0
  29. package/dist/commands/shared/git.js +14 -0
  30. package/dist/commands/shared/path.d.ts +3 -0
  31. package/dist/commands/shared/path.d.ts.map +1 -0
  32. package/dist/commands/shared/path.js +14 -0
  33. package/dist/commands/shared/pr-meta.d.ts +21 -0
  34. package/dist/commands/shared/pr-meta.d.ts.map +1 -0
  35. package/dist/commands/shared/pr-meta.js +72 -0
  36. package/dist/commands/shared/task-backend.d.ts +15 -0
  37. package/dist/commands/shared/task-backend.d.ts.map +1 -0
  38. package/dist/commands/shared/task-backend.js +55 -0
  39. package/dist/commands/task/add.d.ts +8 -0
  40. package/dist/commands/task/add.d.ts.map +1 -0
  41. package/dist/commands/task/add.js +164 -0
  42. package/dist/commands/task/block.d.ts +19 -0
  43. package/dist/commands/task/block.d.ts.map +1 -0
  44. package/dist/commands/task/block.js +86 -0
  45. package/dist/commands/task/comment.d.ts +8 -0
  46. package/dist/commands/task/comment.d.ts.map +1 -0
  47. package/dist/commands/task/comment.js +29 -0
  48. package/dist/commands/task/doc.d.ts +17 -0
  49. package/dist/commands/task/doc.d.ts.map +1 -0
  50. package/dist/commands/task/doc.js +220 -0
  51. package/dist/commands/task/export.d.ts +5 -0
  52. package/dist/commands/task/export.d.ts.map +1 -0
  53. package/dist/commands/task/export.js +27 -0
  54. package/dist/commands/task/finish.d.ts +27 -0
  55. package/dist/commands/task/finish.d.ts.map +1 -0
  56. package/dist/commands/task/finish.js +131 -0
  57. package/dist/commands/task/index.d.ts +23 -0
  58. package/dist/commands/task/index.d.ts.map +1 -0
  59. package/dist/commands/task/index.js +22 -0
  60. package/dist/commands/task/lint.d.ts +5 -0
  61. package/dist/commands/task/lint.d.ts.map +1 -0
  62. package/dist/commands/task/lint.js +22 -0
  63. package/dist/commands/task/list.d.ts +11 -0
  64. package/dist/commands/task/list.d.ts.map +1 -0
  65. package/dist/commands/task/list.js +54 -0
  66. package/dist/commands/task/migrate.d.ts +6 -0
  67. package/dist/commands/task/migrate.d.ts.map +1 -0
  68. package/dist/commands/task/migrate.js +70 -0
  69. package/dist/commands/task/new.d.ts +8 -0
  70. package/dist/commands/task/new.d.ts.map +1 -0
  71. package/dist/commands/task/new.js +117 -0
  72. package/dist/commands/task/next.d.ts +6 -0
  73. package/dist/commands/task/next.d.ts.map +1 -0
  74. package/dist/commands/task/next.js +45 -0
  75. package/dist/commands/task/normalize.d.ts +6 -0
  76. package/dist/commands/task/normalize.d.ts.map +1 -0
  77. package/dist/commands/task/normalize.js +46 -0
  78. package/dist/commands/task/ready.d.ts +6 -0
  79. package/dist/commands/task/ready.d.ts.map +1 -0
  80. package/dist/commands/task/ready.js +57 -0
  81. package/dist/commands/task/scaffold.d.ts +8 -0
  82. package/dist/commands/task/scaffold.d.ts.map +1 -0
  83. package/dist/commands/task/scaffold.js +131 -0
  84. package/dist/commands/task/scrub.d.ts +8 -0
  85. package/dist/commands/task/scrub.d.ts.map +1 -0
  86. package/dist/commands/task/scrub.js +121 -0
  87. package/dist/commands/task/search.d.ts +7 -0
  88. package/dist/commands/task/search.d.ts.map +1 -0
  89. package/dist/commands/task/search.js +79 -0
  90. package/dist/commands/task/set-status.d.ts +19 -0
  91. package/dist/commands/task/set-status.d.ts.map +1 -0
  92. package/dist/commands/task/set-status.js +123 -0
  93. package/dist/commands/task/shared.d.ts +46 -0
  94. package/dist/commands/task/shared.d.ts.map +1 -0
  95. package/dist/commands/task/shared.js +283 -0
  96. package/dist/commands/task/show.d.ts +6 -0
  97. package/dist/commands/task/show.d.ts.map +1 -0
  98. package/dist/commands/task/show.js +35 -0
  99. package/dist/commands/task/start.d.ts +19 -0
  100. package/dist/commands/task/start.d.ts.map +1 -0
  101. package/dist/commands/task/start.js +109 -0
  102. package/dist/commands/task/update.d.ts +8 -0
  103. package/dist/commands/task/update.d.ts.map +1 -0
  104. package/dist/commands/task/update.js +144 -0
  105. package/dist/commands/task/verify.d.ts +14 -0
  106. package/dist/commands/task/verify.d.ts.map +1 -0
  107. package/dist/commands/task/verify.js +362 -0
  108. package/dist/commands/workflow.d.ts +5 -364
  109. package/dist/commands/workflow.d.ts.map +1 -1
  110. package/dist/commands/workflow.js +6 -4617
  111. package/package.json +2 -2
@@ -1,4619 +1,8 @@
1
- import { execFile } from "node:child_process";
2
- import { chmod, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { promisify } from "node:util";
5
- import { extractTaskSuffix, ensureDocSections, getBaseBranch, getStagedFiles, getUnstagedFiles, lintTasksFile, loadConfig, normalizeDocSectionName, parseDocSections, renderTaskReadme, resolveProject, setMarkdownSection, setPinnedBaseBranch, taskReadmePath, validateCommitSubject, validateTaskDocMetadata, } from "@agentplaneorg/core";
6
- import { formatCommentBodyForCommit } from "../shared/comment-format.js";
7
- import { mapBackendError, mapCoreError } from "../cli/error-map.js";
8
- import { fileExists } from "../cli/fs-utils.js";
9
- import { promptChoice, promptInput, promptYesNo } from "../cli/prompts.js";
10
- import { backendNotSupportedMessage, infoMessage, invalidValueForFlag, invalidValueMessage, missingValueMessage, successMessage, unknownEntityMessage, usageMessage, warnMessage, workflowModeMessage, } from "../cli/output.js";
11
- import { CliError } from "../shared/errors.js";
12
- import { loadTaskBackend } from "../backends/task-backend.js";
13
- const execFileAsync = promisify(execFile);
14
- function gitEnv() {
15
- const env = { ...process.env };
16
- delete env.GIT_DIR;
17
- delete env.GIT_WORK_TREE;
18
- delete env.GIT_COMMON_DIR;
19
- delete env.GIT_INDEX_FILE;
20
- delete env.GIT_OBJECT_DIRECTORY;
21
- delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
22
- return env;
23
- }
24
- export const TASK_NEW_USAGE = "Usage: agentplane task new --title <text> --description <text> --priority <low|normal|med|high> --owner <id> --tag <tag> [--tag <tag>...]";
25
- export const TASK_NEW_USAGE_EXAMPLE = 'agentplane task new --title "Refactor CLI" --description "Improve CLI output" --priority med --owner CODER --tag cli';
26
- export const TASK_ADD_USAGE = "Usage: agentplane task add <task-id> [<task-id> ...] --title <text> --description <text> --priority <low|normal|med|high> --owner <id> --tag <tag> [--tag <tag>...]";
27
- export const TASK_ADD_USAGE_EXAMPLE = 'agentplane task add 202602030608-F1Q8AB --title "..." --description "..." --priority med --owner CODER --tag cli';
28
- export const TASK_SCRUB_USAGE = "Usage: agentplane task scrub --find <text> --replace <text> [flags]";
29
- export const TASK_SCRUB_USAGE_EXAMPLE = 'agentplane task scrub --find "agentctl" --replace "agentplane" --dry-run';
30
- export const TASK_UPDATE_USAGE = "Usage: agentplane task update <task-id> [flags]";
31
- export const TASK_UPDATE_USAGE_EXAMPLE = 'agentplane task update 202602030608-F1Q8AB --title "..." --owner CODER';
32
- export const TASK_SCAFFOLD_USAGE = "Usage: agentplane task scaffold <task-id> [--title <text>] [--overwrite] [--force]";
33
- export const TASK_SCAFFOLD_USAGE_EXAMPLE = "agentplane task scaffold 202602030608-F1Q8AB";
34
- export const BRANCH_BASE_USAGE = "Usage: agentplane branch base get|set <name>";
35
- export const BRANCH_BASE_USAGE_EXAMPLE = "agentplane branch base set main";
36
- export const BRANCH_STATUS_USAGE = "Usage: agentplane branch status [--branch <name>] [--base <name>]";
37
- export const BRANCH_STATUS_USAGE_EXAMPLE = "agentplane branch status --base main";
38
- export const BRANCH_REMOVE_USAGE = "Usage: agentplane branch remove [--branch <name>] [--worktree <path>] [--force] [--quiet]";
39
- export const BRANCH_REMOVE_USAGE_EXAMPLE = "agentplane branch remove --branch task/20260203-F1Q8AB --worktree .agentplane/worktrees/task";
40
- function normalizeDependsOnInput(value) {
41
- const trimmed = value.trim();
42
- if (!trimmed || trimmed === "[]")
43
- return [];
44
- return [trimmed];
45
- }
46
- function parseTaskNewFlags(args) {
47
- const out = { tags: [], dependsOn: [], verify: [] };
48
- for (let i = 0; i < args.length; i++) {
49
- const arg = args[i];
50
- if (!arg)
51
- continue;
52
- if (!arg.startsWith("--")) {
53
- throw new CliError({
54
- exitCode: 2,
55
- code: "E_USAGE",
56
- message: `Unexpected argument: ${arg}`,
57
- });
58
- }
59
- const next = args[i + 1];
60
- if (!next) {
61
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
62
- }
63
- switch (arg) {
64
- case "--title": {
65
- out.title = next;
66
- break;
67
- }
68
- case "--description": {
69
- out.description = next;
70
- break;
71
- }
72
- case "--owner": {
73
- out.owner = next;
74
- break;
75
- }
76
- case "--priority": {
77
- out.priority = next;
78
- break;
79
- }
80
- case "--tag": {
81
- out.tags.push(next);
82
- break;
83
- }
84
- case "--depends-on": {
85
- out.dependsOn.push(...normalizeDependsOnInput(next));
86
- break;
87
- }
88
- case "--verify": {
89
- out.verify.push(next);
90
- break;
91
- }
92
- default: {
93
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
94
- }
95
- }
96
- i++;
97
- }
98
- return out;
99
- }
100
- export async function cmdTaskNew(opts) {
101
- const flags = parseTaskNewFlags(opts.args);
102
- const priority = flags.priority ?? "med";
103
- if (!flags.title || !flags.description || !flags.owner || flags.tags.length === 0) {
104
- throw new CliError({
105
- exitCode: 2,
106
- code: "E_USAGE",
107
- message: usageMessage(TASK_NEW_USAGE, TASK_NEW_USAGE_EXAMPLE),
108
- });
109
- }
110
- try {
111
- const { backend, config } = await loadTaskBackend({
112
- cwd: opts.cwd,
113
- rootOverride: opts.rootOverride ?? null,
114
- });
115
- const suffixLength = config.tasks.id_suffix_length_default;
116
- if (!backend.generateTaskId) {
117
- throw new CliError({
118
- exitCode: 3,
119
- code: "E_VALIDATION",
120
- message: backendNotSupportedMessage("generateTaskId()"),
121
- });
122
- }
123
- const taskId = await backend.generateTaskId({ length: suffixLength, attempts: 1000 });
124
- const task = {
125
- id: taskId,
126
- title: flags.title,
127
- description: flags.description,
128
- status: "TODO",
129
- priority,
130
- owner: flags.owner,
131
- tags: flags.tags,
132
- depends_on: flags.dependsOn,
133
- verify: flags.verify,
134
- comments: [],
135
- doc_version: 2,
136
- doc_updated_at: nowIso(),
137
- doc_updated_by: flags.owner,
138
- id_source: "generated",
139
- };
140
- if (requiresVerify(flags.tags, config.tasks.verify.required_tags) &&
141
- flags.verify.length === 0) {
142
- throw new CliError({
143
- exitCode: 2,
144
- code: "E_USAGE",
145
- message: "Missing verify commands for tasks with code/backend/frontend tags (use --verify)",
146
- });
147
- }
148
- await backend.writeTask(task);
149
- process.stdout.write(`${taskId}\n`);
150
- return 0;
151
- }
152
- catch (err) {
153
- throw mapBackendError(err, { command: "task new", root: opts.rootOverride ?? null });
154
- }
155
- }
156
- function parseTaskAddFlags(args) {
157
- const out = {
158
- taskIds: [],
159
- title: "",
160
- description: "",
161
- status: "TODO",
162
- priority: "",
163
- owner: "",
164
- tags: [],
165
- dependsOn: [],
166
- verify: [],
167
- };
168
- for (let i = 0; i < args.length; i++) {
169
- const arg = args[i];
170
- if (!arg)
171
- continue;
172
- if (!arg.startsWith("--")) {
173
- out.taskIds.push(arg);
174
- continue;
175
- }
176
- const next = args[i + 1];
177
- if (arg === "--replace-tags" || arg === "--replace-depends-on" || arg === "--replace-verify") {
178
- throw new CliError({
179
- exitCode: 2,
180
- code: "E_USAGE",
181
- message: usageMessage(TASK_ADD_USAGE, TASK_ADD_USAGE_EXAMPLE),
182
- });
183
- }
184
- if (!next) {
185
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
186
- }
187
- switch (arg) {
188
- case "--title": {
189
- out.title = next;
190
- break;
191
- }
192
- case "--description": {
193
- out.description = next;
194
- break;
195
- }
196
- case "--status": {
197
- out.status = next;
198
- break;
199
- }
200
- case "--priority": {
201
- out.priority = next;
202
- break;
203
- }
204
- case "--owner": {
205
- out.owner = next;
206
- break;
207
- }
208
- case "--tag": {
209
- out.tags.push(next);
210
- break;
211
- }
212
- case "--depends-on": {
213
- out.dependsOn.push(...normalizeDependsOnInput(next));
214
- break;
215
- }
216
- case "--verify": {
217
- out.verify.push(next);
218
- break;
219
- }
220
- case "--comment-author": {
221
- out.commentAuthor = next;
222
- break;
223
- }
224
- case "--comment-body": {
225
- out.commentBody = next;
226
- break;
227
- }
228
- default: {
229
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
230
- }
231
- }
232
- i++;
233
- }
234
- return out;
235
- }
236
- export async function cmdTaskAdd(opts) {
237
- const flags = parseTaskAddFlags(opts.args);
238
- if (flags.taskIds.length === 0 ||
239
- !flags.title ||
240
- !flags.description ||
241
- !flags.priority ||
242
- !flags.owner ||
243
- flags.tags.length === 0) {
244
- throw new CliError({
245
- exitCode: 2,
246
- code: "E_USAGE",
247
- message: usageMessage(TASK_ADD_USAGE, TASK_ADD_USAGE_EXAMPLE),
248
- });
249
- }
250
- try {
251
- const { backend, config } = await loadTaskBackend({
252
- cwd: opts.cwd,
253
- rootOverride: opts.rootOverride ?? null,
254
- });
255
- const status = normalizeTaskStatus(flags.status);
256
- const existing = await backend.listTasks();
257
- const existingIds = new Set(existing.map((task) => task.id));
258
- for (const taskId of flags.taskIds) {
259
- if (existingIds.has(taskId)) {
260
- throw new CliError({
261
- exitCode: 2,
262
- code: "E_USAGE",
263
- message: `Task already exists: ${taskId}`,
264
- });
265
- }
266
- }
267
- const tags = dedupeStrings(flags.tags);
268
- const dependsOn = dedupeStrings(flags.dependsOn);
269
- const verify = dedupeStrings(flags.verify);
270
- const docUpdatedBy = (flags.commentAuthor ?? "").trim() || flags.owner;
271
- if (requiresVerify(tags, config.tasks.verify.required_tags) && verify.length === 0) {
272
- throw new CliError({
273
- exitCode: 2,
274
- code: "E_USAGE",
275
- message: "verify commands are required for tasks with code/backend/frontend tags",
276
- });
277
- }
278
- const tasks = flags.taskIds.map((taskId) => ({
279
- id: taskId,
280
- title: flags.title,
281
- description: flags.description,
282
- status,
283
- priority: flags.priority,
284
- owner: flags.owner,
285
- tags,
286
- depends_on: dependsOn,
287
- verify,
288
- comments: flags.commentAuthor && flags.commentBody
289
- ? [{ author: flags.commentAuthor, body: flags.commentBody }]
290
- : [],
291
- doc_version: 2,
292
- doc_updated_at: nowIso(),
293
- doc_updated_by: docUpdatedBy,
294
- id_source: "explicit",
295
- }));
296
- if (backend.writeTasks) {
297
- await backend.writeTasks(tasks);
298
- }
299
- else {
300
- for (const task of tasks) {
301
- await backend.writeTask(task);
302
- }
303
- }
304
- for (const task of tasks) {
305
- process.stdout.write(`${task.id}\n`);
306
- }
307
- return 0;
308
- }
309
- catch (err) {
310
- throw mapBackendError(err, { command: "task add", root: opts.rootOverride ?? null });
311
- }
312
- }
313
- function parseTaskUpdateFlags(args) {
314
- const [taskId, ...rest] = args;
315
- if (!taskId) {
316
- throw new CliError({
317
- exitCode: 2,
318
- code: "E_USAGE",
319
- message: usageMessage(TASK_UPDATE_USAGE, TASK_UPDATE_USAGE_EXAMPLE),
320
- });
321
- }
322
- const out = {
323
- taskId,
324
- tags: [],
325
- replaceTags: false,
326
- dependsOn: [],
327
- replaceDependsOn: false,
328
- verify: [],
329
- replaceVerify: false,
330
- };
331
- for (let i = 0; i < rest.length; i++) {
332
- const arg = rest[i];
333
- if (!arg)
334
- continue;
335
- if (arg === "--replace-tags") {
336
- out.replaceTags = true;
337
- continue;
338
- }
339
- if (arg === "--replace-depends-on") {
340
- out.replaceDependsOn = true;
341
- continue;
342
- }
343
- if (arg === "--replace-verify") {
344
- out.replaceVerify = true;
345
- continue;
346
- }
347
- if (!arg.startsWith("--")) {
348
- throw new CliError({
349
- exitCode: 2,
350
- code: "E_USAGE",
351
- message: usageMessage(TASK_UPDATE_USAGE, TASK_UPDATE_USAGE_EXAMPLE),
352
- });
353
- }
354
- const next = rest[i + 1];
355
- if (!next) {
356
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
357
- }
358
- switch (arg) {
359
- case "--title": {
360
- out.title = next;
361
- break;
362
- }
363
- case "--description": {
364
- out.description = next;
365
- break;
366
- }
367
- case "--priority": {
368
- out.priority = next;
369
- break;
370
- }
371
- case "--owner": {
372
- out.owner = next;
373
- break;
374
- }
375
- case "--tag": {
376
- out.tags.push(next);
377
- break;
378
- }
379
- case "--depends-on": {
380
- out.dependsOn.push(...normalizeDependsOnInput(next));
381
- break;
382
- }
383
- case "--verify": {
384
- out.verify.push(next);
385
- break;
386
- }
387
- default: {
388
- throw new CliError({
389
- exitCode: 2,
390
- code: "E_USAGE",
391
- message: `Unknown flag: ${arg}`,
392
- });
393
- }
394
- }
395
- i++;
396
- }
397
- return out;
398
- }
399
- export async function cmdTaskUpdate(opts) {
400
- const flags = parseTaskUpdateFlags(opts.args);
401
- try {
402
- const { backend, config } = await loadTaskBackend({
403
- cwd: opts.cwd,
404
- rootOverride: opts.rootOverride ?? null,
405
- });
406
- const task = await backend.getTask(flags.taskId);
407
- if (!task) {
408
- throw new CliError({
409
- exitCode: 2,
410
- code: "E_USAGE",
411
- message: unknownEntityMessage("task id", flags.taskId),
412
- });
413
- }
414
- const next = { ...task };
415
- if (flags.title !== undefined)
416
- next.title = flags.title;
417
- if (flags.description !== undefined)
418
- next.description = flags.description;
419
- if (flags.priority !== undefined)
420
- next.priority = flags.priority;
421
- if (flags.owner !== undefined)
422
- next.owner = flags.owner;
423
- const existingTags = flags.replaceTags ? [] : dedupeStrings(toStringArray(next.tags));
424
- const mergedTags = dedupeStrings([...existingTags, ...flags.tags]);
425
- next.tags = mergedTags;
426
- const existingDepends = flags.replaceDependsOn
427
- ? []
428
- : dedupeStrings(toStringArray(next.depends_on));
429
- const mergedDepends = dedupeStrings([...existingDepends, ...flags.dependsOn]);
430
- next.depends_on = mergedDepends;
431
- const existingVerify = flags.replaceVerify ? [] : dedupeStrings(toStringArray(next.verify));
432
- const mergedVerify = dedupeStrings([...existingVerify, ...flags.verify]);
433
- next.verify = mergedVerify;
434
- if (requiresVerify(mergedTags, config.tasks.verify.required_tags) &&
435
- mergedVerify.length === 0) {
436
- throw new CliError({
437
- exitCode: 2,
438
- code: "E_USAGE",
439
- message: "verify commands are required for tasks with code/backend/frontend tags",
440
- });
441
- }
442
- await backend.writeTask(next);
443
- process.stdout.write(`${successMessage("updated", flags.taskId)}\n`);
444
- return 0;
445
- }
446
- catch (err) {
447
- throw mapBackendError(err, { command: "task update", root: opts.rootOverride ?? null });
448
- }
449
- }
450
- function parseTaskScrubFlags(args) {
451
- const out = {
452
- find: "",
453
- replace: "",
454
- dryRun: false,
455
- quiet: false,
456
- };
457
- for (let i = 0; i < args.length; i++) {
458
- const arg = args[i];
459
- if (!arg)
460
- continue;
461
- if (arg === "--dry-run") {
462
- out.dryRun = true;
463
- continue;
464
- }
465
- if (arg === "--quiet") {
466
- out.quiet = true;
467
- continue;
468
- }
469
- if (!arg.startsWith("--")) {
470
- throw new CliError({
471
- exitCode: 2,
472
- code: "E_USAGE",
473
- message: usageMessage(TASK_SCRUB_USAGE, TASK_SCRUB_USAGE_EXAMPLE),
474
- });
475
- }
476
- const next = args[i + 1];
477
- if (!next)
478
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
479
- if (arg === "--find") {
480
- out.find = next;
481
- }
482
- else if (arg === "--replace") {
483
- out.replace = next;
484
- }
485
- else {
486
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
487
- }
488
- i++;
489
- }
490
- return out;
491
- }
492
- function scrubValue(value, find, replace) {
493
- if (typeof value === "string")
494
- return value.replaceAll(find, replace);
495
- if (Array.isArray(value))
496
- return value.map((item) => scrubValue(item, find, replace));
497
- if (value && typeof value === "object") {
498
- const entries = Object.entries(value);
499
- const out = {};
500
- for (const [key, val] of entries) {
501
- out[key] = scrubValue(val, find, replace);
502
- }
503
- return out;
504
- }
505
- return value;
506
- }
507
- export async function cmdTaskScrub(opts) {
508
- const flags = parseTaskScrubFlags(opts.args);
509
- if (!flags.find) {
510
- throw new CliError({
511
- exitCode: 2,
512
- code: "E_USAGE",
513
- message: usageMessage(TASK_SCRUB_USAGE, TASK_SCRUB_USAGE_EXAMPLE),
514
- });
515
- }
516
- try {
517
- const { backend } = await loadTaskBackend({
518
- cwd: opts.cwd,
519
- rootOverride: opts.rootOverride ?? null,
520
- });
521
- const tasks = await backend.listTasks();
522
- const updated = [];
523
- const changedIds = new Set();
524
- for (const task of tasks) {
525
- const before = JSON.stringify(task);
526
- const scrubbed = scrubValue(task, flags.find, flags.replace);
527
- if (scrubbed && typeof scrubbed === "object") {
528
- const next = scrubbed;
529
- updated.push(next);
530
- const after = JSON.stringify(next);
531
- if (before !== after)
532
- changedIds.add(next.id);
533
- }
534
- else {
535
- updated.push(task);
536
- }
537
- }
538
- if (flags.dryRun) {
539
- if (!flags.quiet) {
540
- process.stdout.write(`${infoMessage(`dry-run: would update ${changedIds.size} task(s)`)}` + "\n");
541
- for (const id of [...changedIds].toSorted()) {
542
- process.stdout.write(`${id}\n`);
543
- }
544
- }
545
- return 0;
546
- }
547
- if (backend.writeTasks) {
548
- await backend.writeTasks(updated);
549
- }
550
- else {
551
- for (const task of updated) {
552
- await backend.writeTask(task);
553
- }
554
- }
555
- if (!flags.quiet) {
556
- process.stdout.write(`${successMessage("updated tasks", undefined, `count=${changedIds.size}`)}` + "\n");
557
- }
558
- return 0;
559
- }
560
- catch (err) {
561
- throw mapBackendError(err, { command: "task scrub", root: opts.rootOverride ?? null });
562
- }
563
- }
564
- function parseTaskListFilters(args, opts) {
565
- const out = { status: [], owner: [], tag: [], quiet: false };
566
- for (let i = 0; i < args.length; i++) {
567
- const arg = args[i];
568
- if (!arg)
569
- continue;
570
- if (arg === "--quiet") {
571
- out.quiet = true;
572
- continue;
573
- }
574
- if (arg === "--status") {
575
- const next = args[i + 1];
576
- if (!next)
577
- throw new CliError({
578
- exitCode: 2,
579
- code: "E_USAGE",
580
- message: missingValueMessage("--status"),
581
- });
582
- out.status.push(next);
583
- i++;
584
- continue;
585
- }
586
- if (arg === "--owner") {
587
- const next = args[i + 1];
588
- if (!next)
589
- throw new CliError({
590
- exitCode: 2,
591
- code: "E_USAGE",
592
- message: missingValueMessage("--owner"),
593
- });
594
- out.owner.push(next);
595
- i++;
596
- continue;
597
- }
598
- if (arg === "--tag") {
599
- const next = args[i + 1];
600
- if (!next)
601
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage("--tag") });
602
- out.tag.push(next);
603
- i++;
604
- continue;
605
- }
606
- if (opts?.allowLimit && arg === "--limit") {
607
- const next = args[i + 1];
608
- if (!next)
609
- throw new CliError({
610
- exitCode: 2,
611
- code: "E_USAGE",
612
- message: missingValueMessage("--limit"),
613
- });
614
- const parsed = Number.parseInt(next, 10);
615
- if (!Number.isFinite(parsed)) {
616
- throw new CliError({
617
- exitCode: 2,
618
- code: "E_USAGE",
619
- message: invalidValueForFlag("--limit", next, "integer"),
620
- });
621
- }
622
- out.limit = parsed;
623
- i++;
624
- continue;
625
- }
626
- if (arg.startsWith("--")) {
627
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
628
- }
629
- }
630
- return out;
631
- }
632
- export async function cmdTaskListWithFilters(opts) {
633
- const filters = parseTaskListFilters(opts.args);
634
- try {
635
- const { backend } = await loadTaskBackend({
636
- cwd: opts.cwd,
637
- rootOverride: opts.rootOverride ?? null,
638
- });
639
- const tasks = await backend.listTasks();
640
- const depState = buildDependencyState(tasks);
641
- let filtered = tasks;
642
- if (filters.status.length > 0) {
643
- const wanted = new Set(filters.status.map((s) => s.trim().toUpperCase()));
644
- filtered = filtered.filter((task) => wanted.has(String(task.status || "TODO").toUpperCase()));
645
- }
646
- if (filters.owner.length > 0) {
647
- const wanted = new Set(filters.owner.map((o) => o.trim().toUpperCase()));
648
- filtered = filtered.filter((task) => wanted.has(String(task.owner || "").toUpperCase()));
649
- }
650
- if (filters.tag.length > 0) {
651
- const wanted = new Set(filters.tag.map((t) => t.trim()).filter(Boolean));
652
- filtered = filtered.filter((task) => {
653
- const tags = dedupeStrings(toStringArray(task.tags));
654
- return tags.some((tag) => wanted.has(tag));
655
- });
656
- }
657
- const sorted = filtered.toSorted((a, b) => a.id.localeCompare(b.id));
658
- for (const task of sorted) {
659
- process.stdout.write(`${formatTaskLine(task, depState.get(task.id))}\n`);
660
- }
661
- if (!filters.quiet) {
662
- const counts = {};
663
- for (const task of sorted) {
664
- const status = String(task.status || "TODO").toUpperCase();
665
- counts[status] = (counts[status] ?? 0) + 1;
666
- }
667
- const total = sorted.length;
668
- const summary = Object.keys(counts)
669
- .toSorted()
670
- .map((key) => `${key}=${counts[key]}`)
671
- .join(", ");
672
- process.stdout.write(`Total: ${total}${summary ? ` (${summary})` : ""}\n`);
673
- }
674
- return 0;
675
- }
676
- catch (err) {
677
- throw mapBackendError(err, { command: "task list", root: opts.rootOverride ?? null });
678
- }
679
- }
680
- export async function cmdTaskNext(opts) {
681
- const filters = parseTaskListFilters(opts.args, { allowLimit: true });
682
- try {
683
- const { backend } = await loadTaskBackend({
684
- cwd: opts.cwd,
685
- rootOverride: opts.rootOverride ?? null,
686
- });
687
- const tasks = await backend.listTasks();
688
- const depState = buildDependencyState(tasks);
689
- const statuses = filters.status.length > 0
690
- ? new Set(filters.status.map((s) => s.trim().toUpperCase()))
691
- : new Set(["TODO"]);
692
- let filtered = tasks.filter((task) => statuses.has(String(task.status || "TODO").toUpperCase()));
693
- if (filters.owner.length > 0) {
694
- const wanted = new Set(filters.owner.map((o) => o.trim().toUpperCase()));
695
- filtered = filtered.filter((task) => wanted.has(String(task.owner || "").toUpperCase()));
696
- }
697
- if (filters.tag.length > 0) {
698
- const wanted = new Set(filters.tag.map((t) => t.trim()).filter(Boolean));
699
- filtered = filtered.filter((task) => {
700
- const tags = dedupeStrings(toStringArray(task.tags));
701
- return tags.some((tag) => wanted.has(tag));
702
- });
703
- }
704
- const sorted = filtered.toSorted((a, b) => a.id.localeCompare(b.id));
705
- const ready = sorted.filter((task) => {
706
- const dep = depState.get(task.id);
707
- return !dep || (dep.missing.length === 0 && dep.incomplete.length === 0);
708
- });
709
- const limited = filters.limit !== undefined && filters.limit >= 0 ? ready.slice(0, filters.limit) : ready;
710
- for (const task of limited) {
711
- process.stdout.write(`${formatTaskLine(task, depState.get(task.id))}\n`);
712
- }
713
- if (!filters.quiet) {
714
- process.stdout.write(`Ready: ${limited.length} / ${filtered.length}\n`);
715
- }
716
- return 0;
717
- }
718
- catch (err) {
719
- throw mapBackendError(err, { command: "task next", root: opts.rootOverride ?? null });
720
- }
721
- }
722
- export async function cmdReady(opts) {
723
- try {
724
- const { backend } = await loadTaskBackend({
725
- cwd: opts.cwd,
726
- rootOverride: opts.rootOverride ?? null,
727
- });
728
- const tasks = await backend.listTasks();
729
- const depState = buildDependencyState(tasks);
730
- const task = tasks.find((item) => item.id === opts.taskId);
731
- const warnings = [];
732
- if (task) {
733
- const dep = depState.get(task.id);
734
- const missing = dep?.missing ?? [];
735
- const incomplete = dep?.incomplete ?? [];
736
- if (missing.length > 0) {
737
- warnings.push(`${task.id}: missing deps: ${missing.join(", ")}`);
738
- }
739
- if (incomplete.length > 0) {
740
- warnings.push(`${task.id}: incomplete deps: ${incomplete.join(", ")}`);
741
- }
742
- }
743
- else {
744
- warnings.push(unknownEntityMessage("task id", opts.taskId));
745
- }
746
- for (const warning of warnings) {
747
- process.stdout.write(`${warnMessage(warning)}\n`);
748
- }
749
- if (task) {
750
- const status = String(task.status || "TODO").toUpperCase();
751
- const title = task.title?.trim() || "(untitled task)";
752
- const owner = task.owner?.trim() || "-";
753
- const dep = depState.get(task.id);
754
- const dependsOn = dep?.dependsOn ?? [];
755
- process.stdout.write(`Task: ${task.id} [${status}] ${title}\n`);
756
- process.stdout.write(`Owner: ${owner}\n`);
757
- process.stdout.write(`Depends on: ${dependsOn.length > 0 ? dependsOn.join(", ") : "-"}\n`);
758
- const missing = dep?.missing ?? [];
759
- const incomplete = dep?.incomplete ?? [];
760
- if (missing.length > 0) {
761
- process.stdout.write(`${warnMessage(`missing deps: ${missing.join(", ")}`)}\n`);
762
- }
763
- if (incomplete.length > 0) {
764
- process.stdout.write(`${warnMessage(`incomplete deps: ${incomplete.join(", ")}`)}\n`);
765
- }
766
- }
767
- const ready = warnings.length === 0;
768
- process.stdout.write(`${ready ? successMessage("ready") : warnMessage("not ready")}` + "\n");
769
- return ready ? 0 : 2;
770
- }
771
- catch (err) {
772
- throw mapBackendError(err, { command: "ready", root: opts.rootOverride ?? null });
773
- }
774
- }
775
- function taskTextBlob(task) {
776
- const parts = [];
777
- for (const key of ["id", "title", "description", "status", "priority", "owner"]) {
778
- const value = task[key];
779
- if (typeof value === "string" && value.trim())
780
- parts.push(value.trim());
781
- }
782
- const tags = toStringArray(task.tags);
783
- parts.push(...tags.filter(Boolean));
784
- const comments = Array.isArray(task.comments) ? task.comments : [];
785
- for (const comment of comments) {
786
- if (comment && typeof comment.author === "string")
787
- parts.push(comment.author);
788
- if (comment && typeof comment.body === "string")
789
- parts.push(comment.body);
790
- }
791
- const commit = task.commit ?? null;
792
- if (commit && typeof commit.hash === "string")
793
- parts.push(commit.hash);
794
- if (commit && typeof commit.message === "string")
795
- parts.push(commit.message);
796
- return parts.join("\n");
797
- }
798
- export async function cmdTaskSearch(opts) {
799
- const query = opts.query.trim();
800
- if (!query) {
801
- throw new CliError({
802
- exitCode: 2,
803
- code: "E_USAGE",
804
- message: "Missing query (expected non-empty text)",
805
- });
806
- }
807
- let regex = false;
808
- const restArgs = [...opts.args];
809
- if (restArgs.includes("--regex")) {
810
- regex = true;
811
- restArgs.splice(restArgs.indexOf("--regex"), 1);
812
- }
813
- const filters = parseTaskListFilters(restArgs, { allowLimit: true });
814
- try {
815
- const { backend } = await loadTaskBackend({
816
- cwd: opts.cwd,
817
- rootOverride: opts.rootOverride ?? null,
818
- });
819
- const tasks = await backend.listTasks();
820
- const depState = buildDependencyState(tasks);
821
- let filtered = tasks;
822
- if (filters.status.length > 0) {
823
- const wanted = new Set(filters.status.map((s) => s.trim().toUpperCase()));
824
- filtered = filtered.filter((task) => wanted.has(String(task.status || "TODO").toUpperCase()));
825
- }
826
- if (filters.owner.length > 0) {
827
- const wanted = new Set(filters.owner.map((o) => o.trim().toUpperCase()));
828
- filtered = filtered.filter((task) => wanted.has(String(task.owner || "").toUpperCase()));
829
- }
830
- if (filters.tag.length > 0) {
831
- const wanted = new Set(filters.tag.map((t) => t.trim()).filter(Boolean));
832
- filtered = filtered.filter((task) => {
833
- const tags = dedupeStrings(toStringArray(task.tags));
834
- return tags.some((tag) => wanted.has(tag));
835
- });
836
- }
837
- let matches = [];
838
- if (regex) {
839
- let pattern;
840
- try {
841
- pattern = new RegExp(query, "i");
842
- }
843
- catch (err) {
844
- const message = err instanceof Error ? err.message : "Invalid regex";
845
- throw new CliError({
846
- exitCode: 2,
847
- code: "E_USAGE",
848
- message: invalidValueMessage("regex", message, "valid pattern"),
849
- });
850
- }
851
- matches = filtered.filter((task) => pattern.test(taskTextBlob(task)));
852
- }
853
- else {
854
- const needle = query.toLowerCase();
855
- matches = filtered.filter((task) => taskTextBlob(task).toLowerCase().includes(needle));
856
- }
857
- if (filters.limit !== undefined && filters.limit >= 0) {
858
- matches = matches.slice(0, filters.limit);
859
- }
860
- const sorted = matches.toSorted((a, b) => a.id.localeCompare(b.id));
861
- for (const task of sorted) {
862
- process.stdout.write(`${formatTaskLine(task, depState.get(task.id))}\n`);
863
- }
864
- return 0;
865
- }
866
- catch (err) {
867
- if (err instanceof CliError)
868
- throw err;
869
- throw mapBackendError(err, { command: "task search", root: opts.rootOverride ?? null });
870
- }
871
- }
872
- function parseTaskScaffoldFlags(args) {
873
- const [taskId, ...rest] = args;
874
- if (!taskId) {
875
- throw new CliError({
876
- exitCode: 2,
877
- code: "E_USAGE",
878
- message: usageMessage(TASK_SCAFFOLD_USAGE, TASK_SCAFFOLD_USAGE_EXAMPLE),
879
- });
880
- }
881
- const out = { taskId, overwrite: false, force: false, quiet: false };
882
- for (let i = 0; i < rest.length; i++) {
883
- const arg = rest[i];
884
- if (!arg)
885
- continue;
886
- if (arg === "--overwrite") {
887
- out.overwrite = true;
888
- continue;
889
- }
890
- if (arg === "--force") {
891
- out.force = true;
892
- continue;
893
- }
894
- if (arg === "--quiet") {
895
- out.quiet = true;
896
- continue;
897
- }
898
- if (arg === "--title") {
899
- const next = rest[i + 1];
900
- if (!next)
901
- throw new CliError({
902
- exitCode: 2,
903
- code: "E_USAGE",
904
- message: missingValueMessage("--title"),
905
- });
906
- out.title = next;
907
- i++;
908
- continue;
909
- }
910
- throw new CliError({
911
- exitCode: 2,
912
- code: "E_USAGE",
913
- message: `Unknown flag: ${arg}`,
914
- });
915
- }
916
- return out;
917
- }
918
- export async function cmdTaskScaffold(opts) {
919
- const flags = parseTaskScaffoldFlags(opts.args);
920
- try {
921
- const { backend, resolved, config } = await loadTaskBackend({
922
- cwd: opts.cwd,
923
- rootOverride: opts.rootOverride ?? null,
924
- });
925
- const task = await backend.getTask(flags.taskId);
926
- if (!task && !flags.force) {
927
- throw new CliError({
928
- exitCode: 2,
929
- code: "E_USAGE",
930
- message: unknownEntityMessage("task id", flags.taskId),
931
- });
932
- }
933
- const readmePath = taskReadmePath(path.join(resolved.gitRoot, config.paths.workflow_dir), flags.taskId);
934
- try {
935
- await readFile(readmePath, "utf8");
936
- if (!flags.overwrite) {
937
- throw new CliError({
938
- exitCode: 2,
939
- code: "E_USAGE",
940
- message: `File already exists: ${readmePath}`,
941
- });
942
- }
943
- }
944
- catch (err) {
945
- const code = err?.code;
946
- if (code !== "ENOENT") {
947
- if (err instanceof CliError)
948
- throw err;
949
- throw err;
950
- }
951
- }
952
- const baseTask = task ??
953
- {
954
- id: flags.taskId,
955
- title: flags.title ?? "",
956
- description: "",
957
- status: "TODO",
958
- priority: "med",
959
- owner: "",
960
- depends_on: [],
961
- tags: [],
962
- verify: [],
963
- comments: [],
964
- doc_version: 2,
965
- doc_updated_at: nowIso(),
966
- doc_updated_by: "UNKNOWN",
967
- };
968
- if (flags.title)
969
- baseTask.title = flags.title;
970
- if (typeof baseTask.doc_updated_by !== "string" ||
971
- baseTask.doc_updated_by.trim().length === 0 ||
972
- baseTask.doc_updated_by.trim().toLowerCase() === "agentplane") {
973
- baseTask.doc_updated_by = baseTask.owner?.trim() ? baseTask.owner : "UNKNOWN";
974
- }
975
- const frontmatter = taskDataToFrontmatter(baseTask);
976
- const body = ensureDocSections("", config.tasks.doc.required_sections);
977
- const text = renderTaskReadme(frontmatter, body);
978
- await mkdir(path.dirname(readmePath), { recursive: true });
979
- await writeFile(readmePath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
980
- if (!flags.quiet) {
981
- process.stdout.write(`${successMessage("wrote", path.relative(resolved.gitRoot, readmePath))}\n`);
982
- }
983
- return 0;
984
- }
985
- catch (err) {
986
- if (err instanceof CliError)
987
- throw err;
988
- throw mapBackendError(err, { command: "task scaffold", root: opts.rootOverride ?? null });
989
- }
990
- }
991
- function parseTaskNormalizeFlags(args) {
992
- const out = { quiet: false, force: false };
993
- for (const arg of args) {
994
- if (!arg)
995
- continue;
996
- if (arg === "--quiet")
997
- out.quiet = true;
998
- else if (arg === "--force")
999
- out.force = true;
1000
- else if (arg.startsWith("--"))
1001
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
1002
- }
1003
- return out;
1004
- }
1005
- export async function cmdTaskNormalize(opts) {
1006
- const flags = parseTaskNormalizeFlags(opts.args);
1007
- if (flags.force) {
1008
- // Force is accepted for parity; no additional checks in node CLI.
1009
- }
1010
- try {
1011
- const { backend } = await loadTaskBackend({
1012
- cwd: opts.cwd,
1013
- rootOverride: opts.rootOverride ?? null,
1014
- });
1015
- const tasks = await backend.listTasks();
1016
- if (backend.writeTasks) {
1017
- await backend.writeTasks(tasks);
1018
- }
1019
- else {
1020
- for (const task of tasks)
1021
- await backend.writeTask(task);
1022
- }
1023
- if (!flags.quiet) {
1024
- process.stdout.write(`${successMessage("normalized tasks", undefined, `count=${tasks.length}`)}\n`);
1025
- }
1026
- return 0;
1027
- }
1028
- catch (err) {
1029
- throw mapBackendError(err, { command: "task normalize", root: opts.rootOverride ?? null });
1030
- }
1031
- }
1032
- function parseTaskMigrateFlags(args) {
1033
- const out = { quiet: false, force: false };
1034
- for (let i = 0; i < args.length; i++) {
1035
- const arg = args[i];
1036
- if (!arg)
1037
- continue;
1038
- if (arg === "--quiet") {
1039
- out.quiet = true;
1040
- continue;
1041
- }
1042
- if (arg === "--force") {
1043
- out.force = true;
1044
- continue;
1045
- }
1046
- if (arg === "--source") {
1047
- const next = args[i + 1];
1048
- if (!next)
1049
- throw new CliError({
1050
- exitCode: 2,
1051
- code: "E_USAGE",
1052
- message: missingValueMessage("--source"),
1053
- });
1054
- out.source = next;
1055
- i++;
1056
- continue;
1057
- }
1058
- if (arg.startsWith("--")) {
1059
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
1060
- }
1061
- }
1062
- return out;
1063
- }
1064
- export async function cmdTaskMigrate(opts) {
1065
- const flags = parseTaskMigrateFlags(opts.args);
1066
- if (flags.force) {
1067
- // Force is accepted for parity; no additional checks in node CLI.
1068
- }
1069
- try {
1070
- const { backend, resolved, config } = await loadTaskBackend({
1071
- cwd: opts.cwd,
1072
- rootOverride: opts.rootOverride ?? null,
1073
- });
1074
- const source = flags.source ?? config.paths.tasks_path;
1075
- const sourcePath = path.join(resolved.gitRoot, source);
1076
- const raw = await readFile(sourcePath, "utf8");
1077
- const parsed = JSON.parse(raw);
1078
- const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
1079
- if (backend.writeTasks) {
1080
- await backend.writeTasks(tasks);
1081
- }
1082
- else {
1083
- for (const task of tasks)
1084
- await backend.writeTask(task);
1085
- }
1086
- if (!flags.quiet) {
1087
- process.stdout.write(`${successMessage("migrated tasks into backend", undefined, `count=${tasks.length}`)}\n`);
1088
- }
1089
- return 0;
1090
- }
1091
- catch (err) {
1092
- throw mapBackendError(err, { command: "task migrate", root: opts.rootOverride ?? null });
1093
- }
1094
- }
1095
- export async function cmdTaskComment(opts) {
1096
- try {
1097
- const { backend, task } = await loadBackendTask({
1098
- cwd: opts.cwd,
1099
- rootOverride: opts.rootOverride ?? null,
1100
- taskId: opts.taskId,
1101
- });
1102
- const existing = Array.isArray(task.comments)
1103
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
1104
- : [];
1105
- const next = {
1106
- ...task,
1107
- comments: [...existing, { author: opts.author, body: opts.body }],
1108
- doc_version: 2,
1109
- doc_updated_at: nowIso(),
1110
- doc_updated_by: opts.author,
1111
- };
1112
- await backend.writeTask(next);
1113
- process.stdout.write(`${successMessage("commented", opts.taskId)}\n`);
1114
- return 0;
1115
- }
1116
- catch (err) {
1117
- throw mapBackendError(err, { command: "task comment", root: opts.rootOverride ?? null });
1118
- }
1119
- }
1120
- async function readCommitInfo(cwd, rev) {
1121
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s", rev], { cwd });
1122
- const trimmed = stdout.trim();
1123
- const [hash, message] = trimmed.split(":", 2);
1124
- if (!hash || !message) {
1125
- throw new Error("Unable to read git commit");
1126
- }
1127
- return { hash, message };
1128
- }
1129
- function defaultCommitEmojiForStatus(status) {
1130
- const normalized = status.trim().toUpperCase();
1131
- if (normalized === "DOING")
1132
- return "🚧";
1133
- if (normalized === "DONE")
1134
- return "✅";
1135
- if (normalized === "BLOCKED")
1136
- return "⛔";
1137
- return "🧩";
1138
- }
1139
- export async function cmdTaskSetStatus(opts) {
1140
- const nextStatus = normalizeTaskStatus(opts.status);
1141
- if (nextStatus === "DONE" && !opts.force) {
1142
- throw new CliError({
1143
- exitCode: 2,
1144
- code: "E_USAGE",
1145
- message: "Use `agentplane finish` to mark DONE (use --force to override)",
1146
- });
1147
- }
1148
- if ((opts.author && !opts.body) || (opts.body && !opts.author)) {
1149
- throw new CliError({
1150
- exitCode: 2,
1151
- code: "E_USAGE",
1152
- message: "--author and --body must be provided together",
1153
- });
1154
- }
1155
- try {
1156
- const { backend, task, config, resolved } = await loadBackendTask({
1157
- cwd: opts.cwd,
1158
- rootOverride: opts.rootOverride ?? null,
1159
- taskId: opts.taskId,
1160
- });
1161
- if (opts.commitFromComment) {
1162
- enforceStatusCommitPolicy({
1163
- policy: config.status_commit_policy,
1164
- action: "task set-status",
1165
- confirmed: opts.confirmStatusCommit,
1166
- quiet: opts.quiet,
1167
- });
1168
- }
1169
- const currentStatus = String(task.status || "TODO").toUpperCase();
1170
- if (!opts.force && !isTransitionAllowed(currentStatus, nextStatus)) {
1171
- throw new CliError({
1172
- exitCode: 2,
1173
- code: "E_USAGE",
1174
- message: `Refusing status transition ${currentStatus} -> ${nextStatus} (use --force to override)`,
1175
- });
1176
- }
1177
- if (!opts.force && (nextStatus === "DOING" || nextStatus === "DONE")) {
1178
- const allTasks = await backend.listTasks();
1179
- const depState = buildDependencyState(allTasks);
1180
- const dep = depState.get(task.id);
1181
- if (dep && (dep.missing.length > 0 || dep.incomplete.length > 0)) {
1182
- if (!opts.quiet) {
1183
- if (dep.missing.length > 0) {
1184
- process.stderr.write(`${warnMessage(`missing deps: ${dep.missing.join(", ")}`)}\n`);
1185
- }
1186
- if (dep.incomplete.length > 0) {
1187
- process.stderr.write(`${warnMessage(`incomplete deps: ${dep.incomplete.join(", ")}`)}\n`);
1188
- }
1189
- }
1190
- throw new CliError({
1191
- exitCode: 2,
1192
- code: "E_USAGE",
1193
- message: `Task is not ready: ${task.id} (use --force to override)`,
1194
- });
1195
- }
1196
- }
1197
- const existingComments = Array.isArray(task.comments)
1198
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
1199
- : [];
1200
- let comments = existingComments;
1201
- if (opts.author && opts.body) {
1202
- const commentBody = opts.commitFromComment
1203
- ? formatCommentBodyForCommit(opts.body, config)
1204
- : opts.body;
1205
- comments = [...existingComments, { author: opts.author, body: commentBody }];
1206
- }
1207
- const next = {
1208
- ...task,
1209
- status: nextStatus,
1210
- comments,
1211
- doc_version: 2,
1212
- doc_updated_at: nowIso(),
1213
- doc_updated_by: resolveDocUpdatedBy(task, opts.author),
1214
- };
1215
- if (opts.commit) {
1216
- const commitInfo = await readCommitInfo(resolved.gitRoot, opts.commit);
1217
- next.commit = { hash: commitInfo.hash, message: commitInfo.message };
1218
- }
1219
- await backend.writeTask(next);
1220
- if (backend.exportTasksJson) {
1221
- const outPath = path.join(resolved.gitRoot, config.paths.tasks_path);
1222
- await backend.exportTasksJson(outPath);
1223
- }
1224
- if (opts.commitFromComment) {
1225
- if (!opts.body) {
1226
- throw new CliError({
1227
- exitCode: 2,
1228
- code: "E_USAGE",
1229
- message: "--body is required when using --commit-from-comment",
1230
- });
1231
- }
1232
- await commitFromComment({
1233
- cwd: opts.cwd,
1234
- rootOverride: opts.rootOverride,
1235
- taskId: opts.taskId,
1236
- commentBody: opts.body,
1237
- formattedComment: formatCommentBodyForCommit(opts.body, config),
1238
- emoji: opts.commitEmoji ?? defaultCommitEmojiForStatus(nextStatus),
1239
- allow: opts.commitAllow,
1240
- autoAllow: opts.commitAutoAllow || opts.commitAllow.length === 0,
1241
- allowTasks: opts.commitAllowTasks,
1242
- requireClean: opts.commitRequireClean,
1243
- quiet: opts.quiet,
1244
- config,
1245
- });
1246
- }
1247
- if (!opts.quiet) {
1248
- process.stdout.write(`${successMessage("status", opts.taskId, `next=${nextStatus}`)}\n`);
1249
- }
1250
- return 0;
1251
- }
1252
- catch (err) {
1253
- if (err instanceof CliError)
1254
- throw err;
1255
- throw mapBackendError(err, { command: "task set-status", root: opts.rootOverride ?? null });
1256
- }
1257
- }
1258
- export async function cmdTaskShow(opts) {
1259
- try {
1260
- const { task, backendId } = await loadBackendTask({
1261
- cwd: opts.cwd,
1262
- rootOverride: opts.rootOverride,
1263
- taskId: opts.taskId,
1264
- });
1265
- const frontmatter = taskDataToFrontmatter(task);
1266
- if (backendId === "local") {
1267
- const metadataErrors = validateTaskDocMetadata(frontmatter);
1268
- if (metadataErrors.length > 0) {
1269
- throw new CliError({
1270
- exitCode: 3,
1271
- code: "E_VALIDATION",
1272
- message: `Invalid task README metadata: ${metadataErrors.join("; ")}`,
1273
- });
1274
- }
1275
- }
1276
- process.stdout.write(`${JSON.stringify(frontmatter, null, 2)}\n`);
1277
- return 0;
1278
- }
1279
- catch (err) {
1280
- if (err instanceof CliError)
1281
- throw err;
1282
- throw mapBackendError(err, {
1283
- command: "task show",
1284
- root: opts.rootOverride ?? null,
1285
- taskId: opts.taskId,
1286
- });
1287
- }
1288
- }
1289
- export async function cmdTaskList(opts) {
1290
- return await cmdTaskListWithFilters(opts);
1291
- }
1292
- export async function cmdTaskExport(opts) {
1293
- try {
1294
- const { backend, resolved, config } = await loadTaskBackend({
1295
- cwd: opts.cwd,
1296
- rootOverride: opts.rootOverride ?? null,
1297
- });
1298
- const outPath = path.join(resolved.gitRoot, config.paths.tasks_path);
1299
- if (!backend.exportTasksJson) {
1300
- throw new CliError({
1301
- exitCode: 3,
1302
- code: "E_VALIDATION",
1303
- message: backendNotSupportedMessage("exportTasksJson()"),
1304
- });
1305
- }
1306
- await backend.exportTasksJson(outPath);
1307
- process.stdout.write(`${path.relative(resolved.gitRoot, outPath)}\n`);
1308
- return 0;
1309
- }
1310
- catch (err) {
1311
- throw mapBackendError(err, { command: "task export", root: opts.rootOverride ?? null });
1312
- }
1313
- }
1314
- export async function cmdTaskLint(opts) {
1315
- try {
1316
- const result = await lintTasksFile({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
1317
- if (result.errors.length > 0) {
1318
- throw new CliError({
1319
- exitCode: 3,
1320
- code: "E_VALIDATION",
1321
- message: result.errors.join("\n"),
1322
- });
1323
- }
1324
- process.stdout.write("OK\n");
1325
- return 0;
1326
- }
1327
- catch (err) {
1328
- if (err instanceof CliError)
1329
- throw err;
1330
- throw mapCoreError(err, { command: "task lint", root: opts.rootOverride ?? null });
1331
- }
1332
- }
1
+ // Thin public re-export layer: keep CLI imports stable while command implementations live by domain.
1333
2
  export const IDE_SYNC_USAGE = "Usage: agentplane ide sync";
1334
3
  export const IDE_SYNC_USAGE_EXAMPLE = "agentplane ide sync";
1335
- export const GUARD_COMMIT_USAGE = "Usage: agentplane guard commit <task-id> -m <message> --allow <path> [--allow <path>...] [--auto-allow] [--allow-tasks] [--require-clean] [--quiet]";
1336
- export const GUARD_COMMIT_USAGE_EXAMPLE = 'agentplane guard commit 202602030608-F1Q8AB -m "✨ F1Q8AB update" --allow packages/agentplane';
1337
- export const COMMIT_USAGE = "Usage: agentplane commit <task-id> -m <message>";
1338
- export const COMMIT_USAGE_EXAMPLE = 'agentplane commit 202602030608-F1Q8AB -m "✨ F1Q8AB update"';
1339
- export const START_USAGE = "Usage: agentplane start <task-id> --author <id> --body <text> [flags]";
1340
- export const START_USAGE_EXAMPLE = 'agentplane start 202602030608-F1Q8AB --author CODER --body "Start: ..."';
1341
- export const BLOCK_USAGE = "Usage: agentplane block <task-id> --author <id> --body <text> [flags]";
1342
- export const BLOCK_USAGE_EXAMPLE = 'agentplane block 202602030608-F1Q8AB --author CODER --body "Blocked: ..."';
1343
- export const FINISH_USAGE = "Usage: agentplane finish <task-id> [<task-id>...] --author <id> --body <text> [flags]";
1344
- export const FINISH_USAGE_EXAMPLE = 'agentplane finish 202602030608-F1Q8AB --author INTEGRATOR --body "Verified: ..."';
1345
- export const VERIFY_USAGE = "Usage: agentplane verify <task-id> [--cwd <path>] [--log <path>] [--skip-if-unchanged] [--quiet] [--require] [--yes]";
1346
- export const VERIFY_USAGE_EXAMPLE = "agentplane verify 202602030608-F1Q8AB";
1347
- export const WORK_START_USAGE = "Usage: agentplane work start <task-id> --agent <id> --slug <slug> --worktree";
1348
- export const WORK_START_USAGE_EXAMPLE = "agentplane work start 202602030608-F1Q8AB --agent CODER --slug cli --worktree";
1349
- export const PR_OPEN_USAGE = "Usage: agentplane pr open <task-id> --author <id> [--branch <name>]";
1350
- export const PR_OPEN_USAGE_EXAMPLE = "agentplane pr open 202602030608-F1Q8AB --author CODER";
1351
- export const PR_UPDATE_USAGE = "Usage: agentplane pr update <task-id>";
1352
- export const PR_UPDATE_USAGE_EXAMPLE = "agentplane pr update 202602030608-F1Q8AB";
1353
- export const PR_CHECK_USAGE = "Usage: agentplane pr check <task-id>";
1354
- export const PR_CHECK_USAGE_EXAMPLE = "agentplane pr check 202602030608-F1Q8AB";
1355
- export const PR_NOTE_USAGE = "Usage: agentplane pr note <task-id> --author <id> --body <text>";
1356
- export const PR_NOTE_USAGE_EXAMPLE = 'agentplane pr note 202602030608-F1Q8AB --author REVIEWER --body "..."';
1357
- export const INTEGRATE_USAGE = "Usage: agentplane integrate <task-id> [--branch <name>] [--base <name>] [--merge-strategy squash|merge|rebase] [--run-verify] [--dry-run] [--quiet]";
1358
- export const INTEGRATE_USAGE_EXAMPLE = "agentplane integrate 202602030608-F1Q8AB --run-verify";
1359
- export const CLEANUP_MERGED_USAGE = "Usage: agentplane cleanup merged [--base <name>] [--yes] [--archive] [--quiet]";
1360
- export const CLEANUP_MERGED_USAGE_EXAMPLE = "agentplane cleanup merged --yes";
1361
- function pathIsUnder(candidate, prefix) {
1362
- if (prefix === "." || prefix === "")
1363
- return true;
1364
- if (candidate === prefix)
1365
- return true;
1366
- return candidate.startsWith(`${prefix}/`);
1367
- }
1368
- function normalizeAllowPrefix(prefix) {
1369
- return prefix.replace(/\/+$/, "");
1370
- }
1371
- function nowIso() {
1372
- return new Date().toISOString();
1373
- }
1374
- const ALLOWED_TASK_STATUSES = new Set(["TODO", "DOING", "DONE", "BLOCKED"]);
1375
- function normalizeTaskStatus(value) {
1376
- const normalized = value.trim().toUpperCase();
1377
- if (!ALLOWED_TASK_STATUSES.has(normalized)) {
1378
- throw new CliError({
1379
- exitCode: 2,
1380
- code: "E_USAGE",
1381
- message: invalidValueMessage("status", value, `one of ${[...ALLOWED_TASK_STATUSES].join(", ")}`),
1382
- });
1383
- }
1384
- return normalized;
1385
- }
1386
- export function dedupeStrings(items) {
1387
- const seen = new Set();
1388
- const out = [];
1389
- for (const item of items) {
1390
- const trimmed = item.trim();
1391
- if (!trimmed)
1392
- continue;
1393
- if (seen.has(trimmed))
1394
- continue;
1395
- seen.add(trimmed);
1396
- out.push(trimmed);
1397
- }
1398
- return out;
1399
- }
1400
- function extractDocSection(doc, sectionName) {
1401
- const target = normalizeDocSectionName(sectionName);
1402
- if (!target)
1403
- return null;
1404
- const lines = doc.replaceAll("\r\n", "\n").split("\n");
1405
- let capturing = false;
1406
- const out = [];
1407
- for (const line of lines) {
1408
- const match = /^##\s+(.*)$/.exec(line.trim());
1409
- if (match) {
1410
- const key = normalizeDocSectionName(match[1] ?? "");
1411
- if (capturing)
1412
- break;
1413
- capturing = key === target;
1414
- continue;
1415
- }
1416
- if (capturing)
1417
- out.push(line);
1418
- }
1419
- if (!capturing)
1420
- return null;
1421
- return out.join("\n").trimEnd();
1422
- }
1423
- function stripListMarker(line) {
1424
- return line.replace(/^(?:[-*]|\d+\.)\s+/, "");
1425
- }
1426
- function parseVerifyStepsFromDoc(doc) {
1427
- const section = extractDocSection(doc, "Verify Steps");
1428
- if (!section)
1429
- return { commands: [], steps: [] };
1430
- const commands = [];
1431
- const steps = [];
1432
- const lines = section.split("\n");
1433
- let inFence = false;
1434
- for (const line of lines) {
1435
- const trimmed = line.trim();
1436
- if (!trimmed)
1437
- continue;
1438
- if (trimmed.startsWith("```")) {
1439
- inFence = !inFence;
1440
- continue;
1441
- }
1442
- if (inFence)
1443
- continue;
1444
- const normalized = stripListMarker(trimmed);
1445
- const lower = normalized.toLowerCase();
1446
- if (lower.startsWith("cmd:")) {
1447
- const command = normalized.slice(4).trim();
1448
- if (command)
1449
- commands.push(command);
1450
- continue;
1451
- }
1452
- steps.push(normalized);
1453
- }
1454
- return { commands, steps };
1455
- }
1456
- function renderVerificationSection(opts) {
1457
- const lines = [
1458
- `Status: ${opts.status}`,
1459
- `Verified at: ${opts.verifiedAt}`,
1460
- ...(opts.verifiedSha ? [`Verified sha: ${opts.verifiedSha}`] : []),
1461
- ...(opts.commands.length > 0
1462
- ? ["", "Commands:", ...opts.commands.map((command) => `- ${command}`)]
1463
- : []),
1464
- ...(opts.steps.length > 0
1465
- ? ["", "Manual steps:", ...opts.steps.map((step) => `- ${step}`)]
1466
- : []),
1467
- ...(opts.details ? ["", `Details: ${opts.details}`] : []),
1468
- ];
1469
- return lines.join("\n");
1470
- }
1471
- async function writeVerificationSection(opts) {
1472
- if (!opts.backend.getTaskDoc || !opts.backend.setTaskDoc) {
1473
- throw new CliError({
1474
- exitCode: 2,
1475
- code: "E_USAGE",
1476
- message: backendNotSupportedMessage("task docs"),
1477
- });
1478
- }
1479
- const baseDoc = ensureDocSections(opts.baseDoc ?? "", opts.config.tasks.doc.required_sections);
1480
- const nextDoc = setMarkdownSection(baseDoc, "Verification", opts.content);
1481
- const normalized = ensureDocSections(nextDoc, opts.config.tasks.doc.required_sections);
1482
- await opts.backend.setTaskDoc(opts.taskId, normalized, opts.updatedBy);
1483
- }
1484
- function toStringArray(value) {
1485
- if (!Array.isArray(value))
1486
- return [];
1487
- return value
1488
- .filter((item) => typeof item === "string")
1489
- .map((item) => item.trim());
1490
- }
1491
- function requiresVerify(tags, requiredTags) {
1492
- const required = new Set(requiredTags.map((tag) => tag.trim().toLowerCase()).filter(Boolean));
1493
- if (required.size === 0)
1494
- return false;
1495
- return tags.some((tag) => required.has(tag.trim().toLowerCase()));
1496
- }
1497
- function buildDependencyState(tasks) {
1498
- const byId = new Map(tasks.map((task) => [task.id, task]));
1499
- const state = new Map();
1500
- for (const task of tasks) {
1501
- const dependsOn = dedupeStrings(toStringArray(task.depends_on));
1502
- const missing = [];
1503
- const incomplete = [];
1504
- for (const depId of dependsOn) {
1505
- const dep = byId.get(depId);
1506
- if (!dep) {
1507
- missing.push(depId);
1508
- continue;
1509
- }
1510
- const status = String(dep.status || "TODO").toUpperCase();
1511
- if (status !== "DONE") {
1512
- incomplete.push(depId);
1513
- }
1514
- }
1515
- state.set(task.id, { dependsOn, missing, incomplete });
1516
- }
1517
- return state;
1518
- }
1519
- function formatDepsSummary(dep) {
1520
- if (!dep)
1521
- return null;
1522
- if (dep.dependsOn.length === 0)
1523
- return "deps=none";
1524
- if (dep.missing.length === 0 && dep.incomplete.length === 0)
1525
- return "deps=ready";
1526
- const parts = [];
1527
- if (dep.missing.length > 0) {
1528
- parts.push(`missing:${dep.missing.join(",")}`);
1529
- }
1530
- if (dep.incomplete.length > 0) {
1531
- parts.push(`wait:${dep.incomplete.join(",")}`);
1532
- }
1533
- return `deps=${parts.join(",")}`;
1534
- }
1535
- function formatTaskLine(task, depState) {
1536
- const status = String(task.status || "TODO").toUpperCase();
1537
- const title = task.title?.trim() || "(untitled task)";
1538
- const extras = [];
1539
- if (task.owner?.trim())
1540
- extras.push(`owner=${task.owner.trim()}`);
1541
- if (task.priority !== undefined && String(task.priority).trim()) {
1542
- extras.push(`prio=${String(task.priority).trim()}`);
1543
- }
1544
- const depsSummary = formatDepsSummary(depState);
1545
- if (depsSummary)
1546
- extras.push(depsSummary);
1547
- const tags = dedupeStrings(toStringArray(task.tags));
1548
- if (tags.length > 0)
1549
- extras.push(`tags=${tags.join(",")}`);
1550
- const verify = dedupeStrings(toStringArray(task.verify));
1551
- if (verify.length > 0)
1552
- extras.push(`verify=${verify.length}`);
1553
- const suffix = extras.length > 0 ? ` (${extras.join(", ")})` : "";
1554
- return `${task.id} [${status}] ${title}${suffix}`;
1555
- }
1556
- function isTransitionAllowed(current, next) {
1557
- if (current === next)
1558
- return true;
1559
- if (current === "TODO")
1560
- return next === "DOING" || next === "BLOCKED";
1561
- if (current === "DOING")
1562
- return next === "DONE" || next === "BLOCKED";
1563
- if (current === "BLOCKED")
1564
- return next === "TODO" || next === "DOING";
1565
- if (current === "DONE")
1566
- return false;
1567
- return false;
1568
- }
1569
- function requireStructuredComment(body, prefix, minChars) {
1570
- const normalized = body.trim();
1571
- if (!normalized.toLowerCase().startsWith(prefix.toLowerCase())) {
1572
- throw new CliError({
1573
- exitCode: 2,
1574
- code: "E_USAGE",
1575
- message: `Comment body must start with ${prefix}`,
1576
- });
1577
- }
1578
- if (normalized.length < minChars) {
1579
- throw new CliError({
1580
- exitCode: 2,
1581
- code: "E_USAGE",
1582
- message: `Comment body must be at least ${minChars} characters`,
1583
- });
1584
- }
1585
- }
1586
- async function readHeadCommit(cwd) {
1587
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s"], { cwd });
1588
- const trimmed = stdout.trim();
1589
- const [hash, message] = trimmed.split(":", 2);
1590
- if (!hash || !message) {
1591
- throw new Error("Unable to read git HEAD commit");
1592
- }
1593
- return { hash, message };
1594
- }
1595
- function deriveCommitMessageFromComment(opts) {
1596
- const summary = (opts.formattedComment ?? formatCommentBodyForCommit(opts.body, opts.config))
1597
- .trim()
1598
- .replaceAll(/\s+/g, " ");
1599
- if (!summary) {
1600
- throw new CliError({
1601
- exitCode: 2,
1602
- code: "E_USAGE",
1603
- message: "Comment body is required to build a commit message from the task comment",
1604
- });
1605
- }
1606
- const prefix = opts.emoji.trim();
1607
- if (!prefix) {
1608
- throw new CliError({
1609
- exitCode: 2,
1610
- code: "E_USAGE",
1611
- message: "Emoji prefix is required when deriving commit messages from task comments",
1612
- });
1613
- }
1614
- const suffix = extractTaskSuffix(opts.taskId);
1615
- if (!suffix) {
1616
- throw new CliError({
1617
- exitCode: 2,
1618
- code: "E_USAGE",
1619
- message: invalidValueMessage("task id", opts.taskId, "valid task id"),
1620
- });
1621
- }
1622
- return `${prefix} ${suffix} ${summary}`;
1623
- }
1624
- function enforceStatusCommitPolicy(opts) {
1625
- if (opts.policy === "off")
1626
- return;
1627
- if (opts.policy === "warn") {
1628
- if (!opts.quiet && !opts.confirmed) {
1629
- process.stderr.write(`${warnMessage(`${opts.action}: status/comment-driven commit requested; policy=warn (pass --confirm-status-commit to acknowledge)`)}\n`);
1630
- }
1631
- return;
1632
- }
1633
- if (opts.policy === "confirm" && !opts.confirmed) {
1634
- throw new CliError({
1635
- exitCode: 2,
1636
- code: "E_USAGE",
1637
- message: `${opts.action}: status/comment-driven commit blocked by status_commit_policy='confirm' ` +
1638
- "(pass --confirm-status-commit to proceed)",
1639
- });
1640
- }
1641
- }
1642
- export function suggestAllowPrefixes(paths) {
1643
- const out = new Set();
1644
- for (const filePath of paths) {
1645
- if (!filePath)
1646
- continue;
1647
- const idx = filePath.lastIndexOf("/");
1648
- if (idx <= 0)
1649
- out.add(filePath);
1650
- else
1651
- out.add(filePath.slice(0, idx));
1652
- }
1653
- return [...out].toSorted((a, b) => a.localeCompare(b));
1654
- }
1655
- const HOOK_MARKER = "agentplane-hook";
1656
- const SHIM_MARKER = "agentplane-hook-shim";
1657
- export const HOOK_NAMES = ["commit-msg", "pre-commit", "pre-push"];
1658
- async function archivePrArtifacts(taskDir) {
1659
- const prDir = path.join(taskDir, "pr");
1660
- if (!(await fileExists(prDir)))
1661
- return null;
1662
- const archiveRoot = path.join(taskDir, "pr-archive");
1663
- await mkdir(archiveRoot, { recursive: true });
1664
- const stamp = new Date().toISOString().replaceAll(/[:.]/g, "");
1665
- let dest = path.join(archiveRoot, stamp);
1666
- if (await fileExists(dest)) {
1667
- dest = path.join(archiveRoot, `${stamp}-${Math.random().toString(36).slice(2, 8)}`);
1668
- }
1669
- await rename(prDir, dest);
1670
- return dest;
1671
- }
1672
- async function resolvePathFallback(filePath) {
1673
- try {
1674
- return await realpath(filePath);
1675
- }
1676
- catch {
1677
- return path.resolve(filePath);
1678
- }
1679
- }
1680
- function isPathWithin(parent, candidate) {
1681
- const rel = path.relative(parent, candidate);
1682
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
1683
- }
1684
- async function gitRevParse(cwd, args) {
1685
- const { stdout } = await execFileAsync("git", ["rev-parse", ...args], { cwd, env: gitEnv() });
1686
- const trimmed = stdout.trim();
1687
- if (!trimmed)
1688
- throw new Error("Failed to resolve git path");
1689
- return trimmed;
1690
- }
1691
- export async function gitInitRepo(cwd, branch) {
1692
- try {
1693
- await execFileAsync("git", ["init", "-q", "-b", branch], { cwd, env: gitEnv() });
1694
- return;
1695
- }
1696
- catch {
1697
- await execFileAsync("git", ["init", "-q"], { cwd, env: gitEnv() });
1698
- }
1699
- try {
1700
- const current = await gitCurrentBranch(cwd);
1701
- if (current !== branch) {
1702
- await execFileAsync("git", ["checkout", "-q", "-b", branch], { cwd, env: gitEnv() });
1703
- }
1704
- }
1705
- catch {
1706
- await execFileAsync("git", ["checkout", "-q", "-b", branch], { cwd, env: gitEnv() });
1707
- }
1708
- }
1709
- async function gitCurrentBranch(cwd) {
1710
- try {
1711
- const { stdout } = await execFileAsync("git", ["symbolic-ref", "--short", "HEAD"], {
1712
- cwd,
1713
- env: gitEnv(),
1714
- });
1715
- const trimmed = stdout.trim();
1716
- if (trimmed)
1717
- return trimmed;
1718
- }
1719
- catch {
1720
- // fall through
1721
- }
1722
- const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1723
- cwd,
1724
- env: gitEnv(),
1725
- });
1726
- const trimmed = stdout.trim();
1727
- if (!trimmed || trimmed === "HEAD")
1728
- throw new Error("Failed to resolve git branch");
1729
- return trimmed;
1730
- }
1731
- async function gitBranchExists(cwd, branch) {
1732
- try {
1733
- await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
1734
- cwd,
1735
- env: gitEnv(),
1736
- });
1737
- return true;
1738
- }
1739
- catch (err) {
1740
- const code = err?.code;
1741
- if (code === 1)
1742
- return false;
1743
- throw err;
1744
- }
1745
- }
1746
- async function gitListBranches(cwd) {
1747
- const { stdout } = await execFileAsync("git", ["branch", "--format=%(refname:short)"], {
1748
- cwd,
1749
- env: gitEnv(),
1750
- });
1751
- return stdout
1752
- .split("\n")
1753
- .map((line) => line.trim())
1754
- .filter((line) => line.length > 0);
1755
- }
1756
- async function gitStagedPaths(cwd) {
1757
- const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
1758
- cwd,
1759
- env: gitEnv(),
1760
- });
1761
- return stdout
1762
- .split("\n")
1763
- .map((line) => line.trim())
1764
- .filter((line) => line.length > 0);
1765
- }
1766
- async function gitAddPaths(cwd, paths) {
1767
- if (paths.length === 0)
1768
- return;
1769
- await execFileAsync("git", ["add", "--", ...paths], { cwd, env: gitEnv() });
1770
- }
1771
- async function gitCommit(cwd, message, opts) {
1772
- const args = ["commit", "-m", message];
1773
- if (opts?.skipHooks)
1774
- args.push("--no-verify");
1775
- const env = opts?.env ? { ...gitEnv(), ...opts.env } : gitEnv();
1776
- await execFileAsync("git", args, { cwd, env });
1777
- }
1778
- export async function resolveInitBaseBranch(gitRoot, fallback) {
1779
- let current = null;
1780
- try {
1781
- current = await gitCurrentBranch(gitRoot);
1782
- }
1783
- catch {
1784
- current = null;
1785
- }
1786
- const branches = await gitListBranches(gitRoot);
1787
- if (current)
1788
- return current;
1789
- if (branches.includes(fallback))
1790
- return fallback;
1791
- if (branches.length > 0) {
1792
- const first = branches[0];
1793
- if (first)
1794
- return first;
1795
- }
1796
- return fallback;
1797
- }
1798
- export async function promptInitBaseBranch(opts) {
1799
- const branches = await gitListBranches(opts.gitRoot);
1800
- let current = null;
1801
- try {
1802
- current = await gitCurrentBranch(opts.gitRoot);
1803
- }
1804
- catch {
1805
- current = null;
1806
- }
1807
- const promptNewBranch = async (hasBranches) => {
1808
- const raw = await promptInput(`Enter new base branch name (default ${opts.fallback}): `);
1809
- const candidate = raw.trim() || opts.fallback;
1810
- if (!candidate) {
1811
- throw new CliError({
1812
- exitCode: 2,
1813
- code: "E_USAGE",
1814
- message: "Base branch name cannot be empty",
1815
- });
1816
- }
1817
- if (await gitBranchExists(opts.gitRoot, candidate))
1818
- return candidate;
1819
- try {
1820
- await execFileAsync("git", hasBranches ? ["branch", candidate] : ["checkout", "-q", "-b", candidate], { cwd: opts.gitRoot, env: gitEnv() });
1821
- }
1822
- catch (err) {
1823
- const message = err instanceof Error ? err.message : `Failed to create branch ${candidate}`;
1824
- throw new CliError({ exitCode: 5, code: "E_GIT", message });
1825
- }
1826
- return candidate;
1827
- };
1828
- if (branches.length === 0) {
1829
- return await promptNewBranch(false);
1830
- }
1831
- const createLabel = "Create new branch";
1832
- const defaultChoice = current && branches.includes(current) ? current : (branches[0] ?? opts.fallback);
1833
- const choice = await promptChoice("Select base branch", [...branches, createLabel], defaultChoice);
1834
- if (choice === createLabel) {
1835
- return await promptNewBranch(true);
1836
- }
1837
- return choice;
1838
- }
1839
- export async function ensureInitCommit(opts) {
1840
- const stagedBefore = await gitStagedPaths(opts.gitRoot);
1841
- if (stagedBefore.length > 0) {
1842
- throw new CliError({
1843
- exitCode: 5,
1844
- code: "E_GIT",
1845
- message: "Git index has staged changes; commit or unstage them before running agentplane init.",
1846
- });
1847
- }
1848
- await setPinnedBaseBranch({
1849
- cwd: opts.gitRoot,
1850
- rootOverride: opts.gitRoot,
1851
- value: opts.baseBranch,
1852
- });
1853
- const dedupedPaths = [...new Set(opts.installPaths)].filter((entry) => entry.length > 0);
1854
- await gitAddPaths(opts.gitRoot, dedupedPaths);
1855
- const staged = await gitStagedPaths(opts.gitRoot);
1856
- if (staged.length === 0)
1857
- return;
1858
- const message = `chore: install agentplane ${opts.version}`;
1859
- await gitCommit(opts.gitRoot, message, { skipHooks: opts.skipHooks });
1860
- }
1861
- function toGitPath(filePath) {
1862
- return filePath.split(path.sep).join("/");
1863
- }
1864
- async function gitShowFile(cwd, ref, relPath) {
1865
- const { stdout } = await execFileAsync("git", ["show", `${ref}:${relPath}`], {
1866
- cwd,
1867
- env: gitEnv(),
1868
- });
1869
- return stdout;
1870
- }
1871
- async function gitDiffNames(cwd, base, branch) {
1872
- const { stdout } = await execFileAsync("git", ["diff", "--name-only", `${base}...${branch}`], {
1873
- cwd,
1874
- env: gitEnv(),
1875
- });
1876
- return stdout
1877
- .split("\n")
1878
- .map((line) => line.trim())
1879
- .filter((line) => line.length > 0);
1880
- }
1881
- async function gitDiffStat(cwd, base, branch) {
1882
- const { stdout } = await execFileAsync("git", ["diff", "--stat", `${base}...${branch}`], {
1883
- cwd,
1884
- env: gitEnv(),
1885
- });
1886
- return stdout.trimEnd();
1887
- }
1888
- async function gitAheadBehind(cwd, base, branch) {
1889
- const { stdout } = await execFileAsync("git", ["rev-list", "--left-right", "--count", `${base}...${branch}`], { cwd, env: gitEnv() });
1890
- const trimmed = stdout.trim();
1891
- if (!trimmed)
1892
- return { ahead: 0, behind: 0 };
1893
- const parts = trimmed.split(/\s+/);
1894
- if (parts.length !== 2)
1895
- return { ahead: 0, behind: 0 };
1896
- const behind = Number.parseInt(parts[0] ?? "0", 10) || 0;
1897
- const ahead = Number.parseInt(parts[1] ?? "0", 10) || 0;
1898
- return { ahead, behind };
1899
- }
1900
- async function listWorktrees(cwd) {
1901
- const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], {
1902
- cwd,
1903
- env: gitEnv(),
1904
- });
1905
- const worktrees = [];
1906
- const lines = stdout.split("\n");
1907
- let current = null;
1908
- for (const line of lines) {
1909
- if (line.startsWith("worktree ")) {
1910
- if (current)
1911
- worktrees.push(current);
1912
- current = { path: line.slice("worktree ".length).trim(), branch: null };
1913
- continue;
1914
- }
1915
- if (line.startsWith("branch ") && current) {
1916
- current.branch = line.slice("branch ".length).trim();
1917
- }
1918
- }
1919
- if (current)
1920
- worktrees.push(current);
1921
- return worktrees;
1922
- }
1923
- async function findWorktreeForBranch(cwd, branch) {
1924
- const target = branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
1925
- const worktrees = await listWorktrees(cwd);
1926
- const match = worktrees.find((entry) => entry.branch === branch || entry.branch === target || entry.branch === `refs/heads/${branch}`);
1927
- return match ? match.path : null;
1928
- }
1929
- function stripBranchRef(branch) {
1930
- return branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
1931
- }
1932
- function parseTaskIdFromBranch(prefix, branch) {
1933
- const normalized = stripBranchRef(branch);
1934
- if (!normalized.startsWith(`${prefix}/`))
1935
- return null;
1936
- const rest = normalized.slice(prefix.length + 1);
1937
- const taskId = rest.split("/", 1)[0];
1938
- return taskId ? taskId.trim() : null;
1939
- }
1940
- async function gitListTaskBranches(cwd, prefix) {
1941
- const { stdout } = await execFileAsync("git", ["for-each-ref", "--format=%(refname:short)", `refs/heads/${prefix}`], { cwd, env: gitEnv() });
1942
- return stdout
1943
- .split("\n")
1944
- .map((line) => line.trim())
1945
- .filter((line) => line.length > 0);
1946
- }
1947
- async function resolveGitHooksDir(cwd) {
1948
- const repoRoot = await gitRevParse(cwd, ["--show-toplevel"]);
1949
- const commonDirRaw = await gitRevParse(cwd, ["--git-common-dir"]);
1950
- const hooksRaw = await gitRevParse(cwd, ["--git-path", "hooks"]);
1951
- const commonDir = path.resolve(path.isAbsolute(commonDirRaw) ? commonDirRaw : path.join(repoRoot, commonDirRaw));
1952
- const hooksDir = path.resolve(path.isAbsolute(hooksRaw) ? hooksRaw : path.join(repoRoot, hooksRaw));
1953
- const resolvedRoot = path.resolve(repoRoot);
1954
- if (!isPathWithin(resolvedRoot, hooksDir) && !isPathWithin(commonDir, hooksDir)) {
1955
- throw new CliError({
1956
- exitCode: 5,
1957
- code: "E_GIT",
1958
- message: [
1959
- "Refusing to manage git hooks outside the repository.",
1960
- `hooks_path=${hooksDir}`,
1961
- `repo_root=${resolvedRoot}`,
1962
- `common_dir=${commonDir}`,
1963
- "Fix:",
1964
- " 1) Use a repo-relative core.hooksPath (e.g., .git/hooks)",
1965
- " 2) Re-run `agentplane hooks install`",
1966
- ].join("\n"),
1967
- });
1968
- }
1969
- return hooksDir;
1970
- }
1971
- async function fileIsManaged(filePath, marker) {
1972
- try {
1973
- const content = await readFile(filePath, "utf8");
1974
- return content.includes(marker);
1975
- }
1976
- catch {
1977
- return false;
1978
- }
1979
- }
1980
- function hookScriptText(hook) {
1981
- return [
1982
- "#!/usr/bin/env sh",
1983
- `# ${HOOK_MARKER} (do not edit)`,
1984
- "set -e",
1985
- "if ! command -v agentplane >/dev/null 2>&1; then",
1986
- ' echo "agentplane hooks: agentplane not found in PATH" >&2',
1987
- " exit 1",
1988
- "fi",
1989
- "exec agentplane hooks run " + hook + ' "$@"',
1990
- "",
1991
- ].join("\n");
1992
- }
1993
- function shimScriptText() {
1994
- return [
1995
- "#!/usr/bin/env sh",
1996
- `# ${SHIM_MARKER} (do not edit)`,
1997
- "set -e",
1998
- "if ! command -v agentplane >/dev/null 2>&1; then",
1999
- ' echo "agentplane shim: agentplane not found in PATH" >&2',
2000
- " exit 1",
2001
- "fi",
2002
- 'exec agentplane "$@"',
2003
- "",
2004
- ].join("\n");
2005
- }
2006
- function validateWorkSlug(slug) {
2007
- const trimmed = slug.trim();
2008
- if (!trimmed) {
2009
- throw new CliError({
2010
- exitCode: 2,
2011
- code: "E_USAGE",
2012
- message: usageMessage(WORK_START_USAGE, WORK_START_USAGE_EXAMPLE),
2013
- });
2014
- }
2015
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) {
2016
- throw new CliError({
2017
- exitCode: 2,
2018
- code: "E_USAGE",
2019
- message: "Slug must be lowercase kebab-case (a-z, 0-9, hyphen)",
2020
- });
2021
- }
2022
- }
2023
- function validateWorkAgent(agent) {
2024
- const trimmed = agent.trim();
2025
- if (!trimmed) {
2026
- throw new CliError({
2027
- exitCode: 2,
2028
- code: "E_USAGE",
2029
- message: usageMessage(WORK_START_USAGE, WORK_START_USAGE_EXAMPLE),
2030
- });
2031
- }
2032
- if (!/^[A-Z0-9_]+$/.test(trimmed)) {
2033
- throw new CliError({
2034
- exitCode: 2,
2035
- code: "E_USAGE",
2036
- message: "Agent id must be uppercase letters, numbers, or underscores",
2037
- });
2038
- }
2039
- }
2040
- function isRecord(value) {
2041
- return !!value && typeof value === "object" && !Array.isArray(value);
2042
- }
2043
- function isIsoDate(value) {
2044
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
2045
- }
2046
- function parsePrMeta(raw, taskId) {
2047
- let parsed;
2048
- try {
2049
- parsed = JSON.parse(raw);
2050
- }
2051
- catch (err) {
2052
- const message = err instanceof Error ? err.message : String(err);
2053
- throw new Error(`JSON Parse error: ${message}`);
2054
- }
2055
- if (!isRecord(parsed))
2056
- throw new Error("pr/meta.json must be an object");
2057
- if (parsed.schema_version !== 1)
2058
- throw new Error("pr/meta.json schema_version must be 1");
2059
- if (parsed.task_id !== taskId)
2060
- throw new Error("pr/meta.json task_id mismatch");
2061
- if (!isIsoDate(parsed.created_at))
2062
- throw new Error("pr/meta.json created_at must be ISO");
2063
- if (!isIsoDate(parsed.updated_at))
2064
- throw new Error("pr/meta.json updated_at must be ISO");
2065
- return parsed;
2066
- }
2067
- function extractLastVerifiedSha(logText) {
2068
- const regex = /verified_sha=([0-9a-f]{7,40})/gi;
2069
- let match = null;
2070
- let last = null;
2071
- while ((match = regex.exec(logText))) {
2072
- last = match[1] ?? null;
2073
- }
2074
- return last;
2075
- }
2076
- async function appendVerifyLog(logPath, header, content) {
2077
- await mkdir(path.dirname(logPath), { recursive: true });
2078
- const lines = [header.trimEnd()];
2079
- if (content)
2080
- lines.push(content.trimEnd());
2081
- lines.push("");
2082
- await writeFile(logPath, `${lines.join("\n")}\n`, { flag: "a" });
2083
- }
2084
- async function runShellCommand(command, cwd) {
2085
- try {
2086
- const { stdout, stderr } = await execFileAsync("sh", ["-lc", command], {
2087
- cwd,
2088
- env: process.env,
2089
- maxBuffer: 10 * 1024 * 1024,
2090
- });
2091
- let output = "";
2092
- if (stdout)
2093
- output += stdout;
2094
- if (stderr)
2095
- output += (output && !output.endsWith("\n") ? "\n" : "") + stderr;
2096
- return { code: 0, output };
2097
- }
2098
- catch (err) {
2099
- const error = err;
2100
- let output = "";
2101
- if (error.stdout)
2102
- output += String(error.stdout);
2103
- if (error.stderr)
2104
- output += (output && !output.endsWith("\n") ? "\n" : "") + String(error.stderr);
2105
- const code = typeof error.code === "number" ? error.code : 1;
2106
- return { code, output };
2107
- }
2108
- }
2109
- function renderPrReviewTemplate(opts) {
2110
- return [
2111
- "# PR Review",
2112
- "",
2113
- `Opened by ${opts.author} on ${opts.createdAt}`,
2114
- `Branch: ${opts.branch}`,
2115
- "",
2116
- "## Summary",
2117
- "",
2118
- "- ",
2119
- "",
2120
- "## Checklist",
2121
- "",
2122
- "- [ ] Tests added/updated",
2123
- "- [ ] Lint/format passes",
2124
- "- [ ] Docs updated",
2125
- "",
2126
- "## Handoff Notes",
2127
- "",
2128
- "- ",
2129
- "",
2130
- "<!-- BEGIN AUTO SUMMARY -->",
2131
- "<!-- END AUTO SUMMARY -->",
2132
- "",
2133
- ].join("\n");
2134
- }
2135
- function updateAutoSummaryBlock(body, summary) {
2136
- const begin = "<!-- BEGIN AUTO SUMMARY -->";
2137
- const end = "<!-- END AUTO SUMMARY -->";
2138
- const normalizedBody = body.replaceAll("\r\n", "\n");
2139
- const beginIndex = normalizedBody.indexOf(begin);
2140
- const endIndex = normalizedBody.indexOf(end);
2141
- const block = `${begin}\n${summary}\n${end}`;
2142
- if (beginIndex !== -1 && endIndex !== -1 && endIndex > beginIndex) {
2143
- const before = normalizedBody.slice(0, beginIndex);
2144
- const after = normalizedBody.slice(endIndex + end.length);
2145
- return `${before}${block}${after}`;
2146
- }
2147
- const trimmed = normalizedBody.trimEnd();
2148
- return `${trimmed}\n\n${block}\n`;
2149
- }
2150
- function appendHandoffNote(body, noteLine) {
2151
- const normalized = body.replaceAll("\r\n", "\n");
2152
- const heading = "## Handoff Notes";
2153
- const lines = normalized.split("\n");
2154
- const headingIndex = lines.findIndex((line) => line.trim() === heading);
2155
- const note = `- ${noteLine}`;
2156
- if (headingIndex === -1) {
2157
- const trimmed = normalized.trimEnd();
2158
- return `${trimmed}\n\n${heading}\n\n${note}\n`;
2159
- }
2160
- let nextHeading = lines.length;
2161
- for (let i = headingIndex + 1; i < lines.length; i++) {
2162
- if (lines[i]?.startsWith("## ")) {
2163
- nextHeading = i;
2164
- break;
2165
- }
2166
- }
2167
- const before = lines.slice(0, nextHeading);
2168
- const after = lines.slice(nextHeading);
2169
- if (before.at(-1)?.trim() !== "")
2170
- before.push("");
2171
- before.push(note, "");
2172
- return [...before, ...after].join("\n");
2173
- }
2174
- async function ensureShim(agentplaneDir, gitRoot) {
2175
- const shimDir = path.join(agentplaneDir, "bin");
2176
- const shimPath = path.join(shimDir, "agentplane");
2177
- await mkdir(shimDir, { recursive: true });
2178
- if (await fileExists(shimPath)) {
2179
- const managed = await fileIsManaged(shimPath, SHIM_MARKER);
2180
- if (!managed) {
2181
- throw new CliError({
2182
- exitCode: 5,
2183
- code: "E_GIT",
2184
- message: `Refusing to overwrite existing shim: ${path.relative(gitRoot, shimPath)}`,
2185
- });
2186
- }
2187
- }
2188
- await writeFile(shimPath, shimScriptText(), "utf8");
2189
- await chmod(shimPath, 0o755);
2190
- }
2191
- function readCommitSubject(message) {
2192
- for (const line of message.split("\n")) {
2193
- const trimmed = line.trim();
2194
- if (!trimmed || trimmed.startsWith("#"))
2195
- continue;
2196
- return trimmed;
2197
- }
2198
- return "";
2199
- }
2200
- function subjectHasSuffix(subject, suffixes) {
2201
- const lowered = subject.toLowerCase();
2202
- return suffixes.some((suffix) => suffix && lowered.includes(suffix.toLowerCase()));
2203
- }
2204
- export async function cmdGuardClean(opts) {
2205
- try {
2206
- const staged = await getStagedFiles({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
2207
- if (staged.length > 0) {
2208
- throw new CliError({
2209
- exitCode: 5,
2210
- code: "E_GIT",
2211
- message: "Staged files exist",
2212
- });
2213
- }
2214
- if (!opts.quiet) {
2215
- process.stdout.write(`${successMessage("index clean", undefined, "no staged files")}\n`);
2216
- }
2217
- return 0;
2218
- }
2219
- catch (err) {
2220
- if (err instanceof CliError)
2221
- throw err;
2222
- throw mapCoreError(err, { command: "guard clean", root: opts.rootOverride ?? null });
2223
- }
2224
- }
2225
- export async function cmdGuardSuggestAllow(opts) {
2226
- try {
2227
- const staged = await getStagedFiles({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
2228
- if (staged.length === 0) {
2229
- throw new CliError({
2230
- exitCode: 2,
2231
- code: "E_USAGE",
2232
- message: "No staged files (git index empty)",
2233
- });
2234
- }
2235
- const prefixes = suggestAllowPrefixes(staged);
2236
- if (opts.format === "args") {
2237
- const args = prefixes.map((p) => `--allow ${p}`).join(" ");
2238
- process.stdout.write(`${args}\n`);
2239
- }
2240
- else {
2241
- for (const prefix of prefixes)
2242
- process.stdout.write(`${prefix}\n`);
2243
- }
2244
- return 0;
2245
- }
2246
- catch (err) {
2247
- throw mapCoreError(err, { command: "guard suggest-allow", root: opts.rootOverride ?? null });
2248
- }
2249
- }
2250
- async function guardCommitCheck(opts) {
2251
- const resolved = await resolveProject({
2252
- cwd: opts.cwd,
2253
- rootOverride: opts.rootOverride ?? null,
2254
- });
2255
- const loaded = await loadConfig(resolved.agentplaneDir);
2256
- const policy = validateCommitSubject({
2257
- subject: opts.message,
2258
- taskId: opts.taskId,
2259
- genericTokens: loaded.config.commit.generic_tokens,
2260
- });
2261
- if (!policy.ok) {
2262
- throw new CliError({ exitCode: 5, code: "E_GIT", message: policy.errors.join("\n") });
2263
- }
2264
- const staged = await getStagedFiles({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
2265
- if (staged.length === 0) {
2266
- throw new CliError({
2267
- exitCode: 5,
2268
- code: "E_GIT",
2269
- message: "No staged files (git index empty)",
2270
- });
2271
- }
2272
- if (opts.allow.length === 0) {
2273
- throw new CliError({
2274
- exitCode: 5,
2275
- code: "E_GIT",
2276
- message: "Provide at least one --allow <path> prefix",
2277
- });
2278
- }
2279
- const allow = opts.allow.map((prefix) => normalizeAllowPrefix(prefix));
2280
- const denied = new Set();
2281
- if (!opts.allowTasks)
2282
- denied.add(".agentplane/tasks.json");
2283
- if (opts.requireClean) {
2284
- const unstaged = await getUnstagedFiles({
2285
- cwd: opts.cwd,
2286
- rootOverride: opts.rootOverride ?? null,
2287
- });
2288
- if (unstaged.length > 0) {
2289
- throw new CliError({ exitCode: 5, code: "E_GIT", message: "Working tree is dirty" });
2290
- }
2291
- }
2292
- for (const filePath of staged) {
2293
- if (denied.has(filePath)) {
2294
- throw new CliError({
2295
- exitCode: 5,
2296
- code: "E_GIT",
2297
- message: `Staged file is forbidden by default: ${filePath} (use --allow-tasks to override)`,
2298
- });
2299
- }
2300
- if (!allow.some((prefix) => pathIsUnder(filePath, prefix))) {
2301
- throw new CliError({
2302
- exitCode: 5,
2303
- code: "E_GIT",
2304
- message: `Staged file is outside allowlist: ${filePath}`,
2305
- });
2306
- }
2307
- }
2308
- }
2309
- async function gitStatusChangedPaths(opts) {
2310
- const resolved = await resolveProject({
2311
- cwd: opts.cwd,
2312
- rootOverride: opts.rootOverride ?? null,
2313
- });
2314
- const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
2315
- cwd: resolved.gitRoot,
2316
- });
2317
- const files = [];
2318
- for (const line of stdout.split("\n")) {
2319
- const trimmed = line.trim();
2320
- if (!trimmed)
2321
- continue;
2322
- const filePart = trimmed.slice(2).trim();
2323
- if (!filePart)
2324
- continue;
2325
- const name = filePart.includes("->") ? filePart.split("->").at(-1)?.trim() : filePart;
2326
- if (name)
2327
- files.push(name);
2328
- }
2329
- return files;
2330
- }
2331
- async function ensureGitClean(opts) {
2332
- const staged = await getStagedFiles({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
2333
- if (staged.length > 0) {
2334
- throw new CliError({ exitCode: 5, code: "E_GIT", message: "Working tree has staged changes" });
2335
- }
2336
- const unstaged = await getUnstagedFiles({
2337
- cwd: opts.cwd,
2338
- rootOverride: opts.rootOverride ?? null,
2339
- });
2340
- if (unstaged.length > 0) {
2341
- throw new CliError({
2342
- exitCode: 5,
2343
- code: "E_GIT",
2344
- message: "Working tree has unstaged changes",
2345
- });
2346
- }
2347
- }
2348
- async function stageAllowlist(opts) {
2349
- const resolved = await resolveProject({
2350
- cwd: opts.cwd,
2351
- rootOverride: opts.rootOverride ?? null,
2352
- });
2353
- const changed = await gitStatusChangedPaths({ cwd: opts.cwd, rootOverride: opts.rootOverride });
2354
- if (changed.length === 0) {
2355
- throw new CliError({
2356
- exitCode: 2,
2357
- code: "E_USAGE",
2358
- message: "No changes to stage (working tree clean)",
2359
- });
2360
- }
2361
- const allow = opts.allow.map((prefix) => normalizeAllowPrefix(prefix.trim().replace(/^\.?\//, "")));
2362
- const denied = new Set();
2363
- if (!opts.allowTasks)
2364
- denied.add(".agentplane/tasks.json");
2365
- const staged = [];
2366
- for (const filePath of changed) {
2367
- if (denied.has(filePath))
2368
- continue;
2369
- if (allow.some((prefix) => pathIsUnder(filePath, prefix))) {
2370
- staged.push(filePath);
2371
- }
2372
- }
2373
- const unique = [...new Set(staged)].toSorted((a, b) => a.localeCompare(b));
2374
- if (unique.length === 0) {
2375
- throw new CliError({
2376
- exitCode: 2,
2377
- code: "E_USAGE",
2378
- message: "No changes matched allowed prefixes (use --commit-auto-allow or update --commit-allow)",
2379
- });
2380
- }
2381
- await execFileAsync("git", ["add", "--", ...unique], { cwd: resolved.gitRoot });
2382
- return unique;
2383
- }
2384
- async function commitFromComment(opts) {
2385
- let allowPrefixes = opts.allow.map((prefix) => prefix.trim()).filter(Boolean);
2386
- if (opts.autoAllow && allowPrefixes.length === 0) {
2387
- const changed = await gitStatusChangedPaths({ cwd: opts.cwd, rootOverride: opts.rootOverride });
2388
- allowPrefixes = suggestAllowPrefixes(changed);
2389
- }
2390
- if (allowPrefixes.length === 0) {
2391
- throw new CliError({
2392
- exitCode: 2,
2393
- code: "E_USAGE",
2394
- message: "Provide at least one --commit-allow prefix or enable --commit-auto-allow",
2395
- });
2396
- }
2397
- const staged = await stageAllowlist({
2398
- cwd: opts.cwd,
2399
- rootOverride: opts.rootOverride,
2400
- allow: allowPrefixes,
2401
- allowTasks: opts.allowTasks,
2402
- });
2403
- const message = deriveCommitMessageFromComment({
2404
- taskId: opts.taskId,
2405
- body: opts.commentBody,
2406
- emoji: opts.emoji,
2407
- formattedComment: opts.formattedComment,
2408
- config: opts.config,
2409
- });
2410
- await guardCommitCheck({
2411
- cwd: opts.cwd,
2412
- rootOverride: opts.rootOverride,
2413
- taskId: opts.taskId,
2414
- message,
2415
- allow: allowPrefixes,
2416
- allowTasks: opts.allowTasks,
2417
- requireClean: opts.requireClean,
2418
- quiet: opts.quiet,
2419
- });
2420
- const resolved = await resolveProject({
2421
- cwd: opts.cwd,
2422
- rootOverride: opts.rootOverride ?? null,
2423
- });
2424
- const env = {
2425
- ...process.env,
2426
- AGENTPLANE_TASK_ID: opts.taskId,
2427
- AGENTPLANE_ALLOW_TASKS: opts.allowTasks ? "1" : "0",
2428
- AGENTPLANE_ALLOW_BASE: opts.allowTasks ? "1" : "0",
2429
- };
2430
- await execFileAsync("git", ["commit", "-m", message], { cwd: resolved.gitRoot, env });
2431
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s"], {
2432
- cwd: resolved.gitRoot,
2433
- });
2434
- const trimmed = stdout.trim();
2435
- const [hash, subject] = trimmed.split(":", 2);
2436
- if (!opts.quiet) {
2437
- process.stdout.write(`${successMessage("committed", `${hash?.slice(0, 12) ?? ""} ${subject ?? ""}`.trim(), `staged=${staged.join(", ")}`)}\n`);
2438
- }
2439
- return { hash: hash ?? "", message: subject ?? "", staged };
2440
- }
2441
- export async function cmdGuardCommit(opts) {
2442
- try {
2443
- await guardCommitCheck(opts);
2444
- if (!opts.quiet)
2445
- process.stdout.write("OK\n");
2446
- return 0;
2447
- }
2448
- catch (err) {
2449
- if (err instanceof CliError)
2450
- throw err;
2451
- throw mapCoreError(err, { command: "guard commit", root: opts.rootOverride ?? null });
2452
- }
2453
- }
2454
- export async function cmdCommit(opts) {
2455
- try {
2456
- let allow = opts.allow;
2457
- if (opts.autoAllow && allow.length === 0) {
2458
- const staged = await getStagedFiles({
2459
- cwd: opts.cwd,
2460
- rootOverride: opts.rootOverride ?? null,
2461
- });
2462
- const prefixes = suggestAllowPrefixes(staged);
2463
- if (prefixes.length === 0) {
2464
- throw new CliError({
2465
- exitCode: 5,
2466
- code: "E_GIT",
2467
- message: "No staged files (git index empty)",
2468
- });
2469
- }
2470
- allow = prefixes;
2471
- }
2472
- await guardCommitCheck({
2473
- cwd: opts.cwd,
2474
- rootOverride: opts.rootOverride,
2475
- taskId: opts.taskId,
2476
- message: opts.message,
2477
- allow,
2478
- allowTasks: opts.allowTasks,
2479
- requireClean: opts.requireClean,
2480
- quiet: opts.quiet,
2481
- });
2482
- const resolved = await resolveProject({
2483
- cwd: opts.cwd,
2484
- rootOverride: opts.rootOverride ?? null,
2485
- });
2486
- const env = {
2487
- ...process.env,
2488
- AGENTPLANE_TASK_ID: opts.taskId,
2489
- AGENTPLANE_ALLOW_TASKS: opts.allowTasks ? "1" : "0",
2490
- AGENTPLANE_ALLOW_BASE: opts.allowBase ? "1" : "0",
2491
- };
2492
- await execFileAsync("git", ["commit", "-m", opts.message], { cwd: resolved.gitRoot, env });
2493
- if (!opts.quiet) {
2494
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s"], {
2495
- cwd: resolved.gitRoot,
2496
- });
2497
- const trimmed = stdout.trim();
2498
- const [hash, subject] = trimmed.split(":", 2);
2499
- process.stdout.write(`${successMessage("committed", `${hash?.slice(0, 12) ?? ""} ${subject ?? ""}`.trim())}\n`);
2500
- }
2501
- return 0;
2502
- }
2503
- catch (err) {
2504
- if (err instanceof CliError)
2505
- throw err;
2506
- throw mapCoreError(err, { command: "commit", root: opts.rootOverride ?? null });
2507
- }
2508
- }
2509
- export async function cmdStart(opts) {
2510
- try {
2511
- const resolved = await resolveProject({
2512
- cwd: opts.cwd,
2513
- rootOverride: opts.rootOverride ?? null,
2514
- });
2515
- const loaded = await loadConfig(resolved.agentplaneDir);
2516
- if (opts.commitFromComment) {
2517
- enforceStatusCommitPolicy({
2518
- policy: loaded.config.status_commit_policy,
2519
- action: "start",
2520
- confirmed: opts.confirmStatusCommit,
2521
- quiet: opts.quiet,
2522
- });
2523
- }
2524
- const { prefix, min_chars: minChars } = loaded.config.tasks.comments.start;
2525
- requireStructuredComment(opts.body, prefix, minChars);
2526
- const { backend, task } = await loadBackendTask({
2527
- cwd: opts.cwd,
2528
- rootOverride: opts.rootOverride,
2529
- taskId: opts.taskId,
2530
- });
2531
- const currentStatus = String(task.status || "TODO").toUpperCase();
2532
- if (!opts.force && !isTransitionAllowed(currentStatus, "DOING")) {
2533
- throw new CliError({
2534
- exitCode: 2,
2535
- code: "E_USAGE",
2536
- message: `Refusing status transition ${currentStatus} -> DOING (use --force to override)`,
2537
- });
2538
- }
2539
- if (!opts.force) {
2540
- const allTasks = await backend.listTasks();
2541
- const depState = buildDependencyState(allTasks);
2542
- const dep = depState.get(task.id);
2543
- if (dep && (dep.missing.length > 0 || dep.incomplete.length > 0)) {
2544
- if (!opts.quiet) {
2545
- if (dep.missing.length > 0) {
2546
- process.stderr.write(`${warnMessage(`missing deps: ${dep.missing.join(", ")}`)}\n`);
2547
- }
2548
- if (dep.incomplete.length > 0) {
2549
- process.stderr.write(`${warnMessage(`incomplete deps: ${dep.incomplete.join(", ")}`)}\n`);
2550
- }
2551
- }
2552
- throw new CliError({
2553
- exitCode: 2,
2554
- code: "E_USAGE",
2555
- message: `Task is not ready: ${task.id} (use --force to override)`,
2556
- });
2557
- }
2558
- }
2559
- const formattedComment = opts.commitFromComment
2560
- ? formatCommentBodyForCommit(opts.body, loaded.config)
2561
- : null;
2562
- const commentBody = formattedComment ?? opts.body;
2563
- const existingComments = Array.isArray(task.comments)
2564
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
2565
- : [];
2566
- const commentsValue = [
2567
- ...existingComments,
2568
- { author: opts.author, body: commentBody },
2569
- ];
2570
- const nextTask = {
2571
- ...task,
2572
- status: "DOING",
2573
- comments: commentsValue,
2574
- doc_version: 2,
2575
- doc_updated_at: nowIso(),
2576
- doc_updated_by: opts.author,
2577
- };
2578
- await backend.writeTask(nextTask);
2579
- let commitInfo = null;
2580
- if (opts.commitFromComment) {
2581
- commitInfo = await commitFromComment({
2582
- cwd: opts.cwd,
2583
- rootOverride: opts.rootOverride,
2584
- taskId: opts.taskId,
2585
- commentBody: opts.body,
2586
- formattedComment,
2587
- emoji: opts.commitEmoji ?? "🚧",
2588
- allow: opts.commitAllow,
2589
- autoAllow: opts.commitAutoAllow || opts.commitAllow.length === 0,
2590
- allowTasks: opts.commitAllowTasks,
2591
- requireClean: opts.commitRequireClean,
2592
- quiet: opts.quiet,
2593
- config: loaded.config,
2594
- });
2595
- }
2596
- if (!opts.quiet) {
2597
- const suffix = commitInfo ? ` (commit=${commitInfo.hash.slice(0, 12)})` : "";
2598
- process.stdout.write(`${successMessage("started", `${opts.taskId}${suffix}`)}\n`);
2599
- }
2600
- return 0;
2601
- }
2602
- catch (err) {
2603
- if (err instanceof CliError)
2604
- throw err;
2605
- throw mapBackendError(err, { command: "start", root: opts.rootOverride ?? null });
2606
- }
2607
- }
2608
- export async function cmdBlock(opts) {
2609
- try {
2610
- const resolved = await resolveProject({
2611
- cwd: opts.cwd,
2612
- rootOverride: opts.rootOverride ?? null,
2613
- });
2614
- const loaded = await loadConfig(resolved.agentplaneDir);
2615
- if (opts.commitFromComment) {
2616
- enforceStatusCommitPolicy({
2617
- policy: loaded.config.status_commit_policy,
2618
- action: "block",
2619
- confirmed: opts.confirmStatusCommit,
2620
- quiet: opts.quiet,
2621
- });
2622
- }
2623
- const { prefix, min_chars: minChars } = loaded.config.tasks.comments.blocked;
2624
- requireStructuredComment(opts.body, prefix, minChars);
2625
- const { backend, task } = await loadBackendTask({
2626
- cwd: opts.cwd,
2627
- rootOverride: opts.rootOverride,
2628
- taskId: opts.taskId,
2629
- });
2630
- const currentStatus = String(task.status || "TODO").toUpperCase();
2631
- if (!opts.force && !isTransitionAllowed(currentStatus, "BLOCKED")) {
2632
- throw new CliError({
2633
- exitCode: 2,
2634
- code: "E_USAGE",
2635
- message: `Refusing status transition ${currentStatus} -> BLOCKED (use --force to override)`,
2636
- });
2637
- }
2638
- const formattedComment = opts.commitFromComment
2639
- ? formatCommentBodyForCommit(opts.body, loaded.config)
2640
- : null;
2641
- const commentBody = formattedComment ?? opts.body;
2642
- const existingComments = Array.isArray(task.comments)
2643
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
2644
- : [];
2645
- const commentsValue = [...existingComments, { author: opts.author, body: commentBody }];
2646
- const nextTask = {
2647
- ...task,
2648
- status: "BLOCKED",
2649
- comments: commentsValue,
2650
- doc_version: 2,
2651
- doc_updated_at: nowIso(),
2652
- doc_updated_by: opts.author,
2653
- };
2654
- await backend.writeTask(nextTask);
2655
- let commitInfo = null;
2656
- if (opts.commitFromComment) {
2657
- commitInfo = await commitFromComment({
2658
- cwd: opts.cwd,
2659
- rootOverride: opts.rootOverride,
2660
- taskId: opts.taskId,
2661
- commentBody: opts.body,
2662
- formattedComment,
2663
- emoji: opts.commitEmoji ?? defaultCommitEmojiForStatus("BLOCKED"),
2664
- allow: opts.commitAllow,
2665
- autoAllow: opts.commitAutoAllow || opts.commitAllow.length === 0,
2666
- allowTasks: opts.commitAllowTasks,
2667
- requireClean: opts.commitRequireClean,
2668
- quiet: opts.quiet,
2669
- config: loaded.config,
2670
- });
2671
- }
2672
- if (!opts.quiet) {
2673
- const suffix = commitInfo ? ` (commit=${commitInfo.hash.slice(0, 12)})` : "";
2674
- process.stdout.write(`${successMessage("blocked", `${opts.taskId}${suffix}`)}\n`);
2675
- }
2676
- return 0;
2677
- }
2678
- catch (err) {
2679
- if (err instanceof CliError)
2680
- throw err;
2681
- throw mapBackendError(err, { command: "block", root: opts.rootOverride ?? null });
2682
- }
2683
- }
2684
- export async function cmdFinish(opts) {
2685
- try {
2686
- if (opts.noRequireTaskIdInCommit) {
2687
- // Parity flag (commit subject checks are not enforced in node CLI).
2688
- }
2689
- const resolved = await resolveProject({
2690
- cwd: opts.cwd,
2691
- rootOverride: opts.rootOverride ?? null,
2692
- });
2693
- const loaded = await loadConfig(resolved.agentplaneDir);
2694
- const { prefix, min_chars: minChars } = loaded.config.tasks.comments.verified;
2695
- requireStructuredComment(opts.body, prefix, minChars);
2696
- if (opts.commitFromComment || opts.statusCommit) {
2697
- enforceStatusCommitPolicy({
2698
- policy: loaded.config.status_commit_policy,
2699
- action: "finish",
2700
- confirmed: opts.confirmStatusCommit,
2701
- quiet: opts.quiet,
2702
- });
2703
- }
2704
- if ((opts.commitFromComment || opts.statusCommit) && opts.taskIds.length !== 1) {
2705
- throw new CliError({
2706
- exitCode: 2,
2707
- code: "E_USAGE",
2708
- message: "--commit-from-comment/--status-commit requires exactly one task id",
2709
- });
2710
- }
2711
- const primaryTaskId = opts.taskIds[0] ?? "";
2712
- if ((opts.commitFromComment || opts.statusCommit) && !primaryTaskId) {
2713
- throw new CliError({
2714
- exitCode: 2,
2715
- code: "E_USAGE",
2716
- message: "--commit-from-comment/--status-commit requires exactly one task id",
2717
- });
2718
- }
2719
- const { backend, config } = await loadTaskBackend({
2720
- cwd: opts.cwd,
2721
- rootOverride: opts.rootOverride ?? null,
2722
- });
2723
- const commitInfo = opts.commit
2724
- ? await readCommitInfo(resolved.gitRoot, opts.commit)
2725
- : await readHeadCommit(resolved.gitRoot);
2726
- for (const taskId of opts.taskIds) {
2727
- const { task } = await loadBackendTask({
2728
- cwd: opts.cwd,
2729
- rootOverride: opts.rootOverride,
2730
- taskId,
2731
- });
2732
- if (!opts.force) {
2733
- const currentStatus = String(task.status || "TODO").toUpperCase();
2734
- if (currentStatus === "DONE") {
2735
- throw new CliError({
2736
- exitCode: 2,
2737
- code: "E_USAGE",
2738
- message: `Task is already DONE: ${task.id} (use --force to override)`,
2739
- });
2740
- }
2741
- }
2742
- const existingComments = Array.isArray(task.comments)
2743
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
2744
- : [];
2745
- const commentsValue = [...existingComments, { author: opts.author, body: opts.body }];
2746
- const nextTask = {
2747
- ...task,
2748
- status: "DONE",
2749
- commit: { hash: commitInfo.hash, message: commitInfo.message },
2750
- comments: commentsValue,
2751
- doc_version: 2,
2752
- doc_updated_at: nowIso(),
2753
- doc_updated_by: opts.author,
2754
- };
2755
- await backend.writeTask(nextTask);
2756
- }
2757
- if (!opts.skipVerify) {
2758
- // No-op for parity; verify is handled by `agentplane verify`.
2759
- }
2760
- if (!backend.exportTasksJson) {
2761
- throw new CliError({
2762
- exitCode: 3,
2763
- code: "E_VALIDATION",
2764
- message: backendNotSupportedMessage("exportTasksJson()"),
2765
- });
2766
- }
2767
- const outPath = path.join(resolved.gitRoot, config.paths.tasks_path);
2768
- await backend.exportTasksJson(outPath);
2769
- const lintResult = await lintTasksFile({
2770
- cwd: opts.cwd,
2771
- rootOverride: opts.rootOverride ?? null,
2772
- });
2773
- if (lintResult.errors.length > 0) {
2774
- throw new CliError({
2775
- exitCode: 3,
2776
- code: "E_VALIDATION",
2777
- message: lintResult.errors.join("\n"),
2778
- });
2779
- }
2780
- if (opts.commitFromComment) {
2781
- await commitFromComment({
2782
- cwd: opts.cwd,
2783
- rootOverride: opts.rootOverride,
2784
- taskId: primaryTaskId,
2785
- commentBody: opts.body,
2786
- formattedComment: formatCommentBodyForCommit(opts.body, loaded.config),
2787
- emoji: opts.commitEmoji ?? defaultCommitEmojiForStatus("DONE"),
2788
- allow: opts.commitAllow,
2789
- autoAllow: opts.commitAutoAllow || opts.commitAllow.length === 0,
2790
- allowTasks: opts.commitAllowTasks,
2791
- requireClean: opts.commitRequireClean,
2792
- quiet: opts.quiet,
2793
- config: loaded.config,
2794
- });
2795
- }
2796
- if (opts.statusCommit) {
2797
- await commitFromComment({
2798
- cwd: opts.cwd,
2799
- rootOverride: opts.rootOverride,
2800
- taskId: primaryTaskId,
2801
- commentBody: opts.body,
2802
- formattedComment: formatCommentBodyForCommit(opts.body, loaded.config),
2803
- emoji: opts.statusCommitEmoji ?? defaultCommitEmojiForStatus("DONE"),
2804
- allow: opts.statusCommitAllow,
2805
- autoAllow: opts.statusCommitAutoAllow || opts.statusCommitAllow.length === 0,
2806
- allowTasks: true,
2807
- requireClean: opts.statusCommitRequireClean,
2808
- quiet: opts.quiet,
2809
- config: loaded.config,
2810
- });
2811
- }
2812
- if (!opts.quiet) {
2813
- process.stdout.write(`${successMessage("finished")}\n`);
2814
- }
2815
- return 0;
2816
- }
2817
- catch (err) {
2818
- if (err instanceof CliError)
2819
- throw err;
2820
- throw mapBackendError(err, { command: "finish", root: opts.rootOverride ?? null });
2821
- }
2822
- }
2823
- export async function cmdVerify(opts) {
2824
- try {
2825
- const { task, backend, config, resolved } = await loadBackendTask({
2826
- cwd: opts.cwd,
2827
- rootOverride: opts.rootOverride,
2828
- taskId: opts.taskId,
2829
- });
2830
- const docText = typeof task.doc === "string" ? task.doc : "";
2831
- const { commands: docCommands, steps: docSteps } = parseVerifyStepsFromDoc(docText);
2832
- const rawVerify = task.verify;
2833
- if (rawVerify !== undefined && rawVerify !== null && !Array.isArray(rawVerify)) {
2834
- throw new CliError({
2835
- exitCode: 2,
2836
- code: "E_USAGE",
2837
- message: `${task.id}: verify must be a list of strings`,
2838
- });
2839
- }
2840
- const taskCommands = Array.isArray(rawVerify)
2841
- ? rawVerify
2842
- .filter((item) => typeof item === "string")
2843
- .map((item) => item.trim())
2844
- .filter(Boolean)
2845
- : [];
2846
- const commands = docCommands.length > 0 ? docCommands : taskCommands;
2847
- let baseDoc = typeof task.doc === "string" ? task.doc : "";
2848
- if (docSteps.length > 0 && !opts.quiet) {
2849
- process.stdout.write(`${infoMessage(`${task.id}: manual verify steps:`)}\n`);
2850
- for (const step of docSteps) {
2851
- process.stdout.write(`- ${step}\n`);
2852
- }
2853
- }
2854
- if (commands.length === 0) {
2855
- if (opts.require) {
2856
- throw new CliError({
2857
- exitCode: 2,
2858
- code: "E_USAGE",
2859
- message: `${task.id}: no verify commands configured`,
2860
- });
2861
- }
2862
- if (!opts.quiet) {
2863
- process.stdout.write(`${infoMessage(`${task.id}: no verify commands configured`)}\n`);
2864
- }
2865
- return 0;
2866
- }
2867
- const requireVerifyApproval = config.agents?.approvals?.require_verify === true;
2868
- if (requireVerifyApproval && !opts.yes) {
2869
- if (!process.stdin.isTTY || opts.quiet) {
2870
- throw new CliError({
2871
- exitCode: 2,
2872
- code: "E_USAGE",
2873
- message: "Verification requires explicit approval (use --yes in non-interactive mode or set agents.approvals.require_verify=false).",
2874
- });
2875
- }
2876
- const approved = await promptYesNo("Require explicit approval for verification. Proceed?", false);
2877
- if (!approved) {
2878
- throw new CliError({
2879
- exitCode: 2,
2880
- code: "E_USAGE",
2881
- message: "Verification cancelled by user.",
2882
- });
2883
- }
2884
- }
2885
- if (!backend.getTaskDoc || !backend.setTaskDoc) {
2886
- throw new CliError({
2887
- exitCode: 2,
2888
- code: "E_USAGE",
2889
- message: backendNotSupportedMessage("task docs"),
2890
- });
2891
- }
2892
- if (!baseDoc) {
2893
- const fetched = await backend.getTaskDoc(task.id);
2894
- if (typeof fetched === "string")
2895
- baseDoc = fetched;
2896
- }
2897
- const execCwd = opts.execCwd ? path.resolve(opts.cwd, opts.execCwd) : resolved.gitRoot;
2898
- if (!isPathWithin(resolved.gitRoot, execCwd)) {
2899
- throw new CliError({
2900
- exitCode: 2,
2901
- code: "E_USAGE",
2902
- message: `--cwd must stay under repo root: ${execCwd}`,
2903
- });
2904
- }
2905
- const taskDir = path.join(resolved.gitRoot, config.paths.workflow_dir, opts.taskId);
2906
- const prDir = path.join(taskDir, "pr");
2907
- const metaPath = path.join(prDir, "meta.json");
2908
- let logPath = null;
2909
- if (opts.logPath) {
2910
- logPath = path.resolve(opts.cwd, opts.logPath);
2911
- if (!isPathWithin(resolved.gitRoot, logPath)) {
2912
- throw new CliError({
2913
- exitCode: 2,
2914
- code: "E_USAGE",
2915
- message: `--log must stay under repo root: ${logPath}`,
2916
- });
2917
- }
2918
- }
2919
- else if (await fileExists(prDir)) {
2920
- logPath = path.join(prDir, "verify.log");
2921
- }
2922
- let meta = null;
2923
- if (await fileExists(metaPath)) {
2924
- const rawMeta = await readFile(metaPath, "utf8");
2925
- meta = parsePrMeta(rawMeta, opts.taskId);
2926
- }
2927
- const headSha = await gitRevParse(execCwd, ["HEAD"]);
2928
- const currentSha = headSha;
2929
- if (opts.skipIfUnchanged) {
2930
- const changed = await gitStatusChangedPaths({
2931
- cwd: execCwd,
2932
- rootOverride: opts.rootOverride,
2933
- });
2934
- if (changed.length > 0) {
2935
- if (!opts.quiet) {
2936
- process.stdout.write(`${warnMessage(`${task.id}: working tree is dirty; ignoring --skip-if-unchanged`)}\n`);
2937
- }
2938
- }
2939
- else {
2940
- let lastVerifiedSha = meta?.last_verified_sha ?? null;
2941
- if (!lastVerifiedSha && logPath && (await fileExists(logPath))) {
2942
- const logText = await readFile(logPath, "utf8");
2943
- lastVerifiedSha = extractLastVerifiedSha(logText);
2944
- }
2945
- if (lastVerifiedSha && lastVerifiedSha === currentSha) {
2946
- const header = `[${nowIso()}] ℹ️ skipped (unchanged verified_sha=${currentSha})`;
2947
- if (logPath) {
2948
- await appendVerifyLog(logPath, header, "");
2949
- }
2950
- if (!opts.quiet) {
2951
- process.stdout.write(`${infoMessage(`${task.id}: verify skipped (unchanged sha ${currentSha.slice(0, 12)})`)}\n`);
2952
- }
2953
- if (meta) {
2954
- const nextMeta = {
2955
- ...meta,
2956
- last_verified_sha: currentSha,
2957
- last_verified_at: nowIso(),
2958
- verify: meta.verify ? { ...meta.verify, status: "pass" } : { status: "pass" },
2959
- };
2960
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
2961
- }
2962
- return 0;
2963
- }
2964
- }
2965
- }
2966
- let verifyError = null;
2967
- let failedCommand = null;
2968
- for (const command of commands) {
2969
- try {
2970
- if (!opts.quiet) {
2971
- process.stdout.write(`$ ${command}\n`);
2972
- }
2973
- const timestamp = nowIso();
2974
- const result = await runShellCommand(command, execCwd);
2975
- const shaPrefix = currentSha ? `sha=${currentSha} ` : "";
2976
- const header = `[${timestamp}] ${shaPrefix}$ ${command}`.trimEnd();
2977
- if (logPath) {
2978
- await appendVerifyLog(logPath, header, result.output);
2979
- }
2980
- if (result.code !== 0) {
2981
- throw new CliError({
2982
- exitCode: result.code || 1,
2983
- code: "E_IO",
2984
- message: `Verify command failed: ${command}`,
2985
- });
2986
- }
2987
- }
2988
- catch (err) {
2989
- verifyError = err instanceof Error ? err : new Error(String(err));
2990
- failedCommand = command;
2991
- break;
2992
- }
2993
- }
2994
- if (verifyError) {
2995
- const details = verifyError.message;
2996
- const failureAt = nowIso();
2997
- const content = renderVerificationSection({
2998
- status: "fail",
2999
- verifiedAt: failureAt,
3000
- verifiedSha: null,
3001
- commands,
3002
- steps: docSteps,
3003
- details: failedCommand ? `${details} (command: ${failedCommand})` : details,
3004
- });
3005
- await writeVerificationSection({
3006
- backend,
3007
- taskId: task.id,
3008
- config,
3009
- baseDoc,
3010
- content,
3011
- updatedBy: "VERIFY",
3012
- });
3013
- if (meta) {
3014
- const nextMeta = {
3015
- ...meta,
3016
- last_verified_at: failureAt,
3017
- verify: meta.verify
3018
- ? { ...meta.verify, status: "fail", command: commands.join(" && ") }
3019
- : { status: "fail", command: commands.join(" && ") },
3020
- };
3021
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
3022
- }
3023
- const existingComments = Array.isArray(task.comments)
3024
- ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
3025
- : [];
3026
- const failureBody = failedCommand
3027
- ? `Verify failed: ${details} (command: ${failedCommand})`
3028
- : `Verify failed: ${details}`;
3029
- const nextTask = {
3030
- ...task,
3031
- status: "DOING",
3032
- comments: [...existingComments, { author: "VERIFY", body: failureBody }],
3033
- doc_version: 2,
3034
- doc_updated_at: failureAt,
3035
- doc_updated_by: "VERIFY",
3036
- };
3037
- await backend.writeTask(nextTask);
3038
- throw verifyError;
3039
- }
3040
- if (currentSha) {
3041
- const header = `[${nowIso()}] ✅ verified_sha=${currentSha}`;
3042
- if (logPath) {
3043
- await appendVerifyLog(logPath, header, "");
3044
- }
3045
- }
3046
- if (!opts.quiet) {
3047
- process.stdout.write(`${successMessage("verify passed", task.id)}\n`);
3048
- }
3049
- const successAt = nowIso();
3050
- const successContent = renderVerificationSection({
3051
- status: "pass",
3052
- verifiedAt: successAt,
3053
- verifiedSha: currentSha,
3054
- commands,
3055
- steps: docSteps,
3056
- details: null,
3057
- });
3058
- await writeVerificationSection({
3059
- backend,
3060
- taskId: task.id,
3061
- config,
3062
- baseDoc,
3063
- content: successContent,
3064
- updatedBy: "VERIFY",
3065
- });
3066
- if (meta) {
3067
- const nextMeta = {
3068
- ...meta,
3069
- last_verified_sha: currentSha,
3070
- last_verified_at: successAt,
3071
- verify: meta.verify
3072
- ? { ...meta.verify, status: "pass", command: commands.join(" && ") }
3073
- : { status: "pass", command: commands.join(" && ") },
3074
- };
3075
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
3076
- }
3077
- return 0;
3078
- }
3079
- catch (err) {
3080
- if (err instanceof CliError)
3081
- throw err;
3082
- throw mapBackendError(err, { command: "verify", root: opts.rootOverride ?? null });
3083
- }
3084
- }
3085
- export async function cmdWorkStart(opts) {
3086
- try {
3087
- validateWorkAgent(opts.agent);
3088
- validateWorkSlug(opts.slug);
3089
- const resolved = await resolveProject({
3090
- cwd: opts.cwd,
3091
- rootOverride: opts.rootOverride ?? null,
3092
- });
3093
- const loaded = await loadConfig(resolved.agentplaneDir);
3094
- if (loaded.config.workflow_mode !== "branch_pr") {
3095
- throw new CliError({
3096
- exitCode: 2,
3097
- code: "E_USAGE",
3098
- message: workflowModeMessage(loaded.config.workflow_mode, "branch_pr"),
3099
- });
3100
- }
3101
- if (!opts.worktree) {
3102
- throw new CliError({
3103
- exitCode: 2,
3104
- code: "E_USAGE",
3105
- message: usageMessage(WORK_START_USAGE, WORK_START_USAGE_EXAMPLE),
3106
- });
3107
- }
3108
- await loadBackendTask({
3109
- cwd: opts.cwd,
3110
- rootOverride: opts.rootOverride,
3111
- taskId: opts.taskId,
3112
- });
3113
- const baseBranch = await getBaseBranch({
3114
- cwd: opts.cwd,
3115
- rootOverride: opts.rootOverride ?? null,
3116
- });
3117
- const currentBranch = await gitCurrentBranch(resolved.gitRoot);
3118
- if (currentBranch !== baseBranch) {
3119
- throw new CliError({
3120
- exitCode: 5,
3121
- code: "E_GIT",
3122
- message: `work start must be run on base branch ${baseBranch} (current: ${currentBranch})`,
3123
- });
3124
- }
3125
- const prefix = loaded.config.branch.task_prefix;
3126
- const branchName = `${prefix}/${opts.taskId}/${opts.slug.trim()}`;
3127
- const worktreesDir = path.resolve(resolved.gitRoot, loaded.config.paths.worktrees_dir);
3128
- if (!isPathWithin(resolved.gitRoot, worktreesDir)) {
3129
- throw new CliError({
3130
- exitCode: 5,
3131
- code: "E_GIT",
3132
- message: `worktrees_dir must be inside the repo: ${worktreesDir}`,
3133
- });
3134
- }
3135
- const worktreePath = path.join(worktreesDir, `${opts.taskId}-${opts.slug.trim()}`);
3136
- if (await fileExists(worktreePath)) {
3137
- throw new CliError({
3138
- exitCode: 5,
3139
- code: "E_GIT",
3140
- message: `Worktree path already exists: ${worktreePath}`,
3141
- });
3142
- }
3143
- await mkdir(worktreesDir, { recursive: true });
3144
- const branchExists = await gitBranchExists(resolved.gitRoot, branchName);
3145
- const worktreeArgs = branchExists
3146
- ? ["worktree", "add", worktreePath, branchName]
3147
- : ["worktree", "add", "-b", branchName, worktreePath, baseBranch];
3148
- await execFileAsync("git", worktreeArgs, { cwd: resolved.gitRoot, env: gitEnv() });
3149
- process.stdout.write(`${successMessage("work start", branchName, `worktree=${path.relative(resolved.gitRoot, worktreePath)}`)}\n`);
3150
- return 0;
3151
- }
3152
- catch (err) {
3153
- if (err instanceof CliError)
3154
- throw err;
3155
- throw mapBackendError(err, { command: "work start", root: opts.rootOverride ?? null });
3156
- }
3157
- }
3158
- async function resolvePrPaths(opts) {
3159
- const resolved = await resolveProject({
3160
- cwd: opts.cwd,
3161
- rootOverride: opts.rootOverride ?? null,
3162
- });
3163
- const loaded = await loadConfig(resolved.agentplaneDir);
3164
- const taskDir = path.join(resolved.gitRoot, loaded.config.paths.workflow_dir, opts.taskId);
3165
- const prDir = path.join(taskDir, "pr");
3166
- return {
3167
- resolved,
3168
- config: loaded.config,
3169
- prDir,
3170
- metaPath: path.join(prDir, "meta.json"),
3171
- diffstatPath: path.join(prDir, "diffstat.txt"),
3172
- verifyLogPath: path.join(prDir, "verify.log"),
3173
- reviewPath: path.join(prDir, "review.md"),
3174
- };
3175
- }
3176
- export async function cmdPrOpen(opts) {
3177
- try {
3178
- const author = opts.author.trim();
3179
- if (!author)
3180
- throw new CliError({
3181
- exitCode: 2,
3182
- code: "E_USAGE",
3183
- message: usageMessage(PR_OPEN_USAGE, PR_OPEN_USAGE_EXAMPLE),
3184
- });
3185
- const { task } = await loadBackendTask({
3186
- cwd: opts.cwd,
3187
- rootOverride: opts.rootOverride,
3188
- taskId: opts.taskId,
3189
- });
3190
- const { resolved, config, prDir, metaPath, diffstatPath, verifyLogPath, reviewPath } = await resolvePrPaths(opts);
3191
- if (config.workflow_mode !== "branch_pr") {
3192
- throw new CliError({
3193
- exitCode: 2,
3194
- code: "E_USAGE",
3195
- message: workflowModeMessage(config.workflow_mode, "branch_pr"),
3196
- });
3197
- }
3198
- const branch = (opts.branch ?? (await gitCurrentBranch(resolved.gitRoot))).trim();
3199
- if (!branch)
3200
- throw new CliError({
3201
- exitCode: 2,
3202
- code: "E_USAGE",
3203
- message: usageMessage(PR_OPEN_USAGE, PR_OPEN_USAGE_EXAMPLE),
3204
- });
3205
- await mkdir(prDir, { recursive: true });
3206
- const now = nowIso();
3207
- let meta = null;
3208
- if (await fileExists(metaPath)) {
3209
- const raw = await readFile(metaPath, "utf8");
3210
- meta = parsePrMeta(raw, task.id);
3211
- }
3212
- const createdAt = meta?.created_at ?? now;
3213
- const nextMeta = {
3214
- schema_version: 1,
3215
- task_id: task.id,
3216
- branch,
3217
- created_at: createdAt,
3218
- updated_at: now,
3219
- last_verified_sha: meta?.last_verified_sha ?? null,
3220
- last_verified_at: meta?.last_verified_at ?? null,
3221
- verify: meta?.verify ?? { status: "skipped" },
3222
- };
3223
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
3224
- if (!(await fileExists(diffstatPath)))
3225
- await writeFile(diffstatPath, "", "utf8");
3226
- if (!(await fileExists(verifyLogPath)))
3227
- await writeFile(verifyLogPath, "", "utf8");
3228
- if (!(await fileExists(reviewPath))) {
3229
- const review = renderPrReviewTemplate({ author, createdAt, branch });
3230
- await writeFile(reviewPath, review, "utf8");
3231
- }
3232
- process.stdout.write(`${successMessage("pr open", path.relative(resolved.gitRoot, prDir))}\n`);
3233
- return 0;
3234
- }
3235
- catch (err) {
3236
- if (err instanceof CliError)
3237
- throw err;
3238
- throw mapBackendError(err, { command: "pr open", root: opts.rootOverride ?? null });
3239
- }
3240
- }
3241
- export async function cmdPrUpdate(opts) {
3242
- try {
3243
- await loadBackendTask({
3244
- cwd: opts.cwd,
3245
- rootOverride: opts.rootOverride,
3246
- taskId: opts.taskId,
3247
- });
3248
- const { resolved, config, prDir, metaPath, diffstatPath, reviewPath } = await resolvePrPaths(opts);
3249
- if (config.workflow_mode !== "branch_pr") {
3250
- throw new CliError({
3251
- exitCode: 2,
3252
- code: "E_USAGE",
3253
- message: workflowModeMessage(config.workflow_mode, "branch_pr"),
3254
- });
3255
- }
3256
- if (!(await fileExists(metaPath)) || !(await fileExists(reviewPath))) {
3257
- const missing = [];
3258
- if (!(await fileExists(metaPath)))
3259
- missing.push(path.relative(resolved.gitRoot, metaPath));
3260
- if (!(await fileExists(reviewPath)))
3261
- missing.push(path.relative(resolved.gitRoot, reviewPath));
3262
- throw new CliError({
3263
- exitCode: 3,
3264
- code: "E_VALIDATION",
3265
- message: `PR artifacts missing: ${missing.join(", ")} (run \`agentplane pr open\`)`,
3266
- });
3267
- }
3268
- const baseBranch = await getBaseBranch({
3269
- cwd: opts.cwd,
3270
- rootOverride: opts.rootOverride ?? null,
3271
- });
3272
- const branch = await gitCurrentBranch(resolved.gitRoot);
3273
- const { stdout: diffStatOut } = await execFileAsync("git", ["diff", "--stat", `${baseBranch}...HEAD`], { cwd: resolved.gitRoot, env: gitEnv() });
3274
- const diffstat = diffStatOut.trimEnd();
3275
- await writeFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
3276
- const { stdout: headOut } = await execFileAsync("git", ["rev-parse", "HEAD"], {
3277
- cwd: resolved.gitRoot,
3278
- env: gitEnv(),
3279
- });
3280
- const headSha = headOut.trim();
3281
- const summaryLines = [
3282
- `- Updated: ${nowIso()}`,
3283
- `- Branch: ${branch}`,
3284
- `- Head: ${headSha.slice(0, 12)}`,
3285
- "- Diffstat:",
3286
- "```",
3287
- diffstat || "No changes detected.",
3288
- "```",
3289
- ];
3290
- const reviewText = await readFile(reviewPath, "utf8");
3291
- const nextReview = updateAutoSummaryBlock(reviewText, summaryLines.join("\n"));
3292
- await writeFile(reviewPath, nextReview, "utf8");
3293
- const rawMeta = await readFile(metaPath, "utf8");
3294
- const meta = parsePrMeta(rawMeta, opts.taskId);
3295
- const nextMeta = {
3296
- ...meta,
3297
- branch,
3298
- updated_at: nowIso(),
3299
- last_verified_sha: meta.last_verified_sha ?? null,
3300
- last_verified_at: meta.last_verified_at ?? null,
3301
- };
3302
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
3303
- process.stdout.write(`${successMessage("pr update", path.relative(resolved.gitRoot, prDir))}\n`);
3304
- return 0;
3305
- }
3306
- catch (err) {
3307
- if (err instanceof CliError)
3308
- throw err;
3309
- throw mapBackendError(err, { command: "pr update", root: opts.rootOverride ?? null });
3310
- }
3311
- }
3312
- export async function cmdPrCheck(opts) {
3313
- try {
3314
- const { task } = await loadBackendTask({
3315
- cwd: opts.cwd,
3316
- rootOverride: opts.rootOverride,
3317
- taskId: opts.taskId,
3318
- });
3319
- const { resolved, config, prDir, metaPath, diffstatPath, verifyLogPath, reviewPath } = await resolvePrPaths(opts);
3320
- if (config.workflow_mode !== "branch_pr") {
3321
- throw new CliError({
3322
- exitCode: 2,
3323
- code: "E_USAGE",
3324
- message: workflowModeMessage(config.workflow_mode, "branch_pr"),
3325
- });
3326
- }
3327
- const errors = [];
3328
- const relPrDir = path.relative(resolved.gitRoot, prDir);
3329
- const relMetaPath = path.relative(resolved.gitRoot, metaPath);
3330
- const relDiffstatPath = path.relative(resolved.gitRoot, diffstatPath);
3331
- const relVerifyLogPath = path.relative(resolved.gitRoot, verifyLogPath);
3332
- const relReviewPath = path.relative(resolved.gitRoot, reviewPath);
3333
- if (!(await fileExists(prDir)))
3334
- errors.push(`Missing PR directory: ${relPrDir}`);
3335
- if (!(await fileExists(metaPath)))
3336
- errors.push(`Missing ${relMetaPath}`);
3337
- if (!(await fileExists(diffstatPath)))
3338
- errors.push(`Missing ${relDiffstatPath}`);
3339
- if (!(await fileExists(verifyLogPath)))
3340
- errors.push(`Missing ${relVerifyLogPath}`);
3341
- if (!(await fileExists(reviewPath)))
3342
- errors.push(`Missing ${relReviewPath}`);
3343
- let meta = null;
3344
- if (await fileExists(metaPath)) {
3345
- try {
3346
- meta = parsePrMeta(await readFile(metaPath, "utf8"), task.id);
3347
- }
3348
- catch (err) {
3349
- const message = err instanceof Error ? err.message : String(err);
3350
- errors.push(message);
3351
- }
3352
- }
3353
- if (await fileExists(reviewPath)) {
3354
- const review = await readFile(reviewPath, "utf8");
3355
- const requiredSections = ["## Summary", "## Checklist", "## Handoff Notes"];
3356
- for (const section of requiredSections) {
3357
- if (!review.includes(section))
3358
- errors.push(`Missing section: ${section}`);
3359
- }
3360
- if (!review.includes("<!-- BEGIN AUTO SUMMARY -->")) {
3361
- errors.push("Missing auto summary start marker");
3362
- }
3363
- if (!review.includes("<!-- END AUTO SUMMARY -->")) {
3364
- errors.push("Missing auto summary end marker");
3365
- }
3366
- }
3367
- if (task.verify && task.verify.length > 0) {
3368
- if (meta?.verify?.status !== "pass") {
3369
- errors.push("Verify requirements not satisfied (meta.verify.status != pass)");
3370
- }
3371
- if (!meta?.last_verified_sha || !meta.last_verified_at) {
3372
- errors.push("Verify metadata missing (last_verified_sha/last_verified_at)");
3373
- }
3374
- }
3375
- if (errors.length > 0) {
3376
- throw new CliError({ exitCode: 3, code: "E_VALIDATION", message: errors.join("\n") });
3377
- }
3378
- process.stdout.write(`${successMessage("pr check", path.relative(resolved.gitRoot, prDir))}\n`);
3379
- return 0;
3380
- }
3381
- catch (err) {
3382
- if (err instanceof CliError)
3383
- throw err;
3384
- throw mapBackendError(err, { command: "pr check", root: opts.rootOverride ?? null });
3385
- }
3386
- }
3387
- export async function cmdPrNote(opts) {
3388
- try {
3389
- const author = opts.author.trim();
3390
- const body = opts.body.trim();
3391
- if (!author || !body) {
3392
- throw new CliError({
3393
- exitCode: 2,
3394
- code: "E_USAGE",
3395
- message: usageMessage(PR_NOTE_USAGE, PR_NOTE_USAGE_EXAMPLE),
3396
- });
3397
- }
3398
- const { config, reviewPath, resolved } = await resolvePrPaths(opts);
3399
- if (config.workflow_mode !== "branch_pr") {
3400
- throw new CliError({
3401
- exitCode: 2,
3402
- code: "E_USAGE",
3403
- message: workflowModeMessage(config.workflow_mode, "branch_pr"),
3404
- });
3405
- }
3406
- if (!(await fileExists(reviewPath))) {
3407
- const relReviewPath = path.relative(resolved.gitRoot, reviewPath);
3408
- throw new CliError({
3409
- exitCode: 3,
3410
- code: "E_VALIDATION",
3411
- message: `Missing ${relReviewPath} (run \`agentplane pr open\`)`,
3412
- });
3413
- }
3414
- const review = await readFile(reviewPath, "utf8");
3415
- const updated = appendHandoffNote(review, `${author}: ${body}`);
3416
- await writeFile(reviewPath, updated, "utf8");
3417
- process.stdout.write(`${successMessage("pr note", opts.taskId)}\n`);
3418
- return 0;
3419
- }
3420
- catch (err) {
3421
- if (err instanceof CliError)
3422
- throw err;
3423
- throw mapCoreError(err, { command: "pr note", root: opts.rootOverride ?? null });
3424
- }
3425
- }
3426
- async function readPrArtifact(opts) {
3427
- const filePath = path.join(opts.prDir, opts.fileName);
3428
- if (await fileExists(filePath)) {
3429
- return await readFile(filePath, "utf8");
3430
- }
3431
- const rel = toGitPath(path.relative(opts.resolved.gitRoot, filePath));
3432
- try {
3433
- return await gitShowFile(opts.resolved.gitRoot, opts.branch, rel);
3434
- }
3435
- catch {
3436
- return null;
3437
- }
3438
- }
3439
- function validateReviewContents(review, errors) {
3440
- const requiredSections = ["## Summary", "## Checklist", "## Handoff Notes"];
3441
- for (const section of requiredSections) {
3442
- if (!review.includes(section))
3443
- errors.push(`Missing section: ${section}`);
3444
- }
3445
- if (!review.includes("<!-- BEGIN AUTO SUMMARY -->")) {
3446
- errors.push("Missing auto summary start marker");
3447
- }
3448
- if (!review.includes("<!-- END AUTO SUMMARY -->")) {
3449
- errors.push("Missing auto summary end marker");
3450
- }
3451
- }
3452
- export async function cmdIntegrate(opts) {
3453
- let tempWorktreePath = null;
3454
- let createdTempWorktree = false;
3455
- try {
3456
- const { task } = await loadBackendTask({
3457
- cwd: opts.cwd,
3458
- rootOverride: opts.rootOverride,
3459
- taskId: opts.taskId,
3460
- });
3461
- const resolved = await resolveProject({
3462
- cwd: opts.cwd,
3463
- rootOverride: opts.rootOverride ?? null,
3464
- });
3465
- const loaded = await loadConfig(resolved.agentplaneDir);
3466
- if (loaded.config.workflow_mode !== "branch_pr") {
3467
- throw new CliError({
3468
- exitCode: 2,
3469
- code: "E_USAGE",
3470
- message: workflowModeMessage(loaded.config.workflow_mode, "branch_pr"),
3471
- });
3472
- }
3473
- await ensureGitClean({ cwd: opts.cwd, rootOverride: opts.rootOverride });
3474
- const baseBranch = (opts.base ?? (await getBaseBranch({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null }))).trim();
3475
- const currentBranch = await gitCurrentBranch(resolved.gitRoot);
3476
- if (currentBranch !== baseBranch) {
3477
- throw new CliError({
3478
- exitCode: 5,
3479
- code: "E_GIT",
3480
- message: `integrate must run on base branch ${baseBranch} (current: ${currentBranch})`,
3481
- });
3482
- }
3483
- const { prDir, metaPath, diffstatPath, verifyLogPath } = await resolvePrPaths({
3484
- cwd: opts.cwd,
3485
- rootOverride: opts.rootOverride,
3486
- taskId: opts.taskId,
3487
- });
3488
- let meta = null;
3489
- let branch = (opts.branch ?? "").trim();
3490
- if (await fileExists(metaPath)) {
3491
- meta = parsePrMeta(await readFile(metaPath, "utf8"), task.id);
3492
- if (!branch)
3493
- branch = (meta.branch ?? "").trim();
3494
- }
3495
- if (!branch) {
3496
- throw new CliError({
3497
- exitCode: 2,
3498
- code: "E_USAGE",
3499
- message: usageMessage(INTEGRATE_USAGE, INTEGRATE_USAGE_EXAMPLE),
3500
- });
3501
- }
3502
- if (!(await gitBranchExists(resolved.gitRoot, branch))) {
3503
- throw new CliError({
3504
- exitCode: 2,
3505
- code: "E_USAGE",
3506
- message: unknownEntityMessage("branch", branch),
3507
- });
3508
- }
3509
- const metaSource = meta ??
3510
- parsePrMeta(await gitShowFile(resolved.gitRoot, branch, toGitPath(path.relative(resolved.gitRoot, metaPath))), task.id);
3511
- const baseCandidate = opts.base ?? metaSource.base_branch ?? baseBranch;
3512
- const base = typeof baseCandidate === "string" && baseCandidate.trim().length > 0
3513
- ? baseCandidate.trim()
3514
- : baseBranch;
3515
- const errors = [];
3516
- const relDiffstat = path.relative(resolved.gitRoot, path.join(prDir, "diffstat.txt"));
3517
- const relVerifyLog = path.relative(resolved.gitRoot, path.join(prDir, "verify.log"));
3518
- const relReview = path.relative(resolved.gitRoot, path.join(prDir, "review.md"));
3519
- const diffstatText = await readPrArtifact({
3520
- resolved,
3521
- prDir,
3522
- fileName: "diffstat.txt",
3523
- branch,
3524
- });
3525
- if (diffstatText === null)
3526
- errors.push(`Missing ${relDiffstat}`);
3527
- const verifyLogText = await readPrArtifact({
3528
- resolved,
3529
- prDir,
3530
- fileName: "verify.log",
3531
- branch,
3532
- });
3533
- if (verifyLogText === null)
3534
- errors.push(`Missing ${relVerifyLog}`);
3535
- const reviewText = await readPrArtifact({
3536
- resolved,
3537
- prDir,
3538
- fileName: "review.md",
3539
- branch,
3540
- });
3541
- if (reviewText === null)
3542
- errors.push(`Missing ${relReview}`);
3543
- if (reviewText)
3544
- validateReviewContents(reviewText, errors);
3545
- if (errors.length > 0) {
3546
- throw new CliError({ exitCode: 3, code: "E_VALIDATION", message: errors.join("\n") });
3547
- }
3548
- const changedPaths = await gitDiffNames(resolved.gitRoot, base, branch);
3549
- const tasksPath = loaded.config.paths.tasks_path;
3550
- if (changedPaths.includes(tasksPath)) {
3551
- throw new CliError({
3552
- exitCode: 5,
3553
- code: "E_GIT",
3554
- message: `Branch ${branch} modifies ${tasksPath} (single-writer violation)`,
3555
- });
3556
- }
3557
- const rawVerify = task.verify;
3558
- const verifyCommands = Array.isArray(rawVerify)
3559
- ? rawVerify
3560
- .filter((item) => typeof item === "string")
3561
- .map((item) => item.trim())
3562
- .filter(Boolean)
3563
- : [];
3564
- let branchHeadSha = await gitRevParse(resolved.gitRoot, [branch]);
3565
- let alreadyVerifiedSha = null;
3566
- if (verifyCommands.length > 0) {
3567
- const metaVerified = metaSource?.last_verified_sha ?? null;
3568
- if (metaVerified && metaVerified === branchHeadSha) {
3569
- alreadyVerifiedSha = branchHeadSha;
3570
- }
3571
- else if (verifyLogText) {
3572
- const logSha = extractLastVerifiedSha(verifyLogText);
3573
- if (logSha && logSha === branchHeadSha)
3574
- alreadyVerifiedSha = logSha;
3575
- }
3576
- }
3577
- let shouldRunVerify = opts.runVerify || (verifyCommands.length > 0 && alreadyVerifiedSha === null);
3578
- if (opts.dryRun) {
3579
- if (!opts.quiet) {
3580
- process.stdout.write(`${successMessage("integrate dry-run", task.id, `base=${base} branch=${branch} verify=${shouldRunVerify ? "yes" : "no"}`)}\n`);
3581
- }
3582
- return 0;
3583
- }
3584
- let worktreePath = await findWorktreeForBranch(resolved.gitRoot, branch);
3585
- if (opts.mergeStrategy === "rebase" && !worktreePath) {
3586
- throw new CliError({
3587
- exitCode: 2,
3588
- code: "E_USAGE",
3589
- message: "rebase strategy requires an existing worktree for the task branch",
3590
- });
3591
- }
3592
- if (shouldRunVerify && !worktreePath) {
3593
- const worktreesDir = path.resolve(resolved.gitRoot, loaded.config.paths.worktrees_dir);
3594
- if (!isPathWithin(resolved.gitRoot, worktreesDir)) {
3595
- throw new CliError({
3596
- exitCode: 5,
3597
- code: "E_GIT",
3598
- message: `worktrees_dir must be inside the repo: ${worktreesDir}`,
3599
- });
3600
- }
3601
- tempWorktreePath = path.join(worktreesDir, `_integrate_tmp_${task.id}`);
3602
- const tempExists = await fileExists(tempWorktreePath);
3603
- if (tempExists) {
3604
- const registered = await findWorktreeForBranch(resolved.gitRoot, branch);
3605
- if (!registered) {
3606
- throw new CliError({
3607
- exitCode: 5,
3608
- code: "E_GIT",
3609
- message: `Temp worktree path exists but is not registered: ${tempWorktreePath}`,
3610
- });
3611
- }
3612
- }
3613
- else {
3614
- await mkdir(worktreesDir, { recursive: true });
3615
- await execFileAsync("git", ["worktree", "add", tempWorktreePath, branch], {
3616
- cwd: resolved.gitRoot,
3617
- env: gitEnv(),
3618
- });
3619
- createdTempWorktree = true;
3620
- }
3621
- worktreePath = tempWorktreePath;
3622
- }
3623
- const verifyEntries = [];
3624
- if (opts.mergeStrategy !== "rebase" && shouldRunVerify && verifyCommands.length > 0) {
3625
- if (!worktreePath) {
3626
- throw new CliError({
3627
- exitCode: 2,
3628
- code: "E_USAGE",
3629
- message: "Unable to locate or create a worktree for verify execution",
3630
- });
3631
- }
3632
- for (const command of verifyCommands) {
3633
- if (!opts.quiet)
3634
- process.stdout.write(`$ ${command}\n`);
3635
- const timestamp = nowIso();
3636
- const result = await runShellCommand(command, worktreePath);
3637
- const shaPrefix = branchHeadSha ? `sha=${branchHeadSha} ` : "";
3638
- verifyEntries.push({
3639
- header: `[${timestamp}] ${shaPrefix}$ ${command}`.trimEnd(),
3640
- content: result.output,
3641
- });
3642
- if (result.code !== 0) {
3643
- throw new CliError({
3644
- exitCode: result.code || 1,
3645
- code: "E_IO",
3646
- message: `Verify command failed: ${command}`,
3647
- });
3648
- }
3649
- }
3650
- if (branchHeadSha) {
3651
- verifyEntries.push({
3652
- header: `[${nowIso()}] ✅ verified_sha=${branchHeadSha}`,
3653
- content: "",
3654
- });
3655
- }
3656
- if (!opts.quiet) {
3657
- process.stdout.write(`${successMessage("verify passed", task.id)}\n`);
3658
- }
3659
- }
3660
- const baseShaBeforeMerge = await gitRevParse(resolved.gitRoot, [base]);
3661
- const headBeforeMerge = await gitRevParse(resolved.gitRoot, ["HEAD"]);
3662
- let mergeHash = "";
3663
- if (opts.mergeStrategy === "squash") {
3664
- try {
3665
- await execFileAsync("git", ["merge", "--squash", branch], {
3666
- cwd: resolved.gitRoot,
3667
- env: gitEnv(),
3668
- });
3669
- }
3670
- catch (err) {
3671
- await execFileAsync("git", ["reset", "--hard", headBeforeMerge], {
3672
- cwd: resolved.gitRoot,
3673
- env: gitEnv(),
3674
- });
3675
- const message = err instanceof Error ? err.message : "git merge --squash failed";
3676
- throw new CliError({ exitCode: 2, code: "E_GIT", message });
3677
- }
3678
- const { stdout: staged } = await execFileAsync("git", ["diff", "--cached", "--name-only"], {
3679
- cwd: resolved.gitRoot,
3680
- env: gitEnv(),
3681
- });
3682
- if (!staged.trim()) {
3683
- await execFileAsync("git", ["reset", "--hard", headBeforeMerge], {
3684
- cwd: resolved.gitRoot,
3685
- env: gitEnv(),
3686
- });
3687
- throw new CliError({
3688
- exitCode: 2,
3689
- code: "E_USAGE",
3690
- message: `Nothing to integrate: ${branch} is already merged into ${base}`,
3691
- });
3692
- }
3693
- const { stdout: subjectOut } = await execFileAsync("git", ["log", "-1", "--pretty=format:%s", branch], { cwd: resolved.gitRoot, env: gitEnv() });
3694
- let subject = subjectOut.trim();
3695
- if (!subject.includes(task.id)) {
3696
- subject = `🧩 ${task.id} integrate ${branch}`;
3697
- }
3698
- const env = {
3699
- ...process.env,
3700
- AGENTPLANE_TASK_ID: task.id,
3701
- AGENTPLANE_ALLOW_BASE: "1",
3702
- AGENTPLANE_ALLOW_TASKS: "0",
3703
- };
3704
- try {
3705
- await execFileAsync("git", ["commit", "-m", subject], {
3706
- cwd: resolved.gitRoot,
3707
- env,
3708
- });
3709
- }
3710
- catch (err) {
3711
- await execFileAsync("git", ["reset", "--hard", headBeforeMerge], {
3712
- cwd: resolved.gitRoot,
3713
- env: gitEnv(),
3714
- });
3715
- const message = err instanceof Error ? err.message : "git commit failed";
3716
- throw new CliError({ exitCode: 2, code: "E_GIT", message });
3717
- }
3718
- mergeHash = await gitRevParse(resolved.gitRoot, ["HEAD"]);
3719
- }
3720
- else if (opts.mergeStrategy === "merge") {
3721
- const env = {
3722
- ...process.env,
3723
- AGENTPLANE_TASK_ID: task.id,
3724
- AGENTPLANE_ALLOW_BASE: "1",
3725
- AGENTPLANE_ALLOW_TASKS: "0",
3726
- };
3727
- try {
3728
- await execFileAsync("git", ["merge", "--no-ff", branch, "-m", `🔀 ${task.id} merge ${branch}`], {
3729
- cwd: resolved.gitRoot,
3730
- env,
3731
- });
3732
- }
3733
- catch (err) {
3734
- await execFileAsync("git", ["merge", "--abort"], { cwd: resolved.gitRoot, env: gitEnv() });
3735
- const message = err instanceof Error ? err.message : "git merge failed";
3736
- throw new CliError({ exitCode: 2, code: "E_GIT", message });
3737
- }
3738
- mergeHash = await gitRevParse(resolved.gitRoot, ["HEAD"]);
3739
- }
3740
- else {
3741
- if (!worktreePath) {
3742
- throw new CliError({
3743
- exitCode: 2,
3744
- code: "E_USAGE",
3745
- message: "rebase strategy requires an existing worktree for the task branch",
3746
- });
3747
- }
3748
- try {
3749
- await execFileAsync("git", ["rebase", base], { cwd: worktreePath, env: gitEnv() });
3750
- }
3751
- catch (err) {
3752
- await execFileAsync("git", ["rebase", "--abort"], { cwd: worktreePath, env: gitEnv() });
3753
- const message = err instanceof Error ? err.message : "git rebase failed";
3754
- throw new CliError({ exitCode: 2, code: "E_GIT", message });
3755
- }
3756
- branchHeadSha = await gitRevParse(resolved.gitRoot, [branch]);
3757
- if (!opts.runVerify && verifyCommands.length > 0) {
3758
- alreadyVerifiedSha = null;
3759
- const metaVerified = metaSource?.last_verified_sha ?? null;
3760
- if (metaVerified && metaVerified === branchHeadSha) {
3761
- alreadyVerifiedSha = branchHeadSha;
3762
- }
3763
- else if (verifyLogText) {
3764
- const logSha = extractLastVerifiedSha(verifyLogText);
3765
- if (logSha && logSha === branchHeadSha)
3766
- alreadyVerifiedSha = logSha;
3767
- }
3768
- shouldRunVerify = alreadyVerifiedSha === null;
3769
- }
3770
- if (shouldRunVerify && verifyCommands.length > 0) {
3771
- if (!worktreePath) {
3772
- throw new CliError({
3773
- exitCode: 2,
3774
- code: "E_USAGE",
3775
- message: "Unable to locate or create a worktree for verify execution",
3776
- });
3777
- }
3778
- for (const command of verifyCommands) {
3779
- if (!opts.quiet)
3780
- process.stdout.write(`$ ${command}\n`);
3781
- const timestamp = nowIso();
3782
- const result = await runShellCommand(command, worktreePath);
3783
- const shaPrefix = branchHeadSha ? `sha=${branchHeadSha} ` : "";
3784
- verifyEntries.push({
3785
- header: `[${timestamp}] ${shaPrefix}$ ${command}`.trimEnd(),
3786
- content: result.output,
3787
- });
3788
- if (result.code !== 0) {
3789
- throw new CliError({
3790
- exitCode: result.code || 1,
3791
- code: "E_IO",
3792
- message: `Verify command failed: ${command}`,
3793
- });
3794
- }
3795
- }
3796
- if (branchHeadSha) {
3797
- verifyEntries.push({
3798
- header: `[${nowIso()}] ✅ verified_sha=${branchHeadSha}`,
3799
- content: "",
3800
- });
3801
- }
3802
- if (!opts.quiet) {
3803
- process.stdout.write(`${successMessage("verify passed", task.id)}\n`);
3804
- }
3805
- }
3806
- try {
3807
- await execFileAsync("git", ["merge", "--ff-only", branch], {
3808
- cwd: resolved.gitRoot,
3809
- env: gitEnv(),
3810
- });
3811
- }
3812
- catch (err) {
3813
- await execFileAsync("git", ["reset", "--hard", headBeforeMerge], {
3814
- cwd: resolved.gitRoot,
3815
- env: gitEnv(),
3816
- }).catch(() => null);
3817
- const message = err instanceof Error ? err.message : "git merge --ff-only failed";
3818
- throw new CliError({ exitCode: 2, code: "E_GIT", message });
3819
- }
3820
- mergeHash = await gitRevParse(resolved.gitRoot, ["HEAD"]);
3821
- }
3822
- if (!(await fileExists(prDir))) {
3823
- throw new CliError({
3824
- exitCode: 3,
3825
- code: "E_VALIDATION",
3826
- message: `Missing PR artifact dir after merge: ${path.relative(resolved.gitRoot, prDir)}`,
3827
- });
3828
- }
3829
- if (verifyEntries.length > 0) {
3830
- for (const entry of verifyEntries) {
3831
- await appendVerifyLog(verifyLogPath, entry.header, entry.content);
3832
- }
3833
- }
3834
- const rawMeta = await readFile(metaPath, "utf8");
3835
- const mergedMeta = parsePrMeta(rawMeta, task.id);
3836
- const now = nowIso();
3837
- const nextMeta = {
3838
- ...mergedMeta,
3839
- branch,
3840
- base_branch: base,
3841
- merge_strategy: opts.mergeStrategy,
3842
- status: "MERGED",
3843
- merged_at: mergedMeta.merged_at ?? now,
3844
- merge_commit: mergeHash,
3845
- head_sha: branchHeadSha,
3846
- updated_at: now,
3847
- };
3848
- if (verifyCommands.length > 0 && (shouldRunVerify || alreadyVerifiedSha)) {
3849
- nextMeta.last_verified_sha = branchHeadSha;
3850
- nextMeta.last_verified_at = now;
3851
- nextMeta.verify = mergedMeta.verify
3852
- ? { ...mergedMeta.verify, status: "pass" }
3853
- : { status: "pass", command: verifyCommands.join(" && ") };
3854
- }
3855
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
3856
- const diffstat = await gitDiffStat(resolved.gitRoot, baseShaBeforeMerge, branch);
3857
- await writeFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
3858
- const verifyDesc = verifyCommands.length === 0
3859
- ? "skipped(no commands)"
3860
- : shouldRunVerify
3861
- ? "ran"
3862
- : alreadyVerifiedSha
3863
- ? `skipped(already verified_sha=${alreadyVerifiedSha})`
3864
- : "skipped";
3865
- const finishBody = `Verified: Integrated via ${opts.mergeStrategy}; verify=${verifyDesc}; pr=${path.relative(resolved.gitRoot, prDir)}.`;
3866
- await cmdFinish({
3867
- cwd: opts.cwd,
3868
- rootOverride: opts.rootOverride,
3869
- taskIds: [task.id],
3870
- author: "INTEGRATOR",
3871
- body: finishBody,
3872
- commit: undefined,
3873
- skipVerify: false,
3874
- force: false,
3875
- noRequireTaskIdInCommit: false,
3876
- commitFromComment: false,
3877
- commitEmoji: undefined,
3878
- commitAllow: [],
3879
- commitAutoAllow: false,
3880
- commitAllowTasks: false,
3881
- commitRequireClean: false,
3882
- statusCommit: false,
3883
- statusCommitEmoji: undefined,
3884
- statusCommitAllow: [],
3885
- statusCommitAutoAllow: false,
3886
- statusCommitRequireClean: false,
3887
- confirmStatusCommit: false,
3888
- quiet: opts.quiet,
3889
- });
3890
- if (!opts.quiet) {
3891
- process.stdout.write(`${successMessage("integrate", task.id, `merge=${mergeHash.slice(0, 12)}`)}\n`);
3892
- }
3893
- return 0;
3894
- }
3895
- catch (err) {
3896
- if (err instanceof CliError)
3897
- throw err;
3898
- throw mapBackendError(err, { command: "integrate", root: opts.rootOverride ?? null });
3899
- }
3900
- finally {
3901
- if (createdTempWorktree && tempWorktreePath) {
3902
- try {
3903
- await execFileAsync("git", ["worktree", "remove", "--force", tempWorktreePath], {
3904
- cwd: opts.cwd,
3905
- env: gitEnv(),
3906
- });
3907
- }
3908
- catch {
3909
- // ignore cleanup errors
3910
- }
3911
- }
3912
- }
3913
- }
3914
- export async function cmdCleanupMerged(opts) {
3915
- try {
3916
- const resolved = await resolveProject({
3917
- cwd: opts.cwd,
3918
- rootOverride: opts.rootOverride ?? null,
3919
- });
3920
- const loaded = await loadConfig(resolved.agentplaneDir);
3921
- if (loaded.config.workflow_mode !== "branch_pr") {
3922
- throw new CliError({
3923
- exitCode: 2,
3924
- code: "E_USAGE",
3925
- message: workflowModeMessage(loaded.config.workflow_mode, "branch_pr"),
3926
- });
3927
- }
3928
- await ensureGitClean({ cwd: opts.cwd, rootOverride: opts.rootOverride });
3929
- const baseBranch = (opts.base ?? (await getBaseBranch({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null }))).trim();
3930
- if (!baseBranch) {
3931
- throw new CliError({
3932
- exitCode: 2,
3933
- code: "E_USAGE",
3934
- message: usageMessage(CLEANUP_MERGED_USAGE, CLEANUP_MERGED_USAGE_EXAMPLE),
3935
- });
3936
- }
3937
- if (!(await gitBranchExists(resolved.gitRoot, baseBranch))) {
3938
- throw new CliError({
3939
- exitCode: 5,
3940
- code: "E_GIT",
3941
- message: unknownEntityMessage("base branch", baseBranch),
3942
- });
3943
- }
3944
- const currentBranch = await gitCurrentBranch(resolved.gitRoot);
3945
- if (currentBranch !== baseBranch) {
3946
- throw new CliError({
3947
- exitCode: 5,
3948
- code: "E_GIT",
3949
- message: `cleanup merged must run on base branch ${baseBranch} (current: ${currentBranch})`,
3950
- });
3951
- }
3952
- const repoRoot = await resolvePathFallback(resolved.gitRoot);
3953
- const { backend } = await loadTaskBackend({
3954
- cwd: opts.cwd,
3955
- rootOverride: opts.rootOverride ?? null,
3956
- });
3957
- const tasks = await backend.listTasks();
3958
- const tasksById = new Map(tasks.map((task) => [task.id, task]));
3959
- const prefix = loaded.config.branch.task_prefix;
3960
- const branches = await gitListTaskBranches(resolved.gitRoot, prefix);
3961
- const candidates = [];
3962
- for (const branch of branches) {
3963
- if (branch === baseBranch)
3964
- continue;
3965
- const taskId = parseTaskIdFromBranch(prefix, branch);
3966
- if (!taskId)
3967
- continue;
3968
- const task = tasksById.get(taskId);
3969
- if (!task)
3970
- continue;
3971
- const status = String(task.status || "").toUpperCase();
3972
- if (status !== "DONE")
3973
- continue;
3974
- const diff = await gitDiffNames(resolved.gitRoot, baseBranch, branch);
3975
- if (diff.length > 0)
3976
- continue;
3977
- const worktreePath = await findWorktreeForBranch(resolved.gitRoot, branch);
3978
- candidates.push({ taskId, branch, worktreePath });
3979
- }
3980
- const sortedCandidates = candidates.toSorted((a, b) => a.taskId.localeCompare(b.taskId));
3981
- if (!opts.quiet) {
3982
- const archiveLabel = opts.archive ? " archive=on" : "";
3983
- process.stdout.write(`cleanup merged (base=${baseBranch}${archiveLabel})\n`);
3984
- if (sortedCandidates.length === 0) {
3985
- process.stdout.write("no candidates\n");
3986
- return 0;
3987
- }
3988
- for (const item of sortedCandidates) {
3989
- process.stdout.write(`- ${item.taskId}: branch=${item.branch} worktree=${item.worktreePath ?? "-"}\n`);
3990
- }
3991
- }
3992
- if (!opts.yes) {
3993
- if (!opts.quiet) {
3994
- process.stdout.write("Re-run with --yes to delete these branches/worktrees.\n");
3995
- }
3996
- return 0;
3997
- }
3998
- for (const item of sortedCandidates) {
3999
- const worktreePath = item.worktreePath ? await resolvePathFallback(item.worktreePath) : null;
4000
- if (worktreePath) {
4001
- if (!isPathWithin(repoRoot, worktreePath)) {
4002
- throw new CliError({
4003
- exitCode: 5,
4004
- code: "E_GIT",
4005
- message: `Refusing to remove worktree outside repo: ${worktreePath}`,
4006
- });
4007
- }
4008
- if (worktreePath === repoRoot) {
4009
- throw new CliError({
4010
- exitCode: 5,
4011
- code: "E_GIT",
4012
- message: "Refusing to remove the current worktree",
4013
- });
4014
- }
4015
- }
4016
- if (opts.archive) {
4017
- const taskDir = path.join(resolved.gitRoot, loaded.config.paths.workflow_dir, item.taskId);
4018
- await archivePrArtifacts(taskDir);
4019
- }
4020
- if (worktreePath) {
4021
- await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
4022
- cwd: resolved.gitRoot,
4023
- env: gitEnv(),
4024
- });
4025
- }
4026
- await execFileAsync("git", ["branch", "-D", item.branch], {
4027
- cwd: resolved.gitRoot,
4028
- env: gitEnv(),
4029
- });
4030
- }
4031
- if (!opts.quiet) {
4032
- process.stdout.write(`${successMessage("cleanup merged", undefined, `deleted=${candidates.length}`)}\n`);
4033
- }
4034
- return 0;
4035
- }
4036
- catch (err) {
4037
- if (err instanceof CliError)
4038
- throw err;
4039
- throw mapBackendError(err, { command: "cleanup merged", root: opts.rootOverride ?? null });
4040
- }
4041
- }
4042
- export async function cmdHooksInstall(opts) {
4043
- try {
4044
- const resolved = await resolveProject({
4045
- cwd: opts.cwd,
4046
- rootOverride: opts.rootOverride ?? null,
4047
- });
4048
- const hooksDir = await resolveGitHooksDir(resolved.gitRoot);
4049
- await mkdir(hooksDir, { recursive: true });
4050
- await mkdir(resolved.agentplaneDir, { recursive: true });
4051
- await ensureShim(resolved.agentplaneDir, resolved.gitRoot);
4052
- for (const hook of HOOK_NAMES) {
4053
- const hookPath = path.join(hooksDir, hook);
4054
- if (await fileExists(hookPath)) {
4055
- const managed = await fileIsManaged(hookPath, HOOK_MARKER);
4056
- if (!managed) {
4057
- throw new CliError({
4058
- exitCode: 5,
4059
- code: "E_GIT",
4060
- message: `Refusing to overwrite existing hook: ${path.relative(resolved.gitRoot, hookPath)}`,
4061
- });
4062
- }
4063
- }
4064
- await writeFile(hookPath, hookScriptText(hook), "utf8");
4065
- await chmod(hookPath, 0o755);
4066
- }
4067
- if (!opts.quiet) {
4068
- process.stdout.write(`${path.relative(resolved.gitRoot, hooksDir)}\n`);
4069
- }
4070
- return 0;
4071
- }
4072
- catch (err) {
4073
- if (err instanceof CliError)
4074
- throw err;
4075
- throw mapCoreError(err, { command: "hooks install", root: opts.rootOverride ?? null });
4076
- }
4077
- }
4078
- export async function cmdHooksUninstall(opts) {
4079
- try {
4080
- const resolved = await resolveProject({
4081
- cwd: opts.cwd,
4082
- rootOverride: opts.rootOverride ?? null,
4083
- });
4084
- const hooksDir = await resolveGitHooksDir(resolved.gitRoot);
4085
- let removed = 0;
4086
- for (const hook of HOOK_NAMES) {
4087
- const hookPath = path.join(hooksDir, hook);
4088
- if (!(await fileExists(hookPath)))
4089
- continue;
4090
- const managed = await fileIsManaged(hookPath, HOOK_MARKER);
4091
- if (!managed)
4092
- continue;
4093
- await rm(hookPath, { force: true });
4094
- removed++;
4095
- }
4096
- if (!opts.quiet) {
4097
- process.stdout.write(removed > 0
4098
- ? `${successMessage("removed hooks", undefined, `count=${removed}`)}\n`
4099
- : `${infoMessage("no agentplane hooks found")}\n`);
4100
- }
4101
- return 0;
4102
- }
4103
- catch (err) {
4104
- if (err instanceof CliError)
4105
- throw err;
4106
- throw mapCoreError(err, { command: "hooks uninstall", root: opts.rootOverride ?? null });
4107
- }
4108
- }
4109
- export async function cmdHooksRun(opts) {
4110
- try {
4111
- if (opts.hook === "commit-msg") {
4112
- const messagePath = opts.args[0];
4113
- if (!messagePath) {
4114
- throw new CliError({
4115
- exitCode: 2,
4116
- code: "E_USAGE",
4117
- message: "Missing commit message file path",
4118
- });
4119
- }
4120
- const raw = await readFile(messagePath, "utf8");
4121
- const subject = readCommitSubject(raw);
4122
- if (!subject) {
4123
- throw new CliError({
4124
- exitCode: 5,
4125
- code: "E_GIT",
4126
- message: "Commit message subject is empty",
4127
- });
4128
- }
4129
- const taskId = (process.env.AGENTPLANE_TASK_ID ?? "").trim();
4130
- if (taskId) {
4131
- const suffix = taskId.split("-").at(-1) ?? "";
4132
- if (!subject.includes(taskId) && (suffix.length === 0 || !subject.includes(suffix))) {
4133
- throw new CliError({
4134
- exitCode: 5,
4135
- code: "E_GIT",
4136
- message: "Commit subject must include task id or suffix",
4137
- });
4138
- }
4139
- return 0;
4140
- }
4141
- const { backend } = await loadTaskBackend({
4142
- cwd: opts.cwd,
4143
- rootOverride: opts.rootOverride ?? null,
4144
- });
4145
- const tasks = await backend.listTasks();
4146
- const suffixes = tasks.map((task) => task.id.split("-").at(-1) ?? "").filter(Boolean);
4147
- if (suffixes.length === 0) {
4148
- throw new CliError({
4149
- exitCode: 5,
4150
- code: "E_GIT",
4151
- message: "No task IDs available to validate commit subject",
4152
- });
4153
- }
4154
- if (!subjectHasSuffix(subject, suffixes)) {
4155
- throw new CliError({
4156
- exitCode: 5,
4157
- code: "E_GIT",
4158
- message: "Commit subject must mention a task suffix",
4159
- });
4160
- }
4161
- return 0;
4162
- }
4163
- if (opts.hook === "pre-commit") {
4164
- const staged = await getStagedFiles({
4165
- cwd: opts.cwd,
4166
- rootOverride: opts.rootOverride ?? null,
4167
- });
4168
- if (staged.length === 0)
4169
- return 0;
4170
- const allowTasks = (process.env.AGENTPLANE_ALLOW_TASKS ?? "").trim() === "1";
4171
- const allowBase = (process.env.AGENTPLANE_ALLOW_BASE ?? "").trim() === "1";
4172
- const resolved = await resolveProject({
4173
- cwd: opts.cwd,
4174
- rootOverride: opts.rootOverride ?? null,
4175
- });
4176
- const loaded = await loadConfig(resolved.agentplaneDir);
4177
- const tasksPath = loaded.config.paths.tasks_path;
4178
- const tasksStaged = staged.includes(tasksPath);
4179
- const nonTasks = staged.filter((entry) => entry !== tasksPath);
4180
- if (tasksStaged && !allowTasks) {
4181
- throw new CliError({
4182
- exitCode: 5,
4183
- code: "E_GIT",
4184
- message: `${tasksPath} is protected by agentplane hooks (set AGENTPLANE_ALLOW_TASKS=1 to override)`,
4185
- });
4186
- }
4187
- if (loaded.config.workflow_mode === "branch_pr") {
4188
- const baseBranch = await getBaseBranch({
4189
- cwd: opts.cwd,
4190
- rootOverride: opts.rootOverride ?? null,
4191
- });
4192
- const currentBranch = await gitCurrentBranch(resolved.gitRoot);
4193
- if (tasksStaged && currentBranch !== baseBranch) {
4194
- throw new CliError({
4195
- exitCode: 5,
4196
- code: "E_GIT",
4197
- message: `${tasksPath} commits are allowed only on ${baseBranch} in branch_pr mode`,
4198
- });
4199
- }
4200
- if (nonTasks.length > 0 && currentBranch === baseBranch && !allowBase) {
4201
- throw new CliError({
4202
- exitCode: 5,
4203
- code: "E_GIT",
4204
- message: `Code commits are forbidden on ${baseBranch} in branch_pr mode`,
4205
- });
4206
- }
4207
- }
4208
- return 0;
4209
- }
4210
- return 0;
4211
- }
4212
- catch (err) {
4213
- if (err instanceof CliError)
4214
- throw err;
4215
- throw mapBackendError(err, {
4216
- command: `hooks run ${opts.hook}`,
4217
- root: opts.rootOverride ?? null,
4218
- });
4219
- }
4220
- }
4221
- export async function cmdBranchBaseGet(opts) {
4222
- try {
4223
- const value = await getBaseBranch({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
4224
- process.stdout.write(`${value}\n`);
4225
- return 0;
4226
- }
4227
- catch (err) {
4228
- throw mapCoreError(err, { command: "branch base get", root: opts.rootOverride ?? null });
4229
- }
4230
- }
4231
- export async function cmdBranchBaseSet(opts) {
4232
- const trimmed = opts.value.trim();
4233
- if (trimmed.length === 0) {
4234
- throw new CliError({
4235
- exitCode: 2,
4236
- code: "E_USAGE",
4237
- message: usageMessage(BRANCH_BASE_USAGE, BRANCH_BASE_USAGE_EXAMPLE),
4238
- });
4239
- }
4240
- try {
4241
- const value = await setPinnedBaseBranch({
4242
- cwd: opts.cwd,
4243
- rootOverride: opts.rootOverride ?? null,
4244
- value: trimmed,
4245
- });
4246
- process.stdout.write(`${value}\n`);
4247
- return 0;
4248
- }
4249
- catch (err) {
4250
- throw mapCoreError(err, { command: "branch base set", root: opts.rootOverride ?? null });
4251
- }
4252
- }
4253
- export async function cmdBranchStatus(opts) {
4254
- try {
4255
- const resolved = await resolveProject({
4256
- cwd: opts.cwd,
4257
- rootOverride: opts.rootOverride ?? null,
4258
- });
4259
- const loaded = await loadConfig(resolved.agentplaneDir);
4260
- const branch = (opts.branch ?? (await gitCurrentBranch(resolved.gitRoot))).trim();
4261
- const base = (opts.base ?? (await getBaseBranch({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null }))).trim();
4262
- if (!branch || !base) {
4263
- throw new CliError({
4264
- exitCode: 2,
4265
- code: "E_USAGE",
4266
- message: usageMessage(BRANCH_STATUS_USAGE, BRANCH_STATUS_USAGE_EXAMPLE),
4267
- });
4268
- }
4269
- if (!(await gitBranchExists(resolved.gitRoot, branch))) {
4270
- throw new CliError({
4271
- exitCode: 2,
4272
- code: "E_USAGE",
4273
- message: unknownEntityMessage("branch", branch),
4274
- });
4275
- }
4276
- if (!(await gitBranchExists(resolved.gitRoot, base))) {
4277
- throw new CliError({
4278
- exitCode: 2,
4279
- code: "E_USAGE",
4280
- message: unknownEntityMessage("base branch", base),
4281
- });
4282
- }
4283
- const taskId = parseTaskIdFromBranch(loaded.config.branch.task_prefix, branch);
4284
- const worktree = await findWorktreeForBranch(resolved.gitRoot, branch);
4285
- const { ahead, behind } = await gitAheadBehind(resolved.gitRoot, base, branch);
4286
- process.stdout.write(`branch=${branch} base=${base} ahead=${ahead} behind=${behind} task_id=${taskId ?? "-"}\n`);
4287
- if (worktree) {
4288
- process.stdout.write(`worktree=${worktree}\n`);
4289
- }
4290
- return 0;
4291
- }
4292
- catch (err) {
4293
- if (err instanceof CliError)
4294
- throw err;
4295
- throw mapCoreError(err, { command: "branch status", root: opts.rootOverride ?? null });
4296
- }
4297
- }
4298
- export async function cmdBranchRemove(opts) {
4299
- const branch = (opts.branch ?? "").trim();
4300
- const worktree = (opts.worktree ?? "").trim();
4301
- if (!branch && !worktree) {
4302
- throw new CliError({
4303
- exitCode: 2,
4304
- code: "E_USAGE",
4305
- message: usageMessage(BRANCH_REMOVE_USAGE, BRANCH_REMOVE_USAGE_EXAMPLE),
4306
- });
4307
- }
4308
- try {
4309
- const resolved = await resolveProject({
4310
- cwd: opts.cwd,
4311
- rootOverride: opts.rootOverride ?? null,
4312
- });
4313
- const loaded = await loadConfig(resolved.agentplaneDir);
4314
- if (worktree) {
4315
- const worktreePath = path.isAbsolute(worktree)
4316
- ? await resolvePathFallback(worktree)
4317
- : await resolvePathFallback(path.join(resolved.gitRoot, worktree));
4318
- const worktreesRoot = path.resolve(resolved.gitRoot, loaded.config.paths.worktrees_dir);
4319
- if (!isPathWithin(worktreesRoot, worktreePath)) {
4320
- throw new CliError({
4321
- exitCode: 2,
4322
- code: "E_USAGE",
4323
- message: `Refusing to remove worktree outside ${worktreesRoot}: ${worktreePath}`,
4324
- });
4325
- }
4326
- await execFileAsync("git", ["worktree", "remove", ...(opts.force ? ["--force"] : []), worktreePath], { cwd: resolved.gitRoot, env: gitEnv() });
4327
- if (!opts.quiet) {
4328
- process.stdout.write(`${successMessage("removed worktree", worktreePath)}\n`);
4329
- }
4330
- }
4331
- if (branch) {
4332
- if (!(await gitBranchExists(resolved.gitRoot, branch))) {
4333
- throw new CliError({
4334
- exitCode: 2,
4335
- code: "E_USAGE",
4336
- message: unknownEntityMessage("branch", branch),
4337
- });
4338
- }
4339
- await execFileAsync("git", ["branch", opts.force ? "-D" : "-d", branch], {
4340
- cwd: resolved.gitRoot,
4341
- env: gitEnv(),
4342
- });
4343
- if (!opts.quiet) {
4344
- process.stdout.write(`${successMessage("removed branch", branch)}\n`);
4345
- }
4346
- }
4347
- return 0;
4348
- }
4349
- catch (err) {
4350
- if (err instanceof CliError)
4351
- throw err;
4352
- throw mapCoreError(err, { command: "branch remove", root: opts.rootOverride ?? null });
4353
- }
4354
- }
4355
- function normalizeDocUpdatedBy(value) {
4356
- const trimmed = value?.trim() ?? "";
4357
- if (!trimmed)
4358
- return "";
4359
- if (trimmed.toLowerCase() === "agentplane")
4360
- return "";
4361
- return trimmed;
4362
- }
4363
- function resolveDocUpdatedBy(task, author) {
4364
- const fromAuthor = normalizeDocUpdatedBy(author);
4365
- if (fromAuthor)
4366
- return fromAuthor;
4367
- const fromTask = normalizeDocUpdatedBy(typeof task.doc_updated_by === "string" ? task.doc_updated_by : undefined);
4368
- if (fromTask)
4369
- return fromTask;
4370
- return normalizeDocUpdatedBy(typeof task.owner === "string" ? task.owner : undefined);
4371
- }
4372
- function taskDataToFrontmatter(task) {
4373
- return {
4374
- id: task.id,
4375
- title: task.title,
4376
- status: task.status,
4377
- priority: task.priority,
4378
- owner: task.owner,
4379
- depends_on: task.depends_on ?? [],
4380
- tags: task.tags ?? [],
4381
- verify: task.verify ?? [],
4382
- commit: task.commit ?? null,
4383
- comments: task.comments ?? [],
4384
- doc_version: task.doc_version,
4385
- doc_updated_at: task.doc_updated_at,
4386
- doc_updated_by: task.doc_updated_by,
4387
- description: task.description ?? "",
4388
- };
4389
- }
4390
- async function loadBackendTask(opts) {
4391
- const { backend, backendId, resolved, config } = await loadTaskBackend({
4392
- cwd: opts.cwd,
4393
- rootOverride: opts.rootOverride ?? null,
4394
- });
4395
- const task = await backend.getTask(opts.taskId);
4396
- if (!task) {
4397
- const tasksDir = path.join(resolved.gitRoot, config.paths.workflow_dir);
4398
- const readmePath = path.join(tasksDir, opts.taskId, "README.md");
4399
- throw new CliError({
4400
- exitCode: 4,
4401
- code: "E_IO",
4402
- message: `ENOENT: no such file or directory, open '${readmePath}'`,
4403
- });
4404
- }
4405
- return { backend, backendId, resolved, config, task };
4406
- }
4407
- export const TASK_DOC_SET_USAGE = "Usage: agentplane task doc set <task-id> --section <name> (--text <text> | --file <path>)";
4408
- export const TASK_DOC_SET_USAGE_EXAMPLE = 'agentplane task doc set 202602030608-F1Q8AB --section Summary --text "..."';
4409
- export const TASK_DOC_SHOW_USAGE = "Usage: agentplane task doc show <task-id> [--section <name>] [--quiet]";
4410
- export const TASK_DOC_SHOW_USAGE_EXAMPLE = "agentplane task doc show 202602030608-F1Q8AB --section Summary";
4411
- function parseTaskDocShowFlags(args) {
4412
- const out = { quiet: false };
4413
- for (let i = 0; i < args.length; i++) {
4414
- const arg = args[i];
4415
- if (!arg)
4416
- continue;
4417
- if (arg === "--quiet") {
4418
- out.quiet = true;
4419
- continue;
4420
- }
4421
- if (arg === "--section") {
4422
- const next = args[i + 1];
4423
- if (!next) {
4424
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
4425
- }
4426
- out.section = next;
4427
- i++;
4428
- continue;
4429
- }
4430
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
4431
- }
4432
- return out;
4433
- }
4434
- function parseTaskDocSetFlags(args) {
4435
- const out = {};
4436
- for (let i = 0; i < args.length; i++) {
4437
- const arg = args[i];
4438
- if (!arg)
4439
- continue;
4440
- if (!arg.startsWith("--")) {
4441
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unexpected argument: ${arg}` });
4442
- }
4443
- const next = args[i + 1];
4444
- if (!next) {
4445
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
4446
- }
4447
- switch (arg) {
4448
- case "--section": {
4449
- out.section = next;
4450
- break;
4451
- }
4452
- case "--text": {
4453
- out.text = next;
4454
- break;
4455
- }
4456
- case "--file": {
4457
- out.file = next;
4458
- break;
4459
- }
4460
- case "--updated-by": {
4461
- out.updatedBy = next;
4462
- break;
4463
- }
4464
- default: {
4465
- throw new CliError({ exitCode: 2, code: "E_USAGE", message: `Unknown flag: ${arg}` });
4466
- }
4467
- }
4468
- i++;
4469
- }
4470
- return out;
4471
- }
4472
- export async function cmdTaskDocSet(opts) {
4473
- const flags = parseTaskDocSetFlags(opts.args);
4474
- if (!flags.section) {
4475
- throw new CliError({
4476
- exitCode: 2,
4477
- code: "E_USAGE",
4478
- message: usageMessage(TASK_DOC_SET_USAGE, TASK_DOC_SET_USAGE_EXAMPLE),
4479
- });
4480
- }
4481
- const hasText = flags.text !== undefined;
4482
- const hasFile = flags.file !== undefined;
4483
- if (hasText === hasFile) {
4484
- throw new CliError({
4485
- exitCode: 2,
4486
- code: "E_USAGE",
4487
- message: usageMessage(TASK_DOC_SET_USAGE, TASK_DOC_SET_USAGE_EXAMPLE),
4488
- });
4489
- }
4490
- let updatedBy;
4491
- if (flags.updatedBy !== undefined) {
4492
- const trimmed = flags.updatedBy.trim();
4493
- if (trimmed.length === 0) {
4494
- throw new CliError({
4495
- exitCode: 2,
4496
- code: "E_USAGE",
4497
- message: "--updated-by must be non-empty",
4498
- });
4499
- }
4500
- updatedBy = trimmed;
4501
- }
4502
- let text = flags.text ?? "";
4503
- if (hasFile) {
4504
- try {
4505
- text = await readFile(path.resolve(opts.cwd, flags.file ?? ""), "utf8");
4506
- }
4507
- catch (err) {
4508
- throw mapCoreError(err, { command: "task doc set", filePath: flags.file ?? "" });
4509
- }
4510
- }
4511
- try {
4512
- const { backend, resolved, config } = await loadTaskBackend({
4513
- cwd: opts.cwd,
4514
- rootOverride: opts.rootOverride ?? null,
4515
- });
4516
- if (!backend.getTaskDoc || !backend.setTaskDoc) {
4517
- throw new CliError({
4518
- exitCode: 2,
4519
- code: "E_USAGE",
4520
- message: backendNotSupportedMessage("task docs"),
4521
- });
4522
- }
4523
- const allowed = config.tasks.doc.sections;
4524
- if (!allowed.includes(flags.section)) {
4525
- throw new CliError({
4526
- exitCode: 2,
4527
- code: "E_USAGE",
4528
- message: unknownEntityMessage("doc section", flags.section),
4529
- });
4530
- }
4531
- const normalizedAllowed = new Set(allowed.map((section) => normalizeDocSectionName(section)));
4532
- const targetKey = normalizeDocSectionName(flags.section);
4533
- const headingKeys = new Set();
4534
- for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
4535
- const match = /^##\s+(.*)$/.exec(line.trim());
4536
- if (!match)
4537
- continue;
4538
- const key = normalizeDocSectionName(match[1] ?? "");
4539
- if (key && normalizedAllowed.has(key))
4540
- headingKeys.add(key);
4541
- }
4542
- const existing = await backend.getTaskDoc(opts.taskId);
4543
- const baseDoc = ensureDocSections(existing ?? "", config.tasks.doc.required_sections);
4544
- if (headingKeys.size > 0 && (headingKeys.size > 1 || !headingKeys.has(targetKey))) {
4545
- const fullDoc = ensureDocSections(text, config.tasks.doc.required_sections);
4546
- await backend.setTaskDoc(opts.taskId, fullDoc, updatedBy);
4547
- }
4548
- else {
4549
- let nextText = text;
4550
- if (headingKeys.size > 0 && headingKeys.has(targetKey)) {
4551
- const lines = nextText.replaceAll("\r\n", "\n").split("\n");
4552
- let firstContent = 0;
4553
- while (firstContent < lines.length && lines[firstContent]?.trim() === "")
4554
- firstContent++;
4555
- if ((lines[firstContent]?.trim() ?? "") === `## ${flags.section}`) {
4556
- lines.splice(firstContent, 1);
4557
- if (lines[firstContent]?.trim() === "")
4558
- lines.splice(firstContent, 1);
4559
- nextText = lines.join("\n");
4560
- }
4561
- }
4562
- const nextDoc = setMarkdownSection(baseDoc, flags.section, nextText);
4563
- const normalized = ensureDocSections(nextDoc, config.tasks.doc.required_sections);
4564
- await backend.setTaskDoc(opts.taskId, normalized, updatedBy);
4565
- }
4566
- const tasksDir = path.join(resolved.gitRoot, config.paths.workflow_dir);
4567
- process.stdout.write(`${path.join(tasksDir, opts.taskId, "README.md")}\n`);
4568
- return 0;
4569
- }
4570
- catch (err) {
4571
- if (err instanceof CliError)
4572
- throw err;
4573
- throw mapBackendError(err, { command: "task doc set", root: opts.rootOverride ?? null });
4574
- }
4575
- }
4576
- export async function cmdTaskDocShow(opts) {
4577
- const flags = parseTaskDocShowFlags(opts.args);
4578
- try {
4579
- const { backend } = await loadTaskBackend({
4580
- cwd: opts.cwd,
4581
- rootOverride: opts.rootOverride ?? null,
4582
- });
4583
- if (!backend.getTaskDoc) {
4584
- throw new CliError({
4585
- exitCode: 2,
4586
- code: "E_USAGE",
4587
- message: backendNotSupportedMessage("task docs"),
4588
- });
4589
- }
4590
- const doc = (await backend.getTaskDoc(opts.taskId)) ?? "";
4591
- if (flags.section) {
4592
- const sectionKey = normalizeDocSectionName(flags.section);
4593
- const { sections } = parseDocSections(doc);
4594
- const entry = sections.get(sectionKey);
4595
- const content = entry?.lines ?? [];
4596
- if (content.length > 0) {
4597
- process.stdout.write(`${content.join("\n").trimEnd()}\n`);
4598
- return 0;
4599
- }
4600
- if (!flags.quiet) {
4601
- process.stdout.write(`${infoMessage(`section has no content: ${flags.section}`)}\n`);
4602
- }
4603
- return 0;
4604
- }
4605
- if (doc.trim()) {
4606
- process.stdout.write(`${doc.trimEnd()}\n`);
4607
- return 0;
4608
- }
4609
- if (!flags.quiet) {
4610
- process.stdout.write(`${infoMessage("task doc metadata missing")}\n`);
4611
- }
4612
- return 0;
4613
- }
4614
- catch (err) {
4615
- if (err instanceof CliError)
4616
- throw err;
4617
- throw mapBackendError(err, { command: "task doc show", root: opts.rootOverride ?? null });
4618
- }
4619
- }
4
+ export { TASK_NEW_USAGE, TASK_NEW_USAGE_EXAMPLE, TASK_ADD_USAGE, TASK_ADD_USAGE_EXAMPLE, TASK_SCRUB_USAGE, TASK_SCRUB_USAGE_EXAMPLE, TASK_UPDATE_USAGE, TASK_UPDATE_USAGE_EXAMPLE, TASK_SCAFFOLD_USAGE, TASK_SCAFFOLD_USAGE_EXAMPLE, TASK_DOC_SET_USAGE, TASK_DOC_SET_USAGE_EXAMPLE, TASK_DOC_SHOW_USAGE, TASK_DOC_SHOW_USAGE_EXAMPLE, START_USAGE, START_USAGE_EXAMPLE, BLOCK_USAGE, BLOCK_USAGE_EXAMPLE, FINISH_USAGE, FINISH_USAGE_EXAMPLE, VERIFY_USAGE, VERIFY_USAGE_EXAMPLE, dedupeStrings, cmdTaskNew, cmdTaskAdd, cmdTaskUpdate, cmdTaskScrub, cmdTaskListWithFilters, cmdTaskNext, cmdReady, cmdTaskSearch, cmdTaskScaffold, cmdTaskNormalize, cmdTaskMigrate, cmdTaskComment, cmdTaskSetStatus, cmdTaskShow, cmdTaskList, cmdTaskExport, cmdTaskLint, cmdTaskDocSet, cmdTaskDocShow, cmdStart, cmdBlock, cmdFinish, cmdVerify, } from "./task/index.js";
5
+ export { BRANCH_BASE_USAGE, BRANCH_BASE_USAGE_EXAMPLE, BRANCH_STATUS_USAGE, BRANCH_STATUS_USAGE_EXAMPLE, BRANCH_REMOVE_USAGE, BRANCH_REMOVE_USAGE_EXAMPLE, WORK_START_USAGE, WORK_START_USAGE_EXAMPLE, CLEANUP_MERGED_USAGE, CLEANUP_MERGED_USAGE_EXAMPLE, gitInitRepo, resolveInitBaseBranch, promptInitBaseBranch, ensureInitCommit, cmdWorkStart, cmdCleanupMerged, cmdBranchBaseGet, cmdBranchBaseSet, cmdBranchBaseClear, cmdBranchBaseExplain, cmdBranchStatus, cmdBranchRemove, } from "./branch/index.js";
6
+ export { PR_OPEN_USAGE, PR_OPEN_USAGE_EXAMPLE, PR_UPDATE_USAGE, PR_UPDATE_USAGE_EXAMPLE, PR_CHECK_USAGE, PR_CHECK_USAGE_EXAMPLE, PR_NOTE_USAGE, PR_NOTE_USAGE_EXAMPLE, INTEGRATE_USAGE, INTEGRATE_USAGE_EXAMPLE, cmdPrOpen, cmdPrUpdate, cmdPrCheck, cmdPrNote, cmdIntegrate, } from "./pr/index.js";
7
+ export { GUARD_COMMIT_USAGE, GUARD_COMMIT_USAGE_EXAMPLE, COMMIT_USAGE, COMMIT_USAGE_EXAMPLE, suggestAllowPrefixes, cmdGuardClean, cmdGuardSuggestAllow, cmdGuardCommit, cmdCommit, } from "./guard/index.js";
8
+ export { HOOK_NAMES, cmdHooksInstall, cmdHooksUninstall, cmdHooksRun } from "./hooks/index.js";