agentplane 0.1.7 → 0.1.9

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 (86) hide show
  1. package/assets/AGENTS.md +13 -2
  2. package/dist/backends/task-backend.d.ts +28 -0
  3. package/dist/backends/task-backend.d.ts.map +1 -1
  4. package/dist/backends/task-backend.js +85 -4
  5. package/dist/backends/task-index.d.ts.map +1 -1
  6. package/dist/backends/task-index.js +3 -6
  7. package/dist/cli/command-guide.d.ts.map +1 -1
  8. package/dist/cli/command-guide.js +10 -11
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +7 -5
  11. package/dist/cli/run-cli.d.ts.map +1 -1
  12. package/dist/cli/run-cli.js +75 -74
  13. package/dist/commands/backend.d.ts.map +1 -1
  14. package/dist/commands/backend.js +17 -2
  15. package/dist/commands/branch/index.d.ts.map +1 -1
  16. package/dist/commands/branch/index.js +3 -1
  17. package/dist/commands/guard/index.d.ts +24 -3
  18. package/dist/commands/guard/index.d.ts.map +1 -1
  19. package/dist/commands/guard/index.js +175 -61
  20. package/dist/commands/hooks/index.d.ts.map +1 -1
  21. package/dist/commands/hooks/index.js +39 -29
  22. package/dist/commands/pr/index.d.ts.map +1 -1
  23. package/dist/commands/pr/index.js +15 -12
  24. package/dist/commands/recipes.d.ts +75 -6
  25. package/dist/commands/recipes.d.ts.map +1 -1
  26. package/dist/commands/recipes.js +76 -538
  27. package/dist/commands/scenario.d.ts +7 -0
  28. package/dist/commands/scenario.d.ts.map +1 -0
  29. package/dist/commands/scenario.js +501 -0
  30. package/dist/commands/shared/network-approval.d.ts +8 -0
  31. package/dist/commands/shared/network-approval.d.ts.map +1 -0
  32. package/dist/commands/shared/network-approval.js +25 -0
  33. package/dist/commands/shared/task-backend.d.ts +19 -3
  34. package/dist/commands/shared/task-backend.d.ts.map +1 -1
  35. package/dist/commands/shared/task-backend.js +19 -5
  36. package/dist/commands/task/block.d.ts.map +1 -1
  37. package/dist/commands/task/block.js +22 -16
  38. package/dist/commands/task/comment.d.ts.map +1 -1
  39. package/dist/commands/task/comment.js +9 -2
  40. package/dist/commands/task/finish.d.ts.map +1 -1
  41. package/dist/commands/task/finish.js +36 -25
  42. package/dist/commands/task/index.d.ts +3 -0
  43. package/dist/commands/task/index.d.ts.map +1 -1
  44. package/dist/commands/task/index.js +3 -0
  45. package/dist/commands/task/migrate-doc.d.ts +8 -0
  46. package/dist/commands/task/migrate-doc.d.ts.map +1 -0
  47. package/dist/commands/task/migrate-doc.js +147 -0
  48. package/dist/commands/task/plan.d.ts +14 -0
  49. package/dist/commands/task/plan.d.ts.map +1 -0
  50. package/dist/commands/task/plan.js +217 -0
  51. package/dist/commands/task/scaffold.d.ts.map +1 -1
  52. package/dist/commands/task/scaffold.js +15 -4
  53. package/dist/commands/task/set-status.d.ts.map +1 -1
  54. package/dist/commands/task/set-status.js +18 -4
  55. package/dist/commands/task/shared.d.ts +5 -2
  56. package/dist/commands/task/shared.d.ts.map +1 -1
  57. package/dist/commands/task/shared.js +47 -28
  58. package/dist/commands/task/start.d.ts.map +1 -1
  59. package/dist/commands/task/start.js +24 -17
  60. package/dist/commands/task/verify-record.d.ts +16 -0
  61. package/dist/commands/task/verify-record.d.ts.map +1 -0
  62. package/dist/commands/task/verify-record.js +284 -0
  63. package/dist/commands/task/verify.d.ts +1 -13
  64. package/dist/commands/task/verify.d.ts.map +1 -1
  65. package/dist/commands/task/verify.js +1 -362
  66. package/dist/commands/upgrade.d.ts.map +1 -1
  67. package/dist/commands/upgrade.js +17 -2
  68. package/dist/commands/workflow.d.ts +1 -1
  69. package/dist/commands/workflow.d.ts.map +1 -1
  70. package/dist/commands/workflow.js +1 -1
  71. package/dist/shared/git-log.d.ts +5 -0
  72. package/dist/shared/git-log.d.ts.map +1 -0
  73. package/dist/shared/git-log.js +14 -0
  74. package/dist/shared/git-path.d.ts +3 -0
  75. package/dist/shared/git-path.d.ts.map +1 -0
  76. package/dist/shared/git-path.js +30 -0
  77. package/dist/shared/guards.d.ts +2 -0
  78. package/dist/shared/guards.d.ts.map +1 -0
  79. package/dist/shared/guards.js +3 -0
  80. package/dist/shared/protected-paths.d.ts +12 -0
  81. package/dist/shared/protected-paths.d.ts.map +1 -0
  82. package/dist/shared/protected-paths.js +51 -0
  83. package/dist/shared/strings.d.ts +2 -0
  84. package/dist/shared/strings.d.ts.map +1 -0
  85. package/dist/shared/strings.js +14 -0
  86. package/package.json +2 -2
