@aperant/framework 0.6.4 → 0.6.6

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 (241) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +54 -234
  3. package/agents/apt-improver.md +99 -0
  4. package/agents/apt-planner.md +115 -10
  5. package/dist/__test-helpers/run-cmd.d.mts +4 -2
  6. package/dist/__test-helpers/run-cmd.d.mts.map +1 -1
  7. package/dist/__test-helpers/run-cmd.mjs +56 -15
  8. package/dist/__test-helpers/run-cmd.mjs.map +1 -1
  9. package/dist/cli/ci-watch/lock.d.mts +53 -0
  10. package/dist/cli/ci-watch/lock.d.mts.map +1 -0
  11. package/dist/cli/ci-watch/lock.mjs +172 -0
  12. package/dist/cli/ci-watch/lock.mjs.map +1 -0
  13. package/dist/cli/ci-watch/state.d.mts +36 -0
  14. package/dist/cli/ci-watch/state.d.mts.map +1 -0
  15. package/dist/cli/ci-watch/state.mjs +103 -0
  16. package/dist/cli/ci-watch/state.mjs.map +1 -0
  17. package/dist/cli/ci-watch/stop-matrix.d.mts +58 -0
  18. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -0
  19. package/dist/cli/ci-watch/stop-matrix.mjs +164 -0
  20. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -0
  21. package/dist/cli/cli-wrappers/ci-watch.d.mts +2 -0
  22. package/dist/cli/cli-wrappers/ci-watch.d.mts.map +1 -0
  23. package/dist/cli/cli-wrappers/ci-watch.mjs +9 -0
  24. package/dist/cli/cli-wrappers/ci-watch.mjs.map +1 -0
  25. package/dist/cli/commands/adr.d.mts +5 -0
  26. package/dist/cli/commands/adr.d.mts.map +1 -0
  27. package/dist/cli/commands/adr.mjs +228 -0
  28. package/dist/cli/commands/adr.mjs.map +1 -0
  29. package/dist/cli/commands/ci-watch.d.mts +7 -0
  30. package/dist/cli/commands/ci-watch.d.mts.map +1 -0
  31. package/dist/cli/commands/ci-watch.mjs +465 -0
  32. package/dist/cli/commands/ci-watch.mjs.map +1 -0
  33. package/dist/cli/commands/commit.d.mts.map +1 -1
  34. package/dist/cli/commands/commit.mjs +25 -8
  35. package/dist/cli/commands/commit.mjs.map +1 -1
  36. package/dist/cli/commands/context.d.mts +7 -0
  37. package/dist/cli/commands/context.d.mts.map +1 -0
  38. package/dist/cli/commands/context.mjs +224 -0
  39. package/dist/cli/commands/context.mjs.map +1 -0
  40. package/dist/cli/commands/event.d.mts.map +1 -1
  41. package/dist/cli/commands/event.mjs +125 -24
  42. package/dist/cli/commands/event.mjs.map +1 -1
  43. package/dist/cli/commands/host-detect.d.mts +1 -1
  44. package/dist/cli/commands/host-detect.d.mts.map +1 -1
  45. package/dist/cli/commands/host-detect.mjs +30 -3
  46. package/dist/cli/commands/host-detect.mjs.map +1 -1
  47. package/dist/cli/commands/modes.d.mts +13 -0
  48. package/dist/cli/commands/modes.d.mts.map +1 -0
  49. package/dist/cli/commands/modes.mjs +221 -0
  50. package/dist/cli/commands/modes.mjs.map +1 -0
  51. package/dist/cli/commands/route.mjs +1 -1
  52. package/dist/cli/commands/route.mjs.map +1 -1
  53. package/dist/cli/commands/task.d.mts.map +1 -1
  54. package/dist/cli/commands/task.mjs +393 -145
  55. package/dist/cli/commands/task.mjs.map +1 -1
  56. package/dist/cli/commands/tokens.d.mts.map +1 -1
  57. package/dist/cli/commands/tokens.mjs +150 -6
  58. package/dist/cli/commands/tokens.mjs.map +1 -1
  59. package/dist/cli/commands/triage.d.mts +8 -0
  60. package/dist/cli/commands/triage.d.mts.map +1 -0
  61. package/dist/cli/commands/triage.mjs +263 -0
  62. package/dist/cli/commands/triage.mjs.map +1 -0
  63. package/dist/cli/config/post-merge-sweep.d.mts +18 -0
  64. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  65. package/dist/cli/config/post-merge-sweep.mjs +48 -3
  66. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  67. package/dist/cli/consistency/registry.d.mts +11 -0
  68. package/dist/cli/consistency/registry.d.mts.map +1 -1
  69. package/dist/cli/consistency/registry.mjs +13 -0
  70. package/dist/cli/consistency/registry.mjs.map +1 -1
  71. package/dist/cli/coordination/agent-identity.d.mts +9 -0
  72. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  73. package/dist/cli/coordination/agent-identity.mjs +11 -0
  74. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  75. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
  76. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
  77. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
  78. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
  79. package/dist/cli/coordination/event-log.d.mts +27 -0
  80. package/dist/cli/coordination/event-log.d.mts.map +1 -1
  81. package/dist/cli/coordination/event-log.mjs +72 -2
  82. package/dist/cli/coordination/event-log.mjs.map +1 -1
  83. package/dist/cli/coordination/event-schema.d.mts +16 -0
  84. package/dist/cli/coordination/event-schema.d.mts.map +1 -0
  85. package/dist/cli/coordination/event-schema.mjs +161 -0
  86. package/dist/cli/coordination/event-schema.mjs.map +1 -0
  87. package/dist/cli/coordination/store.d.mts +6 -0
  88. package/dist/cli/coordination/store.d.mts.map +1 -1
  89. package/dist/cli/coordination/store.mjs +14 -0
  90. package/dist/cli/coordination/store.mjs.map +1 -1
  91. package/dist/cli/dispatch.d.mts.map +1 -1
  92. package/dist/cli/dispatch.mjs +14 -1
  93. package/dist/cli/dispatch.mjs.map +1 -1
  94. package/dist/cli/gate/registry.d.mts +11 -0
  95. package/dist/cli/gate/registry.d.mts.map +1 -1
  96. package/dist/cli/gate/registry.mjs +13 -0
  97. package/dist/cli/gate/registry.mjs.map +1 -1
  98. package/dist/cli/help.d.mts.map +1 -1
  99. package/dist/cli/help.mjs +3 -2
  100. package/dist/cli/help.mjs.map +1 -1
  101. package/dist/cli/host/detect.d.mts +1 -0
  102. package/dist/cli/host/detect.d.mts.map +1 -1
  103. package/dist/cli/host/detect.mjs +5 -0
  104. package/dist/cli/host/detect.mjs.map +1 -1
  105. package/dist/cli/roadmap/conductor-view.d.mts +13 -0
  106. package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
  107. package/dist/cli/roadmap/conductor-view.mjs +31 -0
  108. package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
  109. package/dist/cli/route/envelope.d.mts +68 -4
  110. package/dist/cli/route/envelope.d.mts.map +1 -1
  111. package/dist/cli/route/envelope.mjs +140 -103
  112. package/dist/cli/route/envelope.mjs.map +1 -1
  113. package/dist/cli/route/skill-discover.d.mts +9 -0
  114. package/dist/cli/route/skill-discover.d.mts.map +1 -1
  115. package/dist/cli/route/skill-discover.mjs +13 -1
  116. package/dist/cli/route/skill-discover.mjs.map +1 -1
  117. package/dist/cli/skill-author/contract.d.mts +1 -0
  118. package/dist/cli/skill-author/contract.d.mts.map +1 -1
  119. package/dist/cli/skill-author/contract.mjs +7 -0
  120. package/dist/cli/skill-author/contract.mjs.map +1 -1
  121. package/dist/cli/task/ids.d.mts +7 -4
  122. package/dist/cli/task/ids.d.mts.map +1 -1
  123. package/dist/cli/task/ids.mjs +11 -10
  124. package/dist/cli/task/ids.mjs.map +1 -1
  125. package/dist/cli/task/worktree-cleanup.d.mts +9 -1
  126. package/dist/cli/task/worktree-cleanup.d.mts.map +1 -1
  127. package/dist/cli/task/worktree-cleanup.mjs +190 -9
  128. package/dist/cli/task/worktree-cleanup.mjs.map +1 -1
  129. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  130. package/dist/plugin/agents/apt-planner.md +1 -1
  131. package/dist/plugin/skills/apt/SKILL.md +111 -5
  132. package/dist/plugin/skills/apt-author-skill/SKILL.md +11 -0
  133. package/dist/plugin/skills/apt-bootstrap/SKILL.md +1 -0
  134. package/dist/plugin/skills/apt-classify/SKILL.md +1 -0
  135. package/dist/plugin/skills/apt-close-task/SKILL.md +1 -0
  136. package/dist/plugin/skills/apt-create-docs/SKILL.md +1 -0
  137. package/dist/plugin/skills/apt-debug/SKILL.md +2 -0
  138. package/dist/plugin/skills/apt-design/SKILL.md +2 -0
  139. package/dist/plugin/skills/apt-discuss/SKILL.md +2 -0
  140. package/dist/plugin/skills/apt-docs/SKILL.md +2 -0
  141. package/dist/plugin/skills/apt-execute/SKILL.md +1 -0
  142. package/dist/plugin/skills/apt-mockup/SKILL.md +2 -0
  143. package/dist/plugin/skills/apt-pause/SKILL.md +1 -0
  144. package/dist/plugin/skills/apt-personas/SKILL.md +1 -0
  145. package/dist/plugin/skills/apt-plan/SKILL.md +2 -0
  146. package/dist/plugin/skills/apt-pr-review/SKILL.md +1 -0
  147. package/dist/plugin/skills/apt-quick/SKILL.md +2 -0
  148. package/dist/plugin/skills/apt-resume/SKILL.md +1 -0
  149. package/dist/plugin/skills/apt-review/SKILL.md +1 -0
  150. package/dist/plugin/skills/apt-roadmap/SKILL.md +1 -0
  151. package/dist/plugin/skills/apt-roundtable/SKILL.md +2 -0
  152. package/dist/plugin/skills/apt-run/SKILL.md +1 -0
  153. package/dist/plugin/skills/apt-scan/SKILL.md +1 -0
  154. package/dist/plugin/skills/apt-setup/SKILL.md +1 -0
  155. package/dist/plugin/skills/apt-ship/SKILL.md +6 -5
  156. package/dist/plugin/skills/apt-stress-test/SKILL.md +1 -0
  157. package/dist/plugin/skills/apt-terminal/SKILL.md +1 -0
  158. package/dist/plugin/skills/apt-update/SKILL.md +5 -0
  159. package/dist/plugin/skills/apt-verify/SKILL.md +1 -0
  160. package/dist/plugin/skills/apt-verify-proof/SKILL.md +1 -0
  161. package/dist/types/config.d.ts +85 -0
  162. package/dist/types/config.d.ts.map +1 -1
  163. package/package.json +9 -1
  164. package/prompts/coder.md +2 -0
  165. package/prompts/conductor-status-check.md +23 -0
  166. package/prompts/conductor-sub-agent.md +57 -0
  167. package/prompts/conductor-system.md +172 -0
  168. package/prompts/planner.md +12 -0
  169. package/prompts/spec_writer.md +9 -0
  170. package/skills/apt/SKILL.md +1 -0
  171. package/skills/apt-close-task/SKILL.md +32 -1
  172. package/skills/apt-debug/SKILL.md +39 -6
  173. package/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  174. package/skills/apt-diagram/SKILL.md +342 -0
  175. package/skills/apt-diagram/appendices/design-discipline.md +97 -0
  176. package/skills/apt-discuss/SKILL.md +25 -0
  177. package/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  178. package/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  179. package/skills/apt-execute/SKILL.md +48 -5
  180. package/skills/apt-execute/appendices/tdd-mode.md +107 -0
  181. package/skills/apt-improve/DEEPENING.md +84 -0
  182. package/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  183. package/skills/apt-improve/LANGUAGE.md +104 -0
  184. package/skills/apt-improve/SKILL.md +141 -0
  185. package/skills/apt-plan/SKILL.md +159 -4
  186. package/skills/apt-plan/adapters/conductor.md +98 -0
  187. package/skills/apt-planner.md +42 -1
  188. package/skills/apt-pr-review/SKILL.md +46 -16
  189. package/skills/apt-prototype/LOGIC.md +109 -0
  190. package/skills/apt-prototype/SKILL.md +143 -0
  191. package/skills/apt-prototype/UI.md +90 -0
  192. package/skills/apt-quick/SKILL.md +30 -0
  193. package/skills/apt-review/SKILL.md +2 -0
  194. package/skills/apt-run/SKILL.md +32 -4
  195. package/skills/apt-setup/SKILL.md +130 -2
  196. package/skills/apt-ship/SKILL.md +63 -1
  197. package/skills/apt-spar/SKILL.md +290 -0
  198. package/skills/apt-triage/AGENT-BRIEF.md +84 -0
  199. package/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  200. package/skills/apt-triage/SKILL.md +169 -0
  201. package/skills/apt-verify/SKILL.md +3 -0
  202. package/skills/apt-verify-proof/SKILL.md +3 -0
  203. package/skills/apt-watch-ci/SKILL.md +163 -0
  204. package/skills/apt-zoom-out/SKILL.md +130 -0
  205. package/src/cli/ci-watch/lock.mjs +178 -0
  206. package/src/cli/ci-watch/state.mjs +103 -0
  207. package/src/cli/ci-watch/stop-matrix.mjs +181 -0
  208. package/src/cli/cli-wrappers/ci-watch.mjs +9 -0
  209. package/src/cli/commands/adr.mjs +243 -0
  210. package/src/cli/commands/ci-watch.mjs +503 -0
  211. package/src/cli/commands/commit.mjs +27 -8
  212. package/src/cli/commands/context.mjs +244 -0
  213. package/src/cli/commands/event.mjs +131 -24
  214. package/src/cli/commands/host-detect.mjs +33 -7
  215. package/src/cli/commands/modes.mjs +216 -0
  216. package/src/cli/commands/route.mjs +1 -1
  217. package/src/cli/commands/task.mjs +430 -170
  218. package/src/cli/commands/tokens.mjs +157 -6
  219. package/src/cli/commands/triage.mjs +286 -0
  220. package/src/cli/config/post-merge-sweep.mjs +49 -3
  221. package/src/cli/consistency/registry.mjs +14 -0
  222. package/src/cli/coordination/agent-identity.mjs +12 -0
  223. package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
  224. package/src/cli/coordination/event-log.mjs +73 -2
  225. package/src/cli/coordination/event-schema.d.ts +13 -0
  226. package/src/cli/coordination/event-schema.mjs +174 -0
  227. package/src/cli/coordination/store.mjs +14 -0
  228. package/src/cli/dispatch.mjs +15 -1
  229. package/src/cli/gate/registry.mjs +14 -0
  230. package/src/cli/help.mjs +3 -2
  231. package/src/cli/host/detect.mjs +5 -0
  232. package/src/cli/roadmap/conductor-view.d.ts +10 -0
  233. package/src/cli/roadmap/conductor-view.mjs +31 -0
  234. package/src/cli/route/envelope.mjs +140 -106
  235. package/src/cli/route/skill-discover.mjs +14 -1
  236. package/src/cli/skill-author/contract.mjs +7 -0
  237. package/src/cli/task/ids.mjs +15 -13
  238. package/src/cli/task/worktree-cleanup.mjs +191 -9
  239. package/templates/adr-format.md +56 -0
  240. package/templates/config.json +4 -0
  241. package/templates/context-format.md +34 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * commands/task.mjs — per-task isolated directories for multi-agent concurrency (C29).
