@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()
@@ -14,7 +14,8 @@
14
14
  import { spawnSync } from 'node:child_process'
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
16
16
  import { join, resolve } from 'node:path'
17
- import { getTaskIsolationConfig } from '../config/load.mjs'
17
+ import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs'
18
+ import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
18
19
  import { createCoordinationStore } from '../coordination/store.mjs'
19
20
  import { getGitIdentity } from '../git/identity.mjs'
20
21
  import { detectWorktree } from '../git/worktree-detect.mjs'
@@ -39,13 +40,14 @@ import { parseDuration } from '../util/duration.mjs'
39
40
  import { withFileLock } from '../util/fs-lock.mjs'
40
41
  import { atomicWriteJson } from '../util/io.mjs'
41
42
  import { err, exitWith, ok } from '../util/result.mjs'
43
+ import { cmdCiWatch } from './ci-watch.mjs'
42
44
 
43
45
  export function cmdTask(subcommand, projectDir, extraArgs) {
44
46
  if (!subcommand)
45
47
  return err(
46
- 'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|prune> <project-dir> [flags]',
48
+ 'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]',
47
49
  )
48
- if (!projectDir) return err('Usage: apt-tools task ' + subcommand + ' <project-dir> [flags]')
50
+ if (!projectDir) return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`)
49
51
 
50
52
  const targetDir = resolve(projectDir)
51
53
  const statePath = join(targetDir, '.aperant', 'state.json')
@@ -128,166 +130,233 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
128
130
  // Load config for date format preference
129
131
  const configPath = join(targetDir, '.aperant', 'config.json')
130
132
  const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
131
- const taskId = flags.get('id') || generateTaskId(description, config)
132
-
133
- // Create task directory
134
- const taskDir = getTaskDir(targetDir, taskId)
135
- mkdirSync(taskDir, { recursive: true })
136
- for (const subdir of ['events', 'locks']) {
137
- mkdirSync(join(taskDir, subdir), { recursive: true })
133
+ // User-supplied --id bypasses the collision guard (caller owns the
134
+ // uniqueness contract). Generated ids run through a numeric-counter
135
+ // disambiguator that appends -2, -3, … when the candidate already
136
+ // exists in state or on disk. Bounded at 100 iterations as a
137
+ // defensive ceiling; same-day same-slug collisions are vanishingly
138
+ // rare so the loop should almost always terminate on the first try.
139
+ //
140
+ // BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
141
+ // state.active_tasks write MUST all happen inside a single
142
+ // withFileLock critical section. Two concurrent task create calls
143
+ // in the multi-agent environment can otherwise both compute the
144
+ // same candidate against the same pre-lock snapshot of state and
145
+ // race into a duplicate-id write that silently overwrites the
146
+ // first task's record.
147
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
148
+ const explicitIdRaw = flags.get('id')
149
+ if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
150
+ return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
151
+ command: 'task-create',
152
+ })
153
+ }
154
+ // Secondary guard: ensure the resolved task directory stays inside the
155
+ // tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
156
+ // but allows `.` and `..` as valid characters. A lone `..` id would
157
+ // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
158
+ if (explicitIdRaw) {
159
+ const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'))
160
+ const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw))
161
+ if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
162
+ return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
163
+ command: 'task-create',
164
+ })
165
+ }
138
166
  }
139
167
 
140
- // Register in state.json
141
- if (existsSync(statePath)) {
142
- withFileLock(
143
- statePath,
144
- () => {
145
- const state = JSON.parse(readFileSync(statePath, 'utf-8'))
146
-
147
- // Migrate: if old format (active_task singular), convert
148
- if (state.active_task !== undefined && state.active_tasks === undefined) {
149
- state.active_tasks = {}
150
- if (state.active_task) {
151
- const oldId = state.active_task.id || 'legacy-task'
152
- state.active_tasks[oldId] = state.active_task
168
+ let taskId
169
+ let taskDir
170
+ let collisionOverflowErr = null
171
+
172
+ withFileLock(
173
+ statePath,
174
+ () => {
175
+ // Read state under the lock so the collision scan, taskDir
176
+ // mkdir, and state.active_tasks write all observe the same
177
+ // snapshot. withFileLock seeds an empty file if statePath
178
+ // doesn't exist, so the JSON.parse fallback handles the
179
+ // first-ever task create cleanly.
180
+ const raw = readFileSync(statePath, 'utf-8').trim()
181
+ const state = raw ? JSON.parse(raw) : {}
182
+
183
+ // Migrate: if old format (active_task singular), convert
184
+ if (state.active_task !== undefined && state.active_tasks === undefined) {
185
+ state.active_tasks = {}
186
+ if (state.active_task) {
187
+ const oldId = state.active_task.id || 'legacy-task'
188
+ state.active_tasks[oldId] = state.active_task
189
+ }
190
+ delete state.active_task
191
+ }
192
+ if (!state.active_tasks) state.active_tasks = {}
193
+
194
+ // Resolve taskId against the locked-in state snapshot.
195
+ if (explicitIdRaw) {
196
+ taskId = explicitIdRaw
197
+ } else {
198
+ const base = generateTaskId(description, config)
199
+ const activeIds = Object.keys(state.active_tasks)
200
+ const completedIds = Array.isArray(state.completed_tasks)
201
+ ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
202
+ : []
203
+ const known = new Set([...activeIds, ...completedIds])
204
+ const isTaken = (candidate) =>
205
+ known.has(candidate) || existsSync(getTaskDir(targetDir, candidate))
206
+ let candidate = base
207
+ let counter = 2
208
+ while (isTaken(candidate)) {
209
+ if (counter > 100) {
210
+ collisionOverflowErr = err(
211
+ `task create: collision counter exceeded 100 for base id "${base}" — manual --id required`,
212
+ )
213
+ return
153
214
  }
154
- delete state.active_task
215
+ candidate = `${base}-${counter}`
216
+ counter++
155
217
  }
156
- if (!state.active_tasks) state.active_tasks = {}
157
-
158
- // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
159
- // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
160
- // R11 — resolve the outputs allowlist now so it is pinned at
161
- // task creation time. If the planner edits the task record
162
- // later (e.g. narrows the allowlist), it overwrites this.
163
- const outputs = resolveDefaultOutputs({
164
- id: taskId,
165
- scope,
166
- milestone_id: milestoneId,
167
- phase_id: phaseIdForRoadmap,
168
- })
218
+ taskId = candidate
219
+ }
169
220
 
170
- state.active_tasks[taskId] = {
171
- id: taskId,
172
- description,
173
- track,
174
- autonomy,
175
- // C21 execution_mode is omitted entirely when unset so legacy
176
- // readers (apt-plan SKILL.md 9a) continue to see `undefined`
177
- // rather than `null` and fall through to the autonomy heuristic.
178
- ...(executionMode ? { execution_mode: executionMode } : {}),
179
- scope,
180
- milestone_id: milestoneId,
181
- phase_id: phaseIdForRoadmap,
182
- roadmap_ref_url: roadmapRefUrl,
183
- depends_on: dependsOn,
184
- outputs,
185
- lifecycle_phase: 'planning',
186
- history: [],
187
- created_at: new Date().toISOString(),
188
- progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
189
- }
221
+ // Create task directory under the lock so a concurrent
222
+ // run can't observe (and then re-pick) a half-created id.
223
+ taskDir = getTaskDir(targetDir, taskId)
224
+ mkdirSync(taskDir, { recursive: true })
225
+ for (const subdir of ['events', 'locks']) {
226
+ mkdirSync(join(taskDir, subdir), { recursive: true })
227
+ }
190
228
 
191
- // ── Task-level worktree hook ──────────────────────────────
192
- // When task_isolation.worktree_per_task is enabled AND we're
193
- // not already inside a worktree AND the host supports them,
194
- // fork a branch + worktree and record its location on the
195
- // task record. Any failure is downgraded to a warning so
196
- // task-create never aborts.
197
- const isolation = getTaskIsolationConfig(targetDir)
198
- const host = detectHost()
199
- if (
200
- isolation.worktree_per_task === true &&
201
- host.capabilities.worktrees === true &&
202
- !detectWorktree(targetDir).is_worktree
203
- ) {
204
- try {
205
- const wtArgs = ['--task', taskId, '--scope', scope]
206
- if (isolation.push_on_create) wtArgs.push('--push')
207
- if (isolation.from_branch && isolation.from_branch !== 'current') {
208
- wtArgs.push('--from', isolation.from_branch)
229
+ // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
230
+ // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
231
+ // R11 resolve the outputs allowlist now so it is pinned at
232
+ // task creation time. If the planner edits the task record
233
+ // later (e.g. narrows the allowlist), it overwrites this.
234
+ const outputs = resolveDefaultOutputs({
235
+ id: taskId,
236
+ scope,
237
+ milestone_id: milestoneId,
238
+ phase_id: phaseIdForRoadmap,
239
+ })
240
+
241
+ state.active_tasks[taskId] = {
242
+ id: taskId,
243
+ description,
244
+ track,
245
+ autonomy,
246
+ // C21 — execution_mode is omitted entirely when unset so legacy
247
+ // readers (apt-plan SKILL.md 9a) continue to see `undefined`
248
+ // rather than `null` and fall through to the autonomy heuristic.
249
+ ...(executionMode ? { execution_mode: executionMode } : {}),
250
+ scope,
251
+ milestone_id: milestoneId,
252
+ phase_id: phaseIdForRoadmap,
253
+ roadmap_ref_url: roadmapRefUrl,
254
+ depends_on: dependsOn,
255
+ outputs,
256
+ lifecycle_phase: 'planning',
257
+ history: [],
258
+ created_at: new Date().toISOString(),
259
+ progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
260
+ }
261
+
262
+ // ── Task-level worktree hook ──────────────────────────────
263
+ // When task_isolation.worktree_per_task is enabled AND we're
264
+ // not already inside a worktree AND the host supports them,
265
+ // fork a branch + worktree and record its location on the
266
+ // task record. Any failure is downgraded to a warning so
267
+ // task-create never aborts.
268
+ const isolation = getTaskIsolationConfig(targetDir)
269
+ const host = detectHost()
270
+ if (
271
+ isolation.worktree_per_task === true &&
272
+ host.capabilities.worktrees === true &&
273
+ !detectWorktree(targetDir).is_worktree
274
+ ) {
275
+ try {
276
+ const wtArgs = ['--task', taskId, '--scope', scope]
277
+ if (isolation.push_on_create) wtArgs.push('--push')
278
+ if (isolation.from_branch && isolation.from_branch !== 'current') {
279
+ wtArgs.push('--from', isolation.from_branch)
280
+ }
281
+ // Invoke inline so we capture the structured payload
282
+ // without shelling back out. We cheat by calling the
283
+ // worktree creator directly as a subprocess to keep
284
+ // its output discipline intact, then parse its JSON.
285
+ //
286
+ // D-03 — single source of truth for the apt-tools binary
287
+ // path (replaces the legacy process.argv[1] heuristic).
288
+ const aptToolsBin = resolveAptToolsBin(targetDir)
289
+ const createRes = spawnSync(
290
+ process.execPath,
291
+ [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
292
+ { encoding: 'utf-8' },
293
+ )
294
+ if (createRes.status === 0 && createRes.stdout) {
295
+ const wtOut = JSON.parse(createRes.stdout)
296
+ state.active_tasks[taskId].worktree_path = wtOut.worktree_path
297
+ state.active_tasks[taskId].branch = wtOut.branch
298
+ state.active_tasks[taskId].base_branch = wtOut.base_branch
299
+ state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
300
+ if (wtOut.scope) {
301
+ state.active_tasks[taskId].worktree_scope = wtOut.scope
209
302
  }
210
- // Invoke inline so we capture the structured payload
211
- // without shelling back out. We cheat by calling the
212
- // worktree creator directly as a subprocess to keep
213
- // its output discipline intact, then parse its JSON.
214
- //
215
- // D-03 — single source of truth for the apt-tools binary
216
- // path (replaces the legacy process.argv[1] heuristic).
217
- const aptToolsBin = resolveAptToolsBin(targetDir)
218
- const createRes = spawnSync(
219
- process.execPath,
220
- [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
221
- { encoding: 'utf-8' },
222
- )
223
- if (createRes.status === 0 && createRes.stdout) {
224
- const wtOut = JSON.parse(createRes.stdout)
225
- state.active_tasks[taskId].worktree_path = wtOut.worktree_path
226
- state.active_tasks[taskId].branch = wtOut.branch
227
- state.active_tasks[taskId].base_branch = wtOut.base_branch
228
- state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
229
- if (wtOut.scope) {
230
- state.active_tasks[taskId].worktree_scope = wtOut.scope
231
- }
232
- if (wtOut.push_warning) {
233
- state.active_tasks[taskId].push_warning = wtOut.push_warning
234
- }
235
- // C54 — merge install_* fields surfaced by the
236
- // worktree-install hook in worktree.mjs. These fields are
237
- // absent on pre-C54 default-config paths, so readers that
238
- // don't know about install_* continue to work unchanged.
239
- if (wtOut.install && typeof wtOut.install === 'object') {
240
- const inst = wtOut.install
241
- if (inst.install_status)
242
- state.active_tasks[taskId].install_status = inst.install_status
243
- if (inst.install_log_path)
244
- state.active_tasks[taskId].install_log_path = inst.install_log_path
245
- if (inst.install_pid !== undefined && inst.install_pid !== null)
246
- state.active_tasks[taskId].install_pid = inst.install_pid
247
- if (Array.isArray(inst.install_ecosystems))
248
- state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
249
- if (inst.install_started_at)
250
- state.active_tasks[taskId].install_started_at = inst.install_started_at
251
- if (inst.install_finished_at)
252
- state.active_tasks[taskId].install_finished_at = inst.install_finished_at
253
- if (inst.install_warning)
254
- state.active_tasks[taskId].install_warning = inst.install_warning
255
- // C54 review: persist the per-ecosystem breakdown and
256
- // pre-spawn scanner rejections. worktree-install already
257
- // wrote these directly via updateState; forwarding them
258
- // through the stdout merge keeps the exposed fields
259
- // consistent with the install envelope.
260
- if (
261
- inst.install_per_ecosystem &&
262
- typeof inst.install_per_ecosystem === 'object'
263
- )
264
- state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
265
- if (Array.isArray(inst.install_rejections))
266
- state.active_tasks[taskId].install_rejections = inst.install_rejections
267
- }
268
- if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
269
- state.active_tasks[taskId].install_warning = wtOut.install_warning
270
- }
271
- } else {
272
- state.active_tasks[taskId].worktree_warning =
273
- 'worktree create failed: ' +
274
- (createRes.stderr || createRes.stdout || 'unknown')
275
- .toString()
276
- .trim()
277
- .split('\n')
278
- .pop()
303
+ if (wtOut.push_warning) {
304
+ state.active_tasks[taskId].push_warning = wtOut.push_warning
279
305
  }
280
- } catch (e) {
281
- state.active_tasks[taskId].worktree_warning = 'worktree hook error: ' + e.message
306
+ // C54 merge install_* fields surfaced by the
307
+ // worktree-install hook in worktree.mjs. These fields are
308
+ // absent on pre-C54 default-config paths, so readers that
309
+ // don't know about install_* continue to work unchanged.
310
+ if (wtOut.install && typeof wtOut.install === 'object') {
311
+ const inst = wtOut.install
312
+ if (inst.install_status)
313
+ state.active_tasks[taskId].install_status = inst.install_status
314
+ if (inst.install_log_path)
315
+ state.active_tasks[taskId].install_log_path = inst.install_log_path
316
+ if (inst.install_pid !== undefined && inst.install_pid !== null)
317
+ state.active_tasks[taskId].install_pid = inst.install_pid
318
+ if (Array.isArray(inst.install_ecosystems))
319
+ state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
320
+ if (inst.install_started_at)
321
+ state.active_tasks[taskId].install_started_at = inst.install_started_at
322
+ if (inst.install_finished_at)
323
+ state.active_tasks[taskId].install_finished_at = inst.install_finished_at
324
+ if (inst.install_warning)
325
+ state.active_tasks[taskId].install_warning = inst.install_warning
326
+ // C54 review: persist the per-ecosystem breakdown and
327
+ // pre-spawn scanner rejections. worktree-install already
328
+ // wrote these directly via updateState; forwarding them
329
+ // through the stdout merge keeps the exposed fields
330
+ // consistent with the install envelope.
331
+ if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
332
+ state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
333
+ if (Array.isArray(inst.install_rejections))
334
+ state.active_tasks[taskId].install_rejections = inst.install_rejections
335
+ }
336
+ if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
337
+ state.active_tasks[taskId].install_warning = wtOut.install_warning
338
+ }
339
+ } else {
340
+ state.active_tasks[taskId].worktree_warning =
341
+ 'worktree create failed: ' +
342
+ (createRes.stderr || createRes.stdout || 'unknown')
343
+ .toString()
344
+ .trim()
345
+ .split('\n')
346
+ .pop()
282
347
  }
348
+ } catch (e) {
349
+ state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
283
350
  }
351
+ }
284
352
 
285
- state.last_activity = new Date().toISOString()
286
- atomicWriteJson(statePath, state)
287
- },
288
- { projectRoot: targetDir },
289
- )
290
- }
353
+ state.last_activity = new Date().toISOString()
354
+ atomicWriteJson(statePath, state)
355
+ },
356
+ { projectRoot: targetDir },
357
+ )
358
+
359
+ if (collisionOverflowErr) return collisionOverflowErr
291
360
 
292
361
  regenerateTaskIndex(targetDir)
293
362
 
@@ -457,6 +526,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
457
526
  const taskId = flags.get('id')
458
527
  if (!taskId) return err('Required: --id <task-id>')
459
528
  const verdict = flags.get('verdict') || 'completed'
529
+ // Internal flag set by `task close-merged` when
530
+ // GitHub confirmed state === 'MERGED'. Threads through to
531
+ // computeWorktreeCleanup so cleanup skips local merge-back and
532
+ // runs fetch + ff-only instead.
533
+ const prMerged = flags.has('pr-merged-remotely')
460
534
 
461
535
  if (!existsSync(statePath)) return err('No state.json found')
462
536
 
@@ -521,11 +595,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
521
595
 
522
596
  // Worktree cleanup hook — honors task_isolation.cleanup_on_close
523
597
  // (never | ask | auto). See src/cli/task/worktree-cleanup.mjs.
598
+ // prMerged threads from `task close-merged`
599
+ // via --pr-merged-remotely; when true, cleanup runs fetch +
600
+ // ff-only instead of local merge-back.
524
601
  let cleanup = null
525
602
  if (task.worktree_path) {
526
603
  const isolation = getTaskIsolationConfig(targetDir)
527
604
  const mode = isolation.cleanup_on_close || 'ask'
528
- cleanup = computeWorktreeCleanup(task, mode, targetDir)
605
+ cleanup = computeWorktreeCleanup(task, mode, targetDir, { prMerged })
529
606
  }
530
607
 
531
608
  // Best-effort team sync: commit shared bookkeeping so teammates
@@ -721,6 +798,21 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
721
798
  let lifecycleTransition = null
722
799
  if (flags.has('lifecycle-phase')) {
723
800
  task.lifecycle_phase = flags.get('lifecycle-phase')
801
+ // R6 (roundtable 2026-05-14) — compute elapsed_ms since the
802
+ // previous LIFECYCLE event (or task.created) for phase-boundary
803
+ // telemetry. The 'task.lifecycle.' opPrefix is critical: without
804
+ // it, the most-recent event lookup picks up high-frequency events
805
+ // like task.tokens.recorded, which would silently corrupt
806
+ // elapsed_ms attribution (PR #115 review finding LOG-001).
807
+ const prev = findMostRecentEventForTask(targetDir, taskId, 'task.lifecycle.')
808
+ let prevTime = prev ? new Date(prev.t).getTime() : null
809
+ // LOG-003: when no prior lifecycle event exists (first transition),
810
+ // fall back to task.created_at so planning phase duration is captured.
811
+ if (!prevTime && task.created_at) {
812
+ const createdMs = new Date(task.created_at).getTime()
813
+ if (Number.isFinite(createdMs)) prevTime = createdMs
814
+ }
815
+ const elapsed_ms = prevTime ? Date.now() - prevTime : null
724
816
  // R10 — emit the lifecycle transition event even when the
725
817
  // side-effect helper decides the phase spec is a no-op; the
726
818
  // caller still changed task.lifecycle_phase.
@@ -732,6 +824,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
732
824
  scope: task.scope || 'project',
733
825
  milestone_id: task.milestone_id ?? null,
734
826
  phase_id: task.phase_id ?? null,
827
+ elapsed_ms,
735
828
  },
736
829
  })
737
830
  // C56 B5 — exhaustive lifecycle→roadmap-phase map. `null`
@@ -786,6 +879,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
786
879
  // This collapses the two-call apt:ship contract into a single
787
880
  // load-bearing call so orchestrators can't drop the lifecycle
788
881
  // transition. Idempotent / no-op on any other phase.
882
+ let ciWatchResult = {
883
+ ci_watch_armed: false,
884
+ ci_watch_skip_reason: null,
885
+ ci_watch: null,
886
+ }
887
+ let prUrlForCiWatch = null
789
888
  if (flags.has('pr-url')) {
790
889
  const url = flags.get('pr-url')
791
890
  if (!url || typeof url !== 'string') {
@@ -821,6 +920,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
821
920
  },
822
921
  })
823
922
  }
923
+ prUrlForCiWatch = url
824
924
  }
825
925
  if (flags.has('subtasks-total'))
826
926
  task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10)
@@ -881,6 +981,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
881
981
 
882
982
  state.last_activity = new Date().toISOString()
883
983
  atomicWriteJson(statePath, state)
984
+ if (prUrlForCiWatch) {
985
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
986
+ }
884
987
 
885
988
  regenerateTaskIndex(targetDir)
886
989
 
@@ -899,6 +1002,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
899
1002
 
900
1003
  const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task }
901
1004
  if (lifecycleTransition) updateOut.transition = lifecycleTransition
1005
+ if (flags.has('pr-url')) {
1006
+ updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
1007
+ updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
1008
+ if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
1009
+ }
902
1010
  updateResult = ok(updateOut)
903
1011
  },
904
1012
  { projectRoot: targetDir },
@@ -1253,20 +1361,27 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1253
1361
  // Drive the full close path via an in-process CLI call so all of
1254
1362
  // task close's side effects (team-sync, worktree cleanup, phase flip
1255
1363
  // when auto_close_phase is on, event emission, INDEX regen) run.
1256
- const closeRes = spawnSync(
1257
- 'node',
1258
- [
1259
- resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
1260
- 'task',
1261
- 'close',
1262
- targetDir,
1263
- '--id',
1264
- id,
1265
- '--verdict',
1266
- closeVerdict,
1267
- ],
1268
- { cwd: targetDir, encoding: 'utf-8', env: { ...process.env } },
1269
- )
1364
+ const closeArgs = [
1365
+ resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
1366
+ 'task',
1367
+ 'close',
1368
+ targetDir,
1369
+ '--id',
1370
+ id,
1371
+ '--verdict',
1372
+ closeVerdict,
1373
+ ]
1374
+ // Only signal pr_merged_remotely when GitHub
1375
+ // confirmed MERGED. CLOSED-not-merged is a rejection path; leaving
1376
+ // cleanup behavior unchanged there is intentional (spec §11 R3).
1377
+ if (parsed.state === 'MERGED') {
1378
+ closeArgs.push('--pr-merged-remotely')
1379
+ }
1380
+ const closeRes = spawnSync('node', closeArgs, {
1381
+ cwd: targetDir,
1382
+ encoding: 'utf-8',
1383
+ env: { ...process.env },
1384
+ })
1270
1385
  if (closeRes.status !== 0) {
1271
1386
  errors.push({
1272
1387
  id,
@@ -1317,6 +1432,36 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1317
1432
  verdict: closeVerdict,
1318
1433
  mergedAt: parsed.mergedAt ?? null,
1319
1434
  })
1435
+
1436
+ // Append a pending_narration[] ledger entry on successful merged close
1437
+ // so /apt:close-task --narrate-only can drain it later. The ledger is
1438
+ // additive-only; failure to write must never undo the close above
1439
+ // (R4 ledger leak — caller can manually invoke the narrator).
1440
+ if (closeVerdict === 'approved') {
1441
+ try {
1442
+ withFileLock(
1443
+ statePath,
1444
+ () => {
1445
+ const fresh = JSON.parse(readFileSync(statePath, 'utf-8'))
1446
+ if (!Array.isArray(fresh.pending_narration)) fresh.pending_narration = []
1447
+ fresh.pending_narration.push({
1448
+ task_id: id,
1449
+ scope: task.scope || 'project',
1450
+ phase_id: task.phase_id ?? null,
1451
+ closed_at: new Date().toISOString(),
1452
+ pr_number: parsed.number ?? null,
1453
+ })
1454
+ atomicWriteJson(statePath, fresh)
1455
+ },
1456
+ { projectRoot: targetDir },
1457
+ )
1458
+ } catch (e) {
1459
+ errors.push({
1460
+ id,
1461
+ reason: `ledger-write-failed: ${e.message ?? String(e)}`,
1462
+ })
1463
+ }
1464
+ }
1320
1465
  }
1321
1466
 
1322
1467
  return ok({
@@ -1329,6 +1474,51 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1329
1474
  })
1330
1475
  }
1331
1476
 
1477
+ case 'narration-drain': {
1478
+ // Remove a single pending_narration[] row under the same withFileLock
1479
+ // guard task close uses. Caller (apt:close-task --narrate-only) invokes
1480
+ // this once per drained entry AFTER its background narrator spawn
1481
+ // returns — so a spawn failure leaves the row in place for retry.
1482
+ const taskId = flags.get('task')
1483
+ if (!taskId) return err('Required: --task <task-id>')
1484
+
1485
+ if (!existsSync(statePath)) {
1486
+ return ok({
1487
+ status: 'ok',
1488
+ command: 'task-narration-drain',
1489
+ removed: null,
1490
+ remaining: 0,
1491
+ reason: 'no-state',
1492
+ })
1493
+ }
1494
+
1495
+ let removed = null
1496
+ let remaining = 0
1497
+ withFileLock(
1498
+ statePath,
1499
+ () => {
1500
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'))
1501
+ const ledger = Array.isArray(state.pending_narration) ? state.pending_narration : []
1502
+ const idx = ledger.findIndex((row) => row && row.task_id === taskId)
1503
+ if (idx >= 0) {
1504
+ removed = ledger[idx]
1505
+ ledger.splice(idx, 1)
1506
+ state.pending_narration = ledger
1507
+ atomicWriteJson(statePath, state)
1508
+ }
1509
+ remaining = ledger.length
1510
+ },
1511
+ { projectRoot: targetDir },
1512
+ )
1513
+
1514
+ return ok({
1515
+ status: 'ok',
1516
+ command: 'task-narration-drain',
1517
+ removed,
1518
+ remaining,
1519
+ })
1520
+ }
1521
+
1332
1522
  case 'prune': {
1333
1523
  // C56 A4 — retention verb. Removes `.aperant/tasks/{id}/` directories
1334
1524
  // whose task row is in `state.completed_tasks[]` AND whose completion
@@ -1354,7 +1544,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1354
1544
  const skipped = []
1355
1545
 
1356
1546
  for (const row of state.completed_tasks || []) {
1357
- if (!row || !row.id) continue
1547
+ if (!row?.id) continue
1358
1548
  const rowScope = row.scope || 'project'
1359
1549
  if (scopeFilter && rowScope !== scopeFilter) {
1360
1550
  skipped.push({ id: row.id, reason: 'scope-mismatch' })
@@ -1426,11 +1616,81 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1426
1616
 
1427
1617
  default:
1428
1618
  return err(
1429
- `Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, prune`,
1619
+ `Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`,
1430
1620
  )
1431
1621
  }
1432
1622
  }
1433
1623
 
1624
+ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1625
+ const pr = extractPrNumber(prUrl)
1626
+ if (!pr) {
1627
+ return {
1628
+ ci_watch_armed: false,
1629
+ ci_watch_skip_reason: 'invalid-pr-url',
1630
+ ci_watch: null,
1631
+ }
1632
+ }
1633
+
1634
+ const host = detectHost()
1635
+ const defaultMode = host.capabilities?.cron ? 'auto' : 'never'
1636
+ const config = loadProjectConfig(targetDir)
1637
+ const configuredMode = config?.ci_watch?.after_ship || defaultMode
1638
+ const mode = configuredMode === 'ask' && Number(task.autonomy) === 3 ? 'auto' : configuredMode
1639
+
1640
+ if (mode === 'never') {
1641
+ return {
1642
+ ci_watch_armed: false,
1643
+ ci_watch_skip_reason: 'disabled',
1644
+ ci_watch: null,
1645
+ }
1646
+ }
1647
+
1648
+ if (mode === 'ask') {
1649
+ return {
1650
+ ci_watch_armed: false,
1651
+ ci_watch_skip_reason: 'ask-deferred',
1652
+ ci_watch: null,
1653
+ }
1654
+ }
1655
+
1656
+ if (mode !== 'auto') {
1657
+ return {
1658
+ ci_watch_armed: false,
1659
+ ci_watch_skip_reason: `invalid-config:${mode}`,
1660
+ ci_watch: null,
1661
+ }
1662
+ }
1663
+
1664
+ const result = cmdCiWatch('start', targetDir, [
1665
+ '--pr',
1666
+ String(pr),
1667
+ '--task-id',
1668
+ task.id,
1669
+ '--host',
1670
+ host.cli || 'unknown',
1671
+ ])
1672
+ const envelope = result?.envelope || {}
1673
+ if (envelope.status === 'ok') {
1674
+ return {
1675
+ ci_watch_armed: true,
1676
+ ci_watch_skip_reason: null,
1677
+ ci_watch: envelope,
1678
+ }
1679
+ }
1680
+ if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
1681
+ return {
1682
+ ci_watch_armed: true,
1683
+ ci_watch_skip_reason: 'already-armed',
1684
+ ci_watch: envelope,
1685
+ }
1686
+ }
1687
+ return {
1688
+ ci_watch_armed: false,
1689
+ ci_watch_skip_reason: envelope.reason || 'start-failed',
1690
+ ci_watch: envelope,
1691
+ }
1692
+ }
1693
+
1434
1694
  /**
1435
1695
  * Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123
1436
1696
  * or a short form like `owner/repo#123`. Returns null if not parseable.