@@ -1,18 +1,31 @@
1
- import { extractTaskSuffix, getStagedFiles, getUnstagedFiles, loadConfig, resolveProject, validateCommitSubject, } from "@agentplaneorg/core";
1
+ import { extractTaskSuffix, getStagedFiles, getUnstagedFiles, loadConfig, resolveBaseBranch, resolveProject, validateCommitSubject, } from "@agentplaneorg/core";
2
2
  import { mapCoreError } from "../../cli/error-map.js";
3
3
  import { invalidValueMessage, successMessage } from "../../cli/output.js";
4
4
  import { CliError } from "../../shared/errors.js";
5
- import { formatCommentBodyForCommit } from "../../shared/comment-format.js";
5
+ import { formatCommentBodyForCommit, normalizeCommentBodyForCommit, } from "../../shared/comment-format.js";
6
+ import { gitPathIsUnderPrefix, normalizeGitPathPrefix } from "../../shared/git-path.js";
6
7
  import { execFileAsync } from "../shared/git.js";
7
- function pathIsUnder(candidate, prefix) {
8
- if (prefix === "." || prefix === "")
9
- return true;
10
- if (candidate === prefix)
11
- return true;
12
- return candidate.startsWith(`${prefix}/`);
8
+ import { gitCurrentBranch } from "../shared/git-ops.js";
9
+ import { getProtectedPathOverride, protectedPathKindForFile, } from "../../shared/protected-paths.js";
10
+ import { parseGitLogHashSubject } from "../../shared/git-log.js";
11
+ function parseNullSeparatedPaths(stdout) {
12
+ const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : stdout;
13
+ return text
14
+ .split("\0")
15
+ .map((entry) => entry.trim())
16
+ .filter((entry) => entry.length > 0);
13
17
  }
14
- function normalizeAllowPrefix(prefix) {
15
- return prefix.replace(/\/+$/, "");
18
+ export function buildGitCommitEnv(opts) {
19
+ return {
20
+ ...process.env,
21
+ AGENTPLANE_TASK_ID: opts.taskId,
22
+ AGENTPLANE_ALLOW_TASKS: opts.allowTasks ? "1" : "0",
23
+ AGENTPLANE_ALLOW_BASE: opts.allowBase ? "1" : "0",
24
+ AGENTPLANE_ALLOW_POLICY: opts.allowPolicy ? "1" : "0",
25
+ AGENTPLANE_ALLOW_CONFIG: opts.allowConfig ? "1" : "0",
26
+ AGENTPLANE_ALLOW_HOOKS: opts.allowHooks ? "1" : "0",
27
+ AGENTPLANE_ALLOW_CI: opts.allowCI ? "1" : "0",
28
+ };
16
29
  }
17
30
  export function suggestAllowPrefixes(paths) {
18
31
  const out = new Set();
@@ -27,10 +40,10 @@ export function suggestAllowPrefixes(paths) {
27
40
  }
28
41
  return [...out].toSorted((a, b) => a.localeCompare(b));
29
42
  }
30
- export const GUARD_COMMIT_USAGE = "Usage: agentplane guard commit <task-id> -m <message> --allow <path> [--allow <path>...] [--auto-allow] [--allow-tasks] [--require-clean] [--quiet]";
31
- export const GUARD_COMMIT_USAGE_EXAMPLE = 'agentplane guard commit 202602030608-F1Q8AB -m "✨ F1Q8AB update" --allow packages/agentplane';
43
+ export const GUARD_COMMIT_USAGE = "Usage: agentplane guard commit <task-id> -m <message> --allow <path> [--allow <path>...] [--auto-allow] [--allow-tasks] [--allow-base] [--allow-policy] [--allow-config] [--allow-hooks] [--allow-ci] [--require-clean] [--quiet]";
44
+ export const GUARD_COMMIT_USAGE_EXAMPLE = 'agentplane guard commit 202602030608-F1Q8AB -m "✨ F1Q8AB task: implement allowlist guard" --allow packages/agentplane';
32
45
  export const COMMIT_USAGE = "Usage: agentplane commit <task-id> -m <message>";
33
- export const COMMIT_USAGE_EXAMPLE = 'agentplane commit 202602030608-F1Q8AB -m "✨ F1Q8AB update"';
46
+ export const COMMIT_USAGE_EXAMPLE = 'agentplane commit 202602030608-F1Q8AB -m "✨ F1Q8AB task: implement allowlist guard"';
34
47
  async function guardCommitCheck(opts) {
35
48
  const resolved = await resolveProject({
36
49
  cwd: opts.cwd,
@@ -60,10 +73,8 @@ async function guardCommitCheck(opts) {
60
73
  message: "Provide at least one --allow <path> prefix",
61
74
  });
62
75
  }
63
- const allow = opts.allow.map((prefix) => normalizeAllowPrefix(prefix));
64
- const denied = new Set();
65
- if (!opts.allowTasks)
66
- denied.add(".agentplane/tasks.json");
76
+ const allow = opts.allow.map((prefix) => normalizeGitPathPrefix(prefix));
77
+ const tasksPath = loaded.config.paths.tasks_path;
67
78
  if (opts.requireClean) {
68
79
  const unstaged = await getUnstagedFiles({
69
80
  cwd: opts.cwd,
@@ -73,15 +84,62 @@ async function guardCommitCheck(opts) {
73
84
  throw new CliError({ exitCode: 5, code: "E_GIT", message: "Working tree is dirty" });
74
85
  }
75
86
  }
87
+ if (loaded.config.workflow_mode === "branch_pr") {
88
+ const baseBranch = await resolveBaseBranch({
89
+ cwd: opts.cwd,
90
+ rootOverride: opts.rootOverride ?? null,
91
+ cliBaseOpt: null,
92
+ mode: loaded.config.workflow_mode,
93
+ });
94
+ if (!baseBranch) {
95
+ throw new CliError({
96
+ exitCode: 2,
97
+ code: "E_USAGE",
98
+ message: "Base branch could not be resolved (use `agentplane branch base set`).",
99
+ });
100
+ }
101
+ const currentBranch = await gitCurrentBranch(resolved.gitRoot);
102
+ const tasksStaged = staged.includes(tasksPath);
103
+ const nonTasks = staged.filter((entry) => entry !== tasksPath);
104
+ if (tasksStaged && currentBranch !== baseBranch) {
105
+ throw new CliError({
106
+ exitCode: 5,
107
+ code: "E_GIT",
108
+ message: `${tasksPath} commits are allowed only on ${baseBranch} in branch_pr mode`,
109
+ });
110
+ }
111
+ if (nonTasks.length > 0 && currentBranch === baseBranch && !opts.allowBase) {
112
+ throw new CliError({
113
+ exitCode: 5,
114
+ code: "E_GIT",
115
+ message: `Code commits are forbidden on ${baseBranch} in branch_pr mode`,
116
+ });
117
+ }
118
+ }
76
119
  for (const filePath of staged) {
77
- if (denied.has(filePath)) {
120
+ const kind = protectedPathKindForFile({ filePath, tasksPath });
121
+ if (kind === "tasks" && !opts.allowTasks) {
78
122
  throw new CliError({
79
123
  exitCode: 5,
80
124
  code: "E_GIT",
81
125
  message: `Staged file is forbidden by default: ${filePath} (use --allow-tasks to override)`,
82
126
  });
83
127
  }
84
- if (!allow.some((prefix) => pathIsUnder(filePath, prefix))) {
128
+ if (kind && kind !== "tasks") {
129
+ const override = getProtectedPathOverride(kind);
130
+ const allowed = (kind === "policy" && opts.allowPolicy) ||
131
+ (kind === "config" && opts.allowConfig) ||
132
+ (kind === "hooks" && opts.allowHooks) ||
133
+ (kind === "ci" && opts.allowCI);
134
+ if (!allowed) {
135
+ throw new CliError({
136
+ exitCode: 5,
137
+ code: "E_GIT",
138
+ message: `Staged file is protected by default: ${filePath} (use ${override.cliFlag} to override)`,
139
+ });
140
+ }
141
+ }
142
+ if (!allow.some((prefix) => gitPathIsUnderPrefix(filePath, prefix))) {
85
143
  throw new CliError({
86
144
  exitCode: 5,
87
145
  code: "E_GIT",
@@ -95,22 +153,22 @@ export async function gitStatusChangedPaths(opts) {
95
153
  cwd: opts.cwd,
96
154
  rootOverride: opts.rootOverride ?? null,
97
155
  });
98
- const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
156
+ const optsExec = {
99
157
  cwd: resolved.gitRoot,
100
- });
101
- const files = [];
102
- for (const line of stdout.split("\n")) {
103
- const trimmed = line.trim();
104
- if (!trimmed)
105
- continue;
106
- const filePart = trimmed.slice(2).trim();
107
- if (!filePart)
108
- continue;
109
- const name = filePart.includes("->") ? filePart.split("->").at(-1)?.trim() : filePart;
110
- if (name)
111
- files.push(name);
112
- }
113
- return files;
158
+ encoding: "buffer",
159
+ maxBuffer: 10 * 1024 * 1024,
160
+ };
161
+ const [unstaged, staged, untracked] = await Promise.all([
162
+ execFileAsync("git", ["diff", "--name-only", "-z"], optsExec),
163
+ execFileAsync("git", ["diff", "--name-only", "--cached", "-z"], optsExec),
164
+ execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], optsExec),
165
+ ]);
166
+ const files = [
167
+ ...parseNullSeparatedPaths(unstaged.stdout),
168
+ ...parseNullSeparatedPaths(staged.stdout),
169
+ ...parseNullSeparatedPaths(untracked.stdout),
170
+ ];
171
+ return [...new Set(files)].toSorted((a, b) => a.localeCompare(b));
114
172
  }
115
173
  export async function ensureGitClean(opts) {
116
174
  const staged = await getStagedFiles({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
@@ -142,15 +200,15 @@ async function stageAllowlist(opts) {
142
200
  message: "No changes to stage (working tree clean)",
143
201
  });
144
202
  }
145
- const allow = opts.allow.map((prefix) => normalizeAllowPrefix(prefix.trim().replace(/^\.?\//, "")));
203
+ const allow = opts.allow.map((prefix) => normalizeGitPathPrefix(prefix));
146
204
  const denied = new Set();
147
205
  if (!opts.allowTasks)
148
- denied.add(".agentplane/tasks.json");
206
+ denied.add(opts.tasksPath);
149
207
  const staged = [];
150
208
  for (const filePath of changed) {
151
209
  if (denied.has(filePath))
152
210
  continue;
153
- if (allow.some((prefix) => pathIsUnder(filePath, prefix))) {
211
+ if (allow.some((prefix) => gitPathIsUnderPrefix(filePath, prefix))) {
154
212
  staged.push(filePath);
155
213
  }
156
214
  }
@@ -162,13 +220,23 @@ async function stageAllowlist(opts) {
162
220
  message: "No changes matched allowed prefixes (use --commit-auto-allow or update --commit-allow)",
163
221
  });
164
222
  }
165
- await execFileAsync("git", ["add", "--", ...unique], { cwd: resolved.gitRoot });
223
+ // `git add <pathspec>` is not reliable for staging deletes/renames across versions/configs.
224
+ // `-A -- <pathspec...>` makes the allowlist staging semantics deterministic.
225
+ await execFileAsync("git", ["add", "-A", "--", ...unique], { cwd: resolved.gitRoot });
166
226
  return unique;
167
227
  }
168
228
  function deriveCommitMessageFromComment(opts) {
169
- const summary = (opts.formattedComment ?? formatCommentBodyForCommit(opts.body, opts.config))
229
+ const raw = (opts.formattedComment ?? formatCommentBodyForCommit(opts.body, opts.config))
170
230
  .trim()
171
231
  .replaceAll(/\s+/g, " ");
232
+ if (!raw) {
233
+ throw new CliError({
234
+ exitCode: 2,
235
+ code: "E_USAGE",
236
+ message: "Comment body is required to build a commit message from the task comment",
237
+ });
238
+ }
239
+ const summary = raw.replace(/^(start|blocked|verified):\s+/i, "").trim();
172
240
  if (!summary) {
173
241
  throw new CliError({
174
242
  exitCode: 2,
@@ -192,13 +260,33 @@ function deriveCommitMessageFromComment(opts) {
192
260
  message: invalidValueMessage("task id", opts.taskId, "valid task id"),
193
261
  });
194
262
  }
195
- return `${prefix} ${suffix} ${summary}`;
263
+ return `${prefix} ${suffix} task: ${summary}`;
264
+ }
265
+ function deriveCommitBodyFromComment(opts) {
266
+ const lines = [
267
+ `Task: ${opts.taskId}`,
268
+ ...(opts.author ? [`Agent: ${opts.author}`] : []),
269
+ ...(opts.statusFrom && opts.statusTo ? [`Status: ${opts.statusFrom} -> ${opts.statusTo}`] : []),
270
+ `Comment: ${normalizeCommentBodyForCommit(opts.formattedComment || opts.commentBody)}`,
271
+ ];
272
+ return lines.join("\n").trimEnd();
196
273
  }
197
274
  export async function commitFromComment(opts) {
198
275
  let allowPrefixes = opts.allow.map((prefix) => prefix.trim()).filter(Boolean);
199
276
  if (opts.autoAllow && allowPrefixes.length === 0) {
200
277
  const changed = await gitStatusChangedPaths({ cwd: opts.cwd, rootOverride: opts.rootOverride });
201
- allowPrefixes = suggestAllowPrefixes(changed);
278
+ const tasksPath = opts.config.paths.tasks_path;
279
+ // Auto-allow is for ergonomic status commits. It must never silently
280
+ // broaden into policy/config/CI changes (those require explicit intent).
281
+ const eligible = changed.filter((filePath) => {
282
+ const kind = protectedPathKindForFile({ filePath, tasksPath });
283
+ if (!kind)
284
+ return true;
285
+ if (kind === "tasks")
286
+ return opts.allowTasks;
287
+ return false;
288
+ });
289
+ allowPrefixes = suggestAllowPrefixes(eligible);
202
290
  }
203
291
  if (allowPrefixes.length === 0) {
204
292
  throw new CliError({
@@ -212,6 +300,7 @@ export async function commitFromComment(opts) {
212
300
  rootOverride: opts.rootOverride,
213
301
  allow: allowPrefixes,
214
302
  allowTasks: opts.allowTasks,
303
+ tasksPath: opts.config.paths.tasks_path,
215
304
  });
216
305
  const message = deriveCommitMessageFromComment({
217
306
  taskId: opts.taskId,
@@ -220,13 +309,27 @@ export async function commitFromComment(opts) {
220
309
  formattedComment: opts.formattedComment,
221
310
  config: opts.config,
222
311
  });
312
+ const formattedComment = opts.formattedComment ?? formatCommentBodyForCommit(opts.commentBody, opts.config);
313
+ const body = deriveCommitBodyFromComment({
314
+ taskId: opts.taskId,
315
+ author: opts.author,
316
+ statusFrom: opts.statusFrom,
317
+ statusTo: opts.statusTo,
318
+ commentBody: opts.commentBody,
319
+ formattedComment,
320
+ });
223
321
  await guardCommitCheck({
224
322
  cwd: opts.cwd,
225
323
  rootOverride: opts.rootOverride,
226
324
  taskId: opts.taskId,
227
325
  message,
228
326
  allow: allowPrefixes,
327
+ allowBase: false,
229
328
  allowTasks: opts.allowTasks,
329
+ allowPolicy: false,
330
+ allowConfig: false,
331
+ allowHooks: false,
332
+ allowCI: false,
230
333
  requireClean: opts.requireClean,
231
334
  quiet: opts.quiet,
232
335
  });
@@ -234,22 +337,26 @@ export async function commitFromComment(opts) {
234
337
  cwd: opts.cwd,
235
338
  rootOverride: opts.rootOverride ?? null,
236
339
  });
237
- const env = {
238
- ...process.env,
239
- AGENTPLANE_TASK_ID: opts.taskId,
240
- AGENTPLANE_ALLOW_TASKS: opts.allowTasks ? "1" : "0",
241
- AGENTPLANE_ALLOW_BASE: opts.allowTasks ? "1" : "0",
242
- };
243
- await execFileAsync("git", ["commit", "-m", message], { cwd: resolved.gitRoot, env });
244
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s"], {
340
+ // Never allow base-branch code commits implicitly from comment-driven commits.
341
+ // Base overrides must be explicit via the `commit` command's --allow-base flag.
342
+ const env = buildGitCommitEnv({
343
+ taskId: opts.taskId,
344
+ allowTasks: opts.allowTasks,
345
+ allowBase: false,
346
+ allowPolicy: false,
347
+ allowConfig: false,
348
+ allowHooks: false,
349
+ allowCI: false,
350
+ });
351
+ await execFileAsync("git", ["commit", "-m", message, "-m", body], { cwd: resolved.gitRoot, env });
352
+ const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H%x00%s"], {
245
353
  cwd: resolved.gitRoot,
246
354
  });
247
- const trimmed = stdout.trim();
248
- const [hash, subject] = trimmed.split(":", 2);
355
+ const { hash, subject } = parseGitLogHashSubject(stdout);
249
356
  if (!opts.quiet) {
250
357
  process.stdout.write(`${successMessage("committed", `${hash?.slice(0, 12) ?? ""} ${subject ?? ""}`.trim(), `staged=${staged.join(", ")}`)}\n`);
251
358
  }
252
- return { hash: hash ?? "", message: subject ?? "", staged };
359
+ return { hash, message: subject, staged };
253
360
  }
254
361
  export async function cmdGuardClean(opts) {
255
362
  try {
@@ -334,7 +441,12 @@ export async function cmdCommit(opts) {
334
441
  taskId: opts.taskId,
335
442
  message: opts.message,
336
443
  allow,
444
+ allowBase: opts.allowBase,
337
445
  allowTasks: opts.allowTasks,
446
+ allowPolicy: opts.allowPolicy,
447
+ allowConfig: opts.allowConfig,
448
+ allowHooks: opts.allowHooks,
449
+ allowCI: opts.allowCI,
338
450
  requireClean: opts.requireClean,
339
451
  quiet: opts.quiet,
340
452
  });
@@ -342,19 +454,21 @@ export async function cmdCommit(opts) {
342
454
  cwd: opts.cwd,
343
455
  rootOverride: opts.rootOverride ?? null,
344
456
  });
345
- const env = {
346
- ...process.env,
347
- AGENTPLANE_TASK_ID: opts.taskId,
348
- AGENTPLANE_ALLOW_TASKS: opts.allowTasks ? "1" : "0",
349
- AGENTPLANE_ALLOW_BASE: opts.allowBase ? "1" : "0",
350
- };
457
+ const env = buildGitCommitEnv({
458
+ taskId: opts.taskId,
459
+ allowTasks: opts.allowTasks,
460
+ allowBase: opts.allowBase,
461
+ allowPolicy: opts.allowPolicy,
462
+ allowConfig: opts.allowConfig,
463
+ allowHooks: opts.allowHooks,
464
+ allowCI: opts.allowCI,
465
+ });
351
466
  await execFileAsync("git", ["commit", "-m", opts.message], { cwd: resolved.gitRoot, env });
352
467
  if (!opts.quiet) {
353
- const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H:%s"], {
468
+ const { stdout } = await execFileAsync("git", ["log", "-1", "--pretty=%H%x00%s"], {
354
469
  cwd: resolved.gitRoot,
355
470
  });
356
- const trimmed = stdout.trim();
357
- const [hash, subject] = trimmed.split(":", 2);
471
+ const { hash, subject } = parseGitLogHashSubject(stdout);
358
472
  process.stdout.write(`${successMessage("committed", `${hash?.slice(0, 12) ?? ""} ${subject ?? ""}`.trim())}\n`);
359
473
  }
360
474
  return 0;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/hooks/index.ts"],"names":[],"mappings":"AAeA,eAAO,MAAM,UAAU,mDAAoD,CAAC;AAqG5E,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmClB;AAED,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BlB;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2HlB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/hooks/index.ts"],"names":[],"mappings":"AAwBA,eAAO,MAAM,UAAU,mDAAoD,CAAC;AAgG5E,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmClB;AAED,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BlB;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2IlB"}
@@ -1,11 +1,11 @@
1
1
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { getStagedFiles, loadConfig, resolveBaseBranch, resolveProject } from "@agentplaneorg/core";
4
- import { loadTaskBackend } from "../../backends/task-backend.js";
3
+ import { getStagedFiles, loadConfig, resolveBaseBranch, resolveProject, validateCommitSubject, } from "@agentplaneorg/core";
5
4
  import { mapBackendError, mapCoreError } from "../../cli/error-map.js";
6
5
  import { fileExists } from "../../cli/fs-utils.js";
7
6
  import { infoMessage, successMessage } from "../../cli/output.js";
8
7
  import { CliError } from "../../shared/errors.js";
8
+ import { getProtectedPathOverride, protectedPathKindForFile, } from "../../shared/protected-paths.js";
9
9
  import { gitCurrentBranch, gitRevParse } from "../shared/git-ops.js";
10
10
  import { isPathWithin } from "../shared/path.js";
11
11
  const HOOK_MARKER = "agentplane-hook";
@@ -96,10 +96,6 @@ function readCommitSubject(message) {
96
96
  }
97
97
  return "";
98
98
  }
99
- function subjectHasSuffix(subject, suffixes) {
100
- const lowered = subject.toLowerCase();
101
- return suffixes.some((suffix) => suffix && lowered.includes(suffix.toLowerCase()));
102
- }
103
99
  export async function cmdHooksInstall(opts) {
104
100
  try {
105
101
  const resolved = await resolveProject({
@@ -187,39 +183,32 @@ export async function cmdHooksRun(opts) {
187
183
  message: "Commit message subject is empty",
188
184
  });
189
185
  }
186
+ const resolved = await resolveProject({
187
+ cwd: opts.cwd,
188
+ rootOverride: opts.rootOverride ?? null,
189
+ });
190
+ const loaded = await loadConfig(resolved.agentplaneDir);
190
191
  const taskId = (process.env.AGENTPLANE_TASK_ID ?? "").trim();
191
192
  if (taskId) {
192
- const suffix = taskId.split("-").at(-1) ?? "";
193
- if (!subject.includes(taskId) && (suffix.length === 0 || !subject.includes(suffix))) {
193
+ const policy = validateCommitSubject({
194
+ subject,
195
+ taskId,
196
+ genericTokens: loaded.config.commit.generic_tokens,
197
+ });
198
+ if (!policy.ok) {
194
199
  throw new CliError({
195
200
  exitCode: 5,
196
201
  code: "E_GIT",
197
- message: "Commit subject must include task id or suffix",
202
+ message: policy.errors.join("\n"),
198
203
  });
199
204
  }
200
205
  return 0;
201
206
  }
202
- const { backend } = await loadTaskBackend({
203
- cwd: opts.cwd,
204
- rootOverride: opts.rootOverride ?? null,
207
+ throw new CliError({
208
+ exitCode: 5,
209
+ code: "E_GIT",
210
+ message: "AGENTPLANE_TASK_ID is required (use `agentplane commit ...`)",
205
211
  });
206
- const tasks = await backend.listTasks();
207
- const suffixes = tasks.map((task) => task.id.split("-").at(-1) ?? "").filter(Boolean);
208
- if (suffixes.length === 0) {
209
- throw new CliError({
210
- exitCode: 5,
211
- code: "E_GIT",
212
- message: "No task IDs available to validate commit subject",
213
- });
214
- }
215
- if (!subjectHasSuffix(subject, suffixes)) {
216
- throw new CliError({
217
- exitCode: 5,
218
- code: "E_GIT",
219
- message: "Commit subject must mention a task suffix",
220
- });
221
- }
222
- return 0;
223
212
  }
224
213
  if (opts.hook === "pre-commit") {
225
214
  const staged = await getStagedFiles({
@@ -230,6 +219,10 @@ export async function cmdHooksRun(opts) {
230
219
  return 0;
231
220
  const allowTasks = (process.env.AGENTPLANE_ALLOW_TASKS ?? "").trim() === "1";
232
221
  const allowBase = (process.env.AGENTPLANE_ALLOW_BASE ?? "").trim() === "1";
222
+ const allowPolicy = (process.env.AGENTPLANE_ALLOW_POLICY ?? "").trim() === "1";
223
+ const allowConfig = (process.env.AGENTPLANE_ALLOW_CONFIG ?? "").trim() === "1";
224
+ const allowHooks = (process.env.AGENTPLANE_ALLOW_HOOKS ?? "").trim() === "1";
225
+ const allowCI = (process.env.AGENTPLANE_ALLOW_CI ?? "").trim() === "1";
233
226
  const resolved = await resolveProject({
234
227
  cwd: opts.cwd,
235
228
  rootOverride: opts.rootOverride ?? null,
@@ -245,6 +238,23 @@ export async function cmdHooksRun(opts) {
245
238
  message: `${tasksPath} is protected by agentplane hooks (set AGENTPLANE_ALLOW_TASKS=1 to override)`,
246
239
  });
247
240
  }
241
+ for (const filePath of staged) {
242
+ const kind = protectedPathKindForFile({ filePath, tasksPath });
243
+ if (!kind || kind === "tasks")
244
+ continue;
245
+ const override = getProtectedPathOverride(kind);
246
+ const allowed = (kind === "policy" && allowPolicy) ||
247
+ (kind === "config" && allowConfig) ||
248
+ (kind === "hooks" && allowHooks) ||
249
+ (kind === "ci" && allowCI);
250
+ if (!allowed) {
251
+ throw new CliError({
252
+ exitCode: 5,
253
+ code: "E_GIT",
254
+ message: `${filePath} is protected by agentplane hooks (set ${override.envVar}=1 to override)`,
255
+ });
256
+ }
257
+ }
248
258
  if (loaded.config.workflow_mode === "branch_pr") {
249
259
  const baseBranch = await resolveBaseBranch({
250
260
  cwd: opts.cwd,
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/pr/index.ts"],"names":[],"mappings":"AAmCA,eAAO,MAAM,aAAa,wEAAwE,CAAC;AACnG,eAAO,MAAM,qBAAqB,0DAA0D,CAAC;AAC7F,eAAO,MAAM,eAAe,0CAA0C,CAAC;AACvE,eAAO,MAAM,uBAAuB,6CAA6C,CAAC;AAClF,eAAO,MAAM,cAAc,yCAAyC,CAAC;AACrE,eAAO,MAAM,sBAAsB,4CAA4C,CAAC;AAChF,eAAO,MAAM,aAAa,oEAAoE,CAAC;AAC/F,eAAO,MAAM,qBAAqB,4EACuC,CAAC;AAC1E,eAAO,MAAM,eAAe,wJAC2H,CAAC;AACxJ,eAAO,MAAM,uBAAuB,0DAA0D,CAAC;AA2H/F,wBAAsB,SAAS,CAAC,IAAI,EAAE;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoElB;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyFlB;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgElB;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,MAAM,CAAC,CAwClB;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAigBlB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/pr/index.ts"],"names":[],"mappings":"AAwCA,eAAO,MAAM,aAAa,wEAAwE,CAAC;AACnG,eAAO,MAAM,qBAAqB,0DAA0D,CAAC;AAC7F,eAAO,MAAM,eAAe,0CAA0C,CAAC;AACvE,eAAO,MAAM,uBAAuB,6CAA6C,CAAC;AAClF,eAAO,MAAM,cAAc,yCAAyC,CAAC;AACrE,eAAO,MAAM,sBAAsB,4CAA4C,CAAC;AAChF,eAAO,MAAM,aAAa,oEAAoE,CAAC;AAC/F,eAAO,MAAM,qBAAqB,4EACuC,CAAC;AAC1E,eAAO,MAAM,eAAe,wJAC2H,CAAC;AACxJ,eAAO,MAAM,uBAAuB,0DAA0D,CAAC;AA2H/F,wBAAsB,SAAS,CAAC,IAAI,EAAE;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoElB;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyFlB;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgElB;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,MAAM,CAAC,CAwClB;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAogBlB"}
@@ -1,6 +1,6 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { loadConfig, resolveBaseBranch, resolveProject, } from "@agentplaneorg/core";
3
+ import { atomicWriteFile, loadConfig, resolveBaseBranch, resolveProject, } from "@agentplaneorg/core";
4
4
  import { mapBackendError, mapCoreError } from "../../cli/error-map.js";
5
5
  import { fileExists } from "../../cli/fs-utils.js";
6
6
  import { successMessage, unknownEntityMessage, usageMessage, workflowModeMessage, } from "../../cli/output.js";
@@ -14,6 +14,7 @@ import { appendVerifyLog, extractLastVerifiedSha, parsePrMeta, runShellCommand,
14
14
  import { isPathWithin } from "../shared/path.js";
15
15
  import { loadBackendTask } from "../shared/task-backend.js";
16
16
  import { cmdFinish } from "../task/index.js";
17
+ import { ensurePlanApprovedIfRequired, ensureVerificationSatisfiedIfRequired, } from "../task/shared.js";
17
18
  export const PR_OPEN_USAGE = "Usage: agentplane pr open <task-id> --author <id> [--branch <name>]";
18
19
  export const PR_OPEN_USAGE_EXAMPLE = "agentplane pr open 202602030608-F1Q8AB --author CODER";
19
20
  export const PR_UPDATE_USAGE = "Usage: agentplane pr update <task-id>";
@@ -167,14 +168,14 @@ export async function cmdPrOpen(opts) {
167
168
  last_verified_at: meta?.last_verified_at ?? null,
168
169
  verify: meta?.verify ?? { status: "skipped" },
169
170
  };
170
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
171
+ await atomicWriteFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
171
172
  if (!(await fileExists(diffstatPath)))
172
- await writeFile(diffstatPath, "", "utf8");
173
+ await atomicWriteFile(diffstatPath, "", "utf8");
173
174
  if (!(await fileExists(verifyLogPath)))
174
- await writeFile(verifyLogPath, "", "utf8");
175
+ await atomicWriteFile(verifyLogPath, "", "utf8");
175
176
  if (!(await fileExists(reviewPath))) {
176
177
  const review = renderPrReviewTemplate({ author, createdAt, branch });
177
- await writeFile(reviewPath, review, "utf8");
178
+ await atomicWriteFile(reviewPath, review, "utf8");
178
179
  }
179
180
  process.stdout.write(`${successMessage("pr open", path.relative(resolved.gitRoot, prDir))}\n`);
180
181
  return 0;
@@ -228,7 +229,7 @@ export async function cmdPrUpdate(opts) {
228
229
  const branch = await gitCurrentBranch(resolved.gitRoot);
229
230
  const { stdout: diffStatOut } = await execFileAsync("git", ["diff", "--stat", `${baseBranch}...HEAD`], { cwd: resolved.gitRoot, env: gitEnv() });
230
231
  const diffstat = diffStatOut.trimEnd();
231
- await writeFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
232
+ await atomicWriteFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
232
233
  const { stdout: headOut } = await execFileAsync("git", ["rev-parse", "HEAD"], {
233
234
  cwd: resolved.gitRoot,
234
235
  env: gitEnv(),
@@ -245,7 +246,7 @@ export async function cmdPrUpdate(opts) {
245
246
  ];
246
247
  const reviewText = await readFile(reviewPath, "utf8");
247
248
  const nextReview = updateAutoSummaryBlock(reviewText, summaryLines.join("\n"));
248
- await writeFile(reviewPath, nextReview, "utf8");
249
+ await atomicWriteFile(reviewPath, nextReview, "utf8");
249
250
  const rawMeta = await readFile(metaPath, "utf8");
250
251
  const meta = parsePrMeta(rawMeta, opts.taskId);
251
252
  const nextMeta = {
@@ -255,7 +256,7 @@ export async function cmdPrUpdate(opts) {
255
256
  last_verified_sha: meta.last_verified_sha ?? null,
256
257
  last_verified_at: meta.last_verified_at ?? null,
257
258
  };
258
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
259
+ await atomicWriteFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
259
260
  process.stdout.write(`${successMessage("pr update", path.relative(resolved.gitRoot, prDir))}\n`);
260
261
  return 0;
261
262
  }
@@ -359,7 +360,7 @@ export async function cmdPrNote(opts) {
359
360
  }
360
361
  const review = await readFile(reviewPath, "utf8");
361
362
  const updated = appendHandoffNote(review, `${author}: ${body}`);
362
- await writeFile(reviewPath, updated, "utf8");
363
+ await atomicWriteFile(reviewPath, updated, "utf8");
363
364
  process.stdout.write(`${successMessage("pr note", opts.taskId)}\n`);
364
365
  return 0;
365
366
  }
@@ -390,6 +391,8 @@ export async function cmdIntegrate(opts) {
390
391
  message: workflowModeMessage(loaded.config.workflow_mode, "branch_pr"),
391
392
  });
392
393
  }
394
+ ensurePlanApprovedIfRequired(task, loaded.config);
395
+ ensureVerificationSatisfiedIfRequired(task, loaded.config);
393
396
  await ensureGitClean({ cwd: opts.cwd, rootOverride: opts.rootOverride });
394
397
  if (opts.base?.trim().length === 0) {
395
398
  throw new CliError({
@@ -793,9 +796,9 @@ export async function cmdIntegrate(opts) {
793
796
  ? { ...mergedMeta.verify, status: "pass" }
794
797
  : { status: "pass", command: verifyCommands.join(" && ") };
795
798
  }
796
- await writeFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
799
+ await atomicWriteFile(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`, "utf8");
797
800
  const diffstat = await gitDiffStat(resolved.gitRoot, baseShaBeforeMerge, branch);
798
- await writeFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
801
+ await atomicWriteFile(diffstatPath, diffstat ? `${diffstat}\n` : "", "utf8");
799
802
  const verifyDesc = verifyCommands.length === 0
800
803
  ? "skipped(no commands)"
801
804
  : shouldRunVerify