3
3
  *
4
- * task subcommand: create | list | get | close | update | move | complete-subtask | prune
4
+ * task subcommand: create | list | get | close | update | move | complete-subtask | narration-drain | prune
5
5
  *
6
6
  * Layer 1 (post-CLI-layering refactor, subtask 9). cmdTask returns
7
7
  * { envelope, exitCode } — it MUST NOT call process.exit, output(), or fail()
@@ -13,7 +13,8 @@
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
- import { getTaskIsolationConfig } from '../config/load.mjs';
16
+ import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs';
17
+ import { findMostRecentEventForTask } from '../coordination/event-log.mjs';
17
18
  import { createCoordinationStore } from '../coordination/store.mjs';
18
19
  import { getGitIdentity } from '../git/identity.mjs';
19
20
  import { detectWorktree } from '../git/worktree-detect.mjs';
@@ -34,11 +35,12 @@ import { parseDuration } from '../util/duration.mjs';
34
35
  import { withFileLock } from '../util/fs-lock.mjs';
35
36
  import { atomicWriteJson } from '../util/io.mjs';
36
37
  import { err, exitWith, ok } from '../util/result.mjs';
38
+ import { cmdCiWatch } from './ci-watch.mjs';
37
39
  export function cmdTask(subcommand, projectDir, extraArgs) {
38
40
  if (!subcommand)
39
- return err('Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|prune> <project-dir> [flags]');
41
+ return err('Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]');
40
42
  if (!projectDir)
41
- return err('Usage: apt-tools task ' + subcommand + ' <project-dir> [flags]');
43
+ return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`);
42
44
  const targetDir = resolve(projectDir);
43
45
  const statePath = join(targetDir, '.aperant', 'state.json');
44
46
  const flags = parseFlags(extraArgs);
@@ -115,151 +117,216 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
115
117
  // Load config for date format preference
116
118
  const configPath = join(targetDir, '.aperant', 'config.json');
117
119
  const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {};
118
- const taskId = flags.get('id') || generateTaskId(description, config);
119
- // Create task directory
120
- const taskDir = getTaskDir(targetDir, taskId);
121
- mkdirSync(taskDir, { recursive: true });
122
- for (const subdir of ['events', 'locks']) {
123
- mkdirSync(join(taskDir, subdir), { recursive: true });
120
+ // User-supplied --id bypasses the collision guard (caller owns the
121
+ // uniqueness contract). Generated ids run through a numeric-counter
122
+ // disambiguator that appends -2, -3, … when the candidate already
123
+ // exists in state or on disk. Bounded at 100 iterations as a
124
+ // defensive ceiling; same-day same-slug collisions are vanishingly
125
+ // rare so the loop should almost always terminate on the first try.
126
+ //
127
+ // BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
128
+ // state.active_tasks write MUST all happen inside a single
129
+ // withFileLock critical section. Two concurrent task create calls
130
+ // in the multi-agent environment can otherwise both compute the
131
+ // same candidate against the same pre-lock snapshot of state and
132
+ // race into a duplicate-id write that silently overwrites the
133
+ // first task's record.
134
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
135
+ const explicitIdRaw = flags.get('id');
136
+ if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
137
+ return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
138
+ command: 'task-create',
139
+ });
124
140
  }
125
- // Register in state.json
126
- if (existsSync(statePath)) {
127
- withFileLock(statePath, () => {
128
- const state = JSON.parse(readFileSync(statePath, 'utf-8'));
129
- // Migrate: if old format (active_task singular), convert
130
- if (state.active_task !== undefined && state.active_tasks === undefined) {
131
- state.active_tasks = {};
132
- if (state.active_task) {
133
- const oldId = state.active_task.id || 'legacy-task';
134
- state.active_tasks[oldId] = state.active_task;
141
+ // Secondary guard: ensure the resolved task directory stays inside the
142
+ // tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
143
+ // but allows `.` and `..` as valid characters. A lone `..` id would
144
+ // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
145
+ if (explicitIdRaw) {
146
+ const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'));
147
+ const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw));
148
+ if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
149
+ return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
150
+ command: 'task-create',
151
+ });
152
+ }
153
+ }
154
+ let taskId;
155
+ let taskDir;
156
+ let collisionOverflowErr = null;
157
+ withFileLock(statePath, () => {
158
+ // Read state under the lock so the collision scan, taskDir
159
+ // mkdir, and state.active_tasks write all observe the same
160
+ // snapshot. withFileLock seeds an empty file if statePath
161
+ // doesn't exist, so the JSON.parse fallback handles the
162
+ // first-ever task create cleanly.
163
+ const raw = readFileSync(statePath, 'utf-8').trim();
164
+ const state = raw ? JSON.parse(raw) : {};
165
+ // Migrate: if old format (active_task singular), convert
166
+ if (state.active_task !== undefined && state.active_tasks === undefined) {
167
+ state.active_tasks = {};
168
+ if (state.active_task) {
169
+ const oldId = state.active_task.id || 'legacy-task';
170
+ state.active_tasks[oldId] = state.active_task;
171
+ }
172
+ delete state.active_task;
173
+ }
174
+ if (!state.active_tasks)
175
+ state.active_tasks = {};
176
+ // Resolve taskId against the locked-in state snapshot.
177
+ if (explicitIdRaw) {
178
+ taskId = explicitIdRaw;
179
+ }
180
+ else {
181
+ const base = generateTaskId(description, config);
182
+ const activeIds = Object.keys(state.active_tasks);
183
+ const completedIds = Array.isArray(state.completed_tasks)
184
+ ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
185
+ : [];
186
+ const known = new Set([...activeIds, ...completedIds]);
187
+ const isTaken = (candidate) => known.has(candidate) || existsSync(getTaskDir(targetDir, candidate));
188
+ let candidate = base;
189
+ let counter = 2;
190
+ while (isTaken(candidate)) {
191
+ if (counter > 100) {
192
+ collisionOverflowErr = err(`task create: collision counter exceeded 100 for base id "${base}" — manual --id required`);
193
+ return;
135
194
  }
136
- delete state.active_task;
195
+ candidate = `${base}-${counter}`;
196
+ counter++;
137
197
  }
138
- if (!state.active_tasks)
139
- state.active_tasks = {};
140
- // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
141
- // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
142
- // R11 resolve the outputs allowlist now so it is pinned at
143
- // task creation time. If the planner edits the task record
144
- // later (e.g. narrows the allowlist), it overwrites this.
145
- const outputs = resolveDefaultOutputs({
146
- id: taskId,
147
- scope,
148
- milestone_id: milestoneId,
149
- phase_id: phaseIdForRoadmap,
150
- });
151
- state.active_tasks[taskId] = {
152
- id: taskId,
153
- description,
154
- track,
155
- autonomy,
156
- // C21 — execution_mode is omitted entirely when unset so legacy
157
- // readers (apt-plan SKILL.md 9a) continue to see `undefined`
158
- // rather than `null` and fall through to the autonomy heuristic.
159
- ...(executionMode ? { execution_mode: executionMode } : {}),
160
- scope,
161
- milestone_id: milestoneId,
162
- phase_id: phaseIdForRoadmap,
163
- roadmap_ref_url: roadmapRefUrl,
164
- depends_on: dependsOn,
165
- outputs,
166
- lifecycle_phase: 'planning',
167
- history: [],
168
- created_at: new Date().toISOString(),
169
- progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
170
- };
171
- // ── Task-level worktree hook ──────────────────────────────
172
- // When task_isolation.worktree_per_task is enabled AND we're
173
- // not already inside a worktree AND the host supports them,
174
- // fork a branch + worktree and record its location on the
175
- // task record. Any failure is downgraded to a warning so
176
- // task-create never aborts.
177
- const isolation = getTaskIsolationConfig(targetDir);
178
- const host = detectHost();
179
- if (isolation.worktree_per_task === true &&
180
- host.capabilities.worktrees === true &&
181
- !detectWorktree(targetDir).is_worktree) {
182
- try {
183
- const wtArgs = ['--task', taskId, '--scope', scope];
184
- if (isolation.push_on_create)
185
- wtArgs.push('--push');
186
- if (isolation.from_branch && isolation.from_branch !== 'current') {
187
- wtArgs.push('--from', isolation.from_branch);
198
+ taskId = candidate;
199
+ }
200
+ // Create task directory under the lock so a concurrent
201
+ // run can't observe (and then re-pick) a half-created id.
202
+ taskDir = getTaskDir(targetDir, taskId);
203
+ mkdirSync(taskDir, { recursive: true });
204
+ for (const subdir of ['events', 'locks']) {
205
+ mkdirSync(join(taskDir, subdir), { recursive: true });
206
+ }
207
+ // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
208
+ // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
209
+ // R11 — resolve the outputs allowlist now so it is pinned at
210
+ // task creation time. If the planner edits the task record
211
+ // later (e.g. narrows the allowlist), it overwrites this.
212
+ const outputs = resolveDefaultOutputs({
213
+ id: taskId,
214
+ scope,
215
+ milestone_id: milestoneId,
216
+ phase_id: phaseIdForRoadmap,
217
+ });
218
+ state.active_tasks[taskId] = {
219
+ id: taskId,
220
+ description,
221
+ track,
222
+ autonomy,
223
+ // C21 — execution_mode is omitted entirely when unset so legacy
224
+ // readers (apt-plan SKILL.md 9a) continue to see `undefined`
225
+ // rather than `null` and fall through to the autonomy heuristic.
226
+ ...(executionMode ? { execution_mode: executionMode } : {}),
227
+ scope,
228
+ milestone_id: milestoneId,
229
+ phase_id: phaseIdForRoadmap,
230
+ roadmap_ref_url: roadmapRefUrl,
231
+ depends_on: dependsOn,
232
+ outputs,
233
+ lifecycle_phase: 'planning',
234
+ history: [],
235
+ created_at: new Date().toISOString(),
236
+ progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
237
+ };
238
+ // ── Task-level worktree hook ──────────────────────────────
239
+ // When task_isolation.worktree_per_task is enabled AND we're
240
+ // not already inside a worktree AND the host supports them,
241
+ // fork a branch + worktree and record its location on the
242
+ // task record. Any failure is downgraded to a warning so
243
+ // task-create never aborts.
244
+ const isolation = getTaskIsolationConfig(targetDir);
245
+ const host = detectHost();
246
+ if (isolation.worktree_per_task === true &&
247
+ host.capabilities.worktrees === true &&
248
+ !detectWorktree(targetDir).is_worktree) {
249
+ try {
250
+ const wtArgs = ['--task', taskId, '--scope', scope];
251
+ if (isolation.push_on_create)
252
+ wtArgs.push('--push');
253
+ if (isolation.from_branch && isolation.from_branch !== 'current') {
254
+ wtArgs.push('--from', isolation.from_branch);
255
+ }
256
+ // Invoke inline so we capture the structured payload
257
+ // without shelling back out. We cheat by calling the
258
+ // worktree creator directly as a subprocess to keep
259
+ // its output discipline intact, then parse its JSON.
260
+ //
261
+ // D-03 — single source of truth for the apt-tools binary
262
+ // path (replaces the legacy process.argv[1] heuristic).
263
+ const aptToolsBin = resolveAptToolsBin(targetDir);
264
+ const createRes = spawnSync(process.execPath, [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs], { encoding: 'utf-8' });
265
+ if (createRes.status === 0 && createRes.stdout) {
266
+ const wtOut = JSON.parse(createRes.stdout);
267
+ state.active_tasks[taskId].worktree_path = wtOut.worktree_path;
268
+ state.active_tasks[taskId].branch = wtOut.branch;
269
+ state.active_tasks[taskId].base_branch = wtOut.base_branch;
270
+ state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking;
271
+ if (wtOut.scope) {
272
+ state.active_tasks[taskId].worktree_scope = wtOut.scope;
188
273
  }
189
- // Invoke inline so we capture the structured payload
190
- // without shelling back out. We cheat by calling the
191
- // worktree creator directly as a subprocess to keep
192
- // its output discipline intact, then parse its JSON.
193
- //
194
- // D-03 — single source of truth for the apt-tools binary
195
- // path (replaces the legacy process.argv[1] heuristic).
196
- const aptToolsBin = resolveAptToolsBin(targetDir);
197
- const createRes = spawnSync(process.execPath, [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs], { encoding: 'utf-8' });
198
- if (createRes.status === 0 && createRes.stdout) {
199
- const wtOut = JSON.parse(createRes.stdout);
200
- state.active_tasks[taskId].worktree_path = wtOut.worktree_path;
201
- state.active_tasks[taskId].branch = wtOut.branch;
202
- state.active_tasks[taskId].base_branch = wtOut.base_branch;
203
- state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking;
204
- if (wtOut.scope) {
205
- state.active_tasks[taskId].worktree_scope = wtOut.scope;
206
- }
207
- if (wtOut.push_warning) {
208
- state.active_tasks[taskId].push_warning = wtOut.push_warning;
209
- }
210
- // C54 — merge install_* fields surfaced by the
211
- // worktree-install hook in worktree.mjs. These fields are
212
- // absent on pre-C54 default-config paths, so readers that
213
- // don't know about install_* continue to work unchanged.
214
- if (wtOut.install && typeof wtOut.install === 'object') {
215
- const inst = wtOut.install;
216
- if (inst.install_status)
217
- state.active_tasks[taskId].install_status = inst.install_status;
218
- if (inst.install_log_path)
219
- state.active_tasks[taskId].install_log_path = inst.install_log_path;
220
- if (inst.install_pid !== undefined && inst.install_pid !== null)
221
- state.active_tasks[taskId].install_pid = inst.install_pid;
222
- if (Array.isArray(inst.install_ecosystems))
223
- state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems;
224
- if (inst.install_started_at)
225
- state.active_tasks[taskId].install_started_at = inst.install_started_at;
226
- if (inst.install_finished_at)
227
- state.active_tasks[taskId].install_finished_at = inst.install_finished_at;
228
- if (inst.install_warning)
229
- state.active_tasks[taskId].install_warning = inst.install_warning;
230
- // C54 review: persist the per-ecosystem breakdown and
231
- // pre-spawn scanner rejections. worktree-install already
232
- // wrote these directly via updateState; forwarding them
233
- // through the stdout merge keeps the exposed fields
234
- // consistent with the install envelope.
235
- if (inst.install_per_ecosystem &&
236
- typeof inst.install_per_ecosystem === 'object')
237
- state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem;
238
- if (Array.isArray(inst.install_rejections))
239
- state.active_tasks[taskId].install_rejections = inst.install_rejections;
240
- }
241
- if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
242
- state.active_tasks[taskId].install_warning = wtOut.install_warning;
243
- }
274
+ if (wtOut.push_warning) {
275
+ state.active_tasks[taskId].push_warning = wtOut.push_warning;
244
276
  }
245
- else {
246
- state.active_tasks[taskId].worktree_warning =
247
- 'worktree create failed: ' +
248
- (createRes.stderr || createRes.stdout || 'unknown')
249
- .toString()
250
- .trim()
251
- .split('\n')
252
- .pop();
277
+ // C54 — merge install_* fields surfaced by the
278
+ // worktree-install hook in worktree.mjs. These fields are
279
+ // absent on pre-C54 default-config paths, so readers that
280
+ // don't know about install_* continue to work unchanged.
281
+ if (wtOut.install && typeof wtOut.install === 'object') {
282
+ const inst = wtOut.install;
283
+ if (inst.install_status)
284
+ state.active_tasks[taskId].install_status = inst.install_status;
285
+ if (inst.install_log_path)
286
+ state.active_tasks[taskId].install_log_path = inst.install_log_path;
287
+ if (inst.install_pid !== undefined && inst.install_pid !== null)
288
+ state.active_tasks[taskId].install_pid = inst.install_pid;
289
+ if (Array.isArray(inst.install_ecosystems))
290
+ state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems;
291
+ if (inst.install_started_at)
292
+ state.active_tasks[taskId].install_started_at = inst.install_started_at;
293
+ if (inst.install_finished_at)
294
+ state.active_tasks[taskId].install_finished_at = inst.install_finished_at;
295
+ if (inst.install_warning)
296
+ state.active_tasks[taskId].install_warning = inst.install_warning;
297
+ // C54 review: persist the per-ecosystem breakdown and
298
+ // pre-spawn scanner rejections. worktree-install already
299
+ // wrote these directly via updateState; forwarding them
300
+ // through the stdout merge keeps the exposed fields
301
+ // consistent with the install envelope.
302
+ if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
303
+ state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem;
304
+ if (Array.isArray(inst.install_rejections))
305
+ state.active_tasks[taskId].install_rejections = inst.install_rejections;
306
+ }
307
+ if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
308
+ state.active_tasks[taskId].install_warning = wtOut.install_warning;
253
309
  }
254
310
  }
255
- catch (e) {
256
- state.active_tasks[taskId].worktree_warning = 'worktree hook error: ' + e.message;
311
+ else {
312
+ state.active_tasks[taskId].worktree_warning =
313
+ 'worktree create failed: ' +
314
+ (createRes.stderr || createRes.stdout || 'unknown')
315
+ .toString()
316
+ .trim()
317
+ .split('\n')
318
+ .pop();
257
319
  }
258
320
  }
259
- state.last_activity = new Date().toISOString();
260
- atomicWriteJson(statePath, state);
261
- }, { projectRoot: targetDir });
262
- }
321
+ catch (e) {
322
+ state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`;
323
+ }
324
+ }
325
+ state.last_activity = new Date().toISOString();
326
+ atomicWriteJson(statePath, state);
327
+ }, { projectRoot: targetDir });
328
+ if (collisionOverflowErr)
329
+ return collisionOverflowErr;
263
330
  regenerateTaskIndex(targetDir);
264
331
  // Team collaboration: register user + add to active board
265
332
  updateTeamMember(targetDir);
@@ -416,6 +483,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
416
483
  if (!taskId)
417
484
  return err('Required: --id <task-id>');
418
485
  const verdict = flags.get('verdict') || 'completed';
486
+ // Internal flag set by `task close-merged` when
487
+ // GitHub confirmed state === 'MERGED'. Threads through to
488
+ // computeWorktreeCleanup so cleanup skips local merge-back and
489
+ // runs fetch + ff-only instead.
490
+ const prMerged = flags.has('pr-merged-remotely');
419
491
  if (!existsSync(statePath))
420
492
  return err('No state.json found');
421
493
  // withFileLock callback can produce either an error envelope (early
@@ -472,11 +544,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
472
544
  });
473
545
  // Worktree cleanup hook — honors task_isolation.cleanup_on_close
474
546
  // (never | ask | auto). See src/cli/task/worktree-cleanup.mjs.
547
+ // prMerged threads from `task close-merged`
548
+ // via --pr-merged-remotely; when true, cleanup runs fetch +
549
+ // ff-only instead of local merge-back.
475
550
  let cleanup = null;
476
551
  if (task.worktree_path) {
477
552
  const isolation = getTaskIsolationConfig(targetDir);
478
553
  const mode = isolation.cleanup_on_close || 'ask';
479
- cleanup = computeWorktreeCleanup(task, mode, targetDir);
554
+ cleanup = computeWorktreeCleanup(task, mode, targetDir, { prMerged });
480
555
  }
481
556
  // Best-effort team sync: commit shared bookkeeping so teammates
482
557
  // don't have to pick up orphaned diffs. Never fails task close.
@@ -653,6 +728,22 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
653
728
  let lifecycleTransition = null;
654
729
  if (flags.has('lifecycle-phase')) {
655
730
  task.lifecycle_phase = flags.get('lifecycle-phase');
731
+ // R6 (roundtable 2026-05-14) — compute elapsed_ms since the
732
+ // previous LIFECYCLE event (or task.created) for phase-boundary
733
+ // telemetry. The 'task.lifecycle.' opPrefix is critical: without
734
+ // it, the most-recent event lookup picks up high-frequency events
735
+ // like task.tokens.recorded, which would silently corrupt
736
+ // elapsed_ms attribution (PR #115 review finding LOG-001).
737
+ const prev = findMostRecentEventForTask(targetDir, taskId, 'task.lifecycle.');
738
+ let prevTime = prev ? new Date(prev.t).getTime() : null;
739
+ // LOG-003: when no prior lifecycle event exists (first transition),
740
+ // fall back to task.created_at so planning phase duration is captured.
741
+ if (!prevTime && task.created_at) {
742
+ const createdMs = new Date(task.created_at).getTime();
743
+ if (Number.isFinite(createdMs))
744
+ prevTime = createdMs;
745
+ }
746
+ const elapsed_ms = prevTime ? Date.now() - prevTime : null;
656
747
  // R10 — emit the lifecycle transition event even when the
657
748
  // side-effect helper decides the phase spec is a no-op; the
658
749
  // caller still changed task.lifecycle_phase.
@@ -664,6 +755,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
664
755
  scope: task.scope || 'project',
665
756
  milestone_id: task.milestone_id ?? null,
666
757
  phase_id: task.phase_id ?? null,
758
+ elapsed_ms,
667
759
  },
668
760
  });
669
761
  // C56 B5 — exhaustive lifecycle→roadmap-phase map. `null`
@@ -710,6 +802,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
710
802
  // This collapses the two-call apt:ship contract into a single
711
803
  // load-bearing call so orchestrators can't drop the lifecycle
712
804
  // transition. Idempotent / no-op on any other phase.
805
+ let ciWatchResult = {
806
+ ci_watch_armed: false,
807
+ ci_watch_skip_reason: null,
808
+ ci_watch: null,
809
+ };
810
+ let prUrlForCiWatch = null;
713
811
  if (flags.has('pr-url')) {
714
812
  const url = flags.get('pr-url');
715
813
  if (!url || typeof url !== 'string') {
@@ -743,6 +841,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
743
841
  },
744
842
  });
745
843
  }
844
+ prUrlForCiWatch = url;
746
845
  }
747
846
  if (flags.has('subtasks-total'))
748
847
  task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10);
@@ -797,6 +896,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
797
896
  }
798
897
  state.last_activity = new Date().toISOString();
799
898
  atomicWriteJson(statePath, state);
899
+ if (prUrlForCiWatch) {
900
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch);
901
+ }
800
902
  regenerateTaskIndex(targetDir);
801
903
  // Team collaboration: sync active board with updated state
802
904
  updateTeamMember(targetDir);
@@ -812,6 +914,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
812
914
  const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task };
813
915
  if (lifecycleTransition)
814
916
  updateOut.transition = lifecycleTransition;
917
+ if (flags.has('pr-url')) {
918
+ updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed;
919
+ updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason;
920
+ if (ciWatchResult.ci_watch)
921
+ updateOut.ci_watch = ciWatchResult.ci_watch;
922
+ }
815
923
  updateResult = ok(updateOut);
816
924
  }, { projectRoot: targetDir });
817
925
  return updateError ?? updateResult;
@@ -1126,7 +1234,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1126
1234
  // Drive the full close path via an in-process CLI call so all of
1127
1235
  // task close's side effects (team-sync, worktree cleanup, phase flip
1128
1236
  // when auto_close_phase is on, event emission, INDEX regen) run.
1129
- const closeRes = spawnSync('node', [
1237
+ const closeArgs = [
1130
1238
  resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
1131
1239
  'task',
1132
1240
  'close',
@@ -1135,7 +1243,18 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1135
1243
  id,
1136
1244
  '--verdict',
1137
1245
  closeVerdict,
1138
- ], { cwd: targetDir, encoding: 'utf-8', env: { ...process.env } });
1246
+ ];
1247
+ // Only signal pr_merged_remotely when GitHub
1248
+ // confirmed MERGED. CLOSED-not-merged is a rejection path; leaving
1249
+ // cleanup behavior unchanged there is intentional (spec §11 R3).
1250
+ if (parsed.state === 'MERGED') {
1251
+ closeArgs.push('--pr-merged-remotely');
1252
+ }
1253
+ const closeRes = spawnSync('node', closeArgs, {
1254
+ cwd: targetDir,
1255
+ encoding: 'utf-8',
1256
+ env: { ...process.env },
1257
+ });
1139
1258
  if (closeRes.status !== 0) {
1140
1259
  errors.push({
1141
1260
  id,
@@ -1185,6 +1304,33 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1185
1304
  verdict: closeVerdict,
1186
1305
  mergedAt: parsed.mergedAt ?? null,
1187
1306
  });
1307
+ // Append a pending_narration[] ledger entry on successful merged close
1308
+ // so /apt:close-task --narrate-only can drain it later. The ledger is
1309
+ // additive-only; failure to write must never undo the close above
1310
+ // (R4 ledger leak — caller can manually invoke the narrator).
1311
+ if (closeVerdict === 'approved') {
1312
+ try {
1313
+ withFileLock(statePath, () => {
1314
+ const fresh = JSON.parse(readFileSync(statePath, 'utf-8'));
1315
+ if (!Array.isArray(fresh.pending_narration))
1316
+ fresh.pending_narration = [];
1317
+ fresh.pending_narration.push({
1318
+ task_id: id,
1319
+ scope: task.scope || 'project',
1320
+ phase_id: task.phase_id ?? null,
1321
+ closed_at: new Date().toISOString(),
1322
+ pr_number: parsed.number ?? null,
1323
+ });
1324
+ atomicWriteJson(statePath, fresh);
1325
+ }, { projectRoot: targetDir });
1326
+ }
1327
+ catch (e) {
1328
+ errors.push({
1329
+ id,
1330
+ reason: `ledger-write-failed: ${e.message ?? String(e)}`,
1331
+ });
1332
+ }
1333
+ }
1188
1334
  }
1189
1335
  return ok({
1190
1336
  status: 'ok',
@@ -1195,6 +1341,44 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1195
1341
  errors,
1196
1342
  });
1197
1343
  }
1344
+ case 'narration-drain': {
1345
+ // Remove a single pending_narration[] row under the same withFileLock
1346
+ // guard task close uses. Caller (apt:close-task --narrate-only) invokes
1347
+ // this once per drained entry AFTER its background narrator spawn
1348
+ // returns — so a spawn failure leaves the row in place for retry.
1349
+ const taskId = flags.get('task');
1350
+ if (!taskId)
1351
+ return err('Required: --task <task-id>');
1352
+ if (!existsSync(statePath)) {
1353
+ return ok({
1354
+ status: 'ok',
1355
+ command: 'task-narration-drain',
1356
+ removed: null,
1357
+ remaining: 0,
1358
+ reason: 'no-state',
1359
+ });
1360
+ }
1361
+ let removed = null;
1362
+ let remaining = 0;
1363
+ withFileLock(statePath, () => {
1364
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
1365
+ const ledger = Array.isArray(state.pending_narration) ? state.pending_narration : [];
1366
+ const idx = ledger.findIndex((row) => row && row.task_id === taskId);
1367
+ if (idx >= 0) {
1368
+ removed = ledger[idx];
1369
+ ledger.splice(idx, 1);
1370
+ state.pending_narration = ledger;
1371
+ atomicWriteJson(statePath, state);
1372
+ }
1373
+ remaining = ledger.length;
1374
+ }, { projectRoot: targetDir });
1375
+ return ok({
1376
+ status: 'ok',
1377
+ command: 'task-narration-drain',
1378
+ removed,
1379
+ remaining,
1380
+ });
1381
+ }
1198
1382
  case 'prune': {
1199
1383
  // C56 A4 — retention verb. Removes `.aperant/tasks/{id}/` directories
1200
1384
  // whose task row is in `state.completed_tasks[]` AND whose completion
@@ -1219,7 +1403,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1219
1403
  const pruned = [];
1220
1404
  const skipped = [];
1221
1405
  for (const row of state.completed_tasks || []) {
1222
- if (!row || !row.id)
1406
+ if (!row?.id)
1223
1407
  continue;
1224
1408
  const rowScope = row.scope || 'project';
1225
1409
  if (scopeFilter && rowScope !== scopeFilter) {
@@ -1290,8 +1474,72 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1290
1474
  });
1291
1475
  }
1292
1476
  default:
1293
- return err(`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, prune`);
1477
+ return err(`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`);
1478
+ }
1479
+ }
1480
+ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1481
+ const pr = extractPrNumber(prUrl);
1482
+ if (!pr) {
1483
+ return {
1484
+ ci_watch_armed: false,
1485
+ ci_watch_skip_reason: 'invalid-pr-url',
1486
+ ci_watch: null,
1487
+ };
1488
+ }
1489
+ const host = detectHost();
1490
+ const defaultMode = host.capabilities?.cron ? 'auto' : 'never';
1491
+ const config = loadProjectConfig(targetDir);
1492
+ const configuredMode = config?.ci_watch?.after_ship || defaultMode;
1493
+ const mode = configuredMode === 'ask' && Number(task.autonomy) === 3 ? 'auto' : configuredMode;
1494
+ if (mode === 'never') {
1495
+ return {
1496
+ ci_watch_armed: false,
1497
+ ci_watch_skip_reason: 'disabled',
1498
+ ci_watch: null,
1499
+ };
1500
+ }
1501
+ if (mode === 'ask') {
1502
+ return {
1503
+ ci_watch_armed: false,
1504
+ ci_watch_skip_reason: 'ask-deferred',
1505
+ ci_watch: null,
1506
+ };
1507
+ }
1508
+ if (mode !== 'auto') {
1509
+ return {
1510
+ ci_watch_armed: false,
1511
+ ci_watch_skip_reason: `invalid-config:${mode}`,
1512
+ ci_watch: null,
1513
+ };
1514
+ }
1515
+ const result = cmdCiWatch('start', targetDir, [
1516
+ '--pr',
1517
+ String(pr),
1518
+ '--task-id',
1519
+ task.id,
1520
+ '--host',
1521
+ host.cli || 'unknown',
1522
+ ]);
1523
+ const envelope = result?.envelope || {};
1524
+ if (envelope.status === 'ok') {
1525
+ return {
1526
+ ci_watch_armed: true,
1527
+ ci_watch_skip_reason: null,
1528
+ ci_watch: envelope,
1529
+ };
1530
+ }
1531
+ if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
1532
+ return {
1533
+ ci_watch_armed: true,
1534
+ ci_watch_skip_reason: 'already-armed',
1535
+ ci_watch: envelope,
1536
+ };
1294
1537
  }
1538
+ return {
1539
+ ci_watch_armed: false,
1540
+ ci_watch_skip_reason: envelope.reason || 'start-failed',
1541
+ ci_watch: envelope,
1542
+ };
1295
1543
  }
1296
1544
  /**
1297
1545
  * Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123