@aperant/framework 0.6.4 → 0.6.5

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 (205) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +64 -10
  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/context.d.mts +7 -0
  34. package/dist/cli/commands/context.d.mts.map +1 -0
  35. package/dist/cli/commands/context.mjs +224 -0
  36. package/dist/cli/commands/context.mjs.map +1 -0
  37. package/dist/cli/commands/event.d.mts.map +1 -1
  38. package/dist/cli/commands/event.mjs +59 -24
  39. package/dist/cli/commands/event.mjs.map +1 -1
  40. package/dist/cli/commands/host-detect.d.mts +1 -1
  41. package/dist/cli/commands/host-detect.d.mts.map +1 -1
  42. package/dist/cli/commands/host-detect.mjs +30 -3
  43. package/dist/cli/commands/host-detect.mjs.map +1 -1
  44. package/dist/cli/commands/modes.d.mts +13 -0
  45. package/dist/cli/commands/modes.d.mts.map +1 -0
  46. package/dist/cli/commands/modes.mjs +220 -0
  47. package/dist/cli/commands/modes.mjs.map +1 -0
  48. package/dist/cli/commands/route.mjs +1 -1
  49. package/dist/cli/commands/route.mjs.map +1 -1
  50. package/dist/cli/commands/task.d.mts.map +1 -1
  51. package/dist/cli/commands/task.mjs +108 -6
  52. package/dist/cli/commands/task.mjs.map +1 -1
  53. package/dist/cli/commands/tokens.d.mts.map +1 -1
  54. package/dist/cli/commands/tokens.mjs +150 -6
  55. package/dist/cli/commands/tokens.mjs.map +1 -1
  56. package/dist/cli/commands/triage.d.mts +8 -0
  57. package/dist/cli/commands/triage.d.mts.map +1 -0
  58. package/dist/cli/commands/triage.mjs +259 -0
  59. package/dist/cli/commands/triage.mjs.map +1 -0
  60. package/dist/cli/config/post-merge-sweep.d.mts +18 -0
  61. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  62. package/dist/cli/config/post-merge-sweep.mjs +48 -3
  63. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  64. package/dist/cli/consistency/registry.d.mts +11 -0
  65. package/dist/cli/consistency/registry.d.mts.map +1 -1
  66. package/dist/cli/consistency/registry.mjs +13 -0
  67. package/dist/cli/consistency/registry.mjs.map +1 -1
  68. package/dist/cli/coordination/agent-identity.d.mts +9 -0
  69. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  70. package/dist/cli/coordination/agent-identity.mjs +11 -0
  71. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  72. package/dist/cli/coordination/event-log.d.mts +27 -0
  73. package/dist/cli/coordination/event-log.d.mts.map +1 -1
  74. package/dist/cli/coordination/event-log.mjs +72 -2
  75. package/dist/cli/coordination/event-log.mjs.map +1 -1
  76. package/dist/cli/dispatch.d.mts.map +1 -1
  77. package/dist/cli/dispatch.mjs +14 -1
  78. package/dist/cli/dispatch.mjs.map +1 -1
  79. package/dist/cli/gate/registry.d.mts +11 -0
  80. package/dist/cli/gate/registry.d.mts.map +1 -1
  81. package/dist/cli/gate/registry.mjs +13 -0
  82. package/dist/cli/gate/registry.mjs.map +1 -1
  83. package/dist/cli/help.d.mts.map +1 -1
  84. package/dist/cli/help.mjs +1 -0
  85. package/dist/cli/help.mjs.map +1 -1
  86. package/dist/cli/host/detect.d.mts +1 -0
  87. package/dist/cli/host/detect.d.mts.map +1 -1
  88. package/dist/cli/host/detect.mjs +5 -0
  89. package/dist/cli/host/detect.mjs.map +1 -1
  90. package/dist/cli/route/envelope.d.mts +68 -4
  91. package/dist/cli/route/envelope.d.mts.map +1 -1
  92. package/dist/cli/route/envelope.mjs +140 -103
  93. package/dist/cli/route/envelope.mjs.map +1 -1
  94. package/dist/cli/route/skill-discover.d.mts +9 -0
  95. package/dist/cli/route/skill-discover.d.mts.map +1 -1
  96. package/dist/cli/route/skill-discover.mjs +11 -0
  97. package/dist/cli/route/skill-discover.mjs.map +1 -1
  98. package/dist/cli/skill-author/contract.d.mts +1 -0
  99. package/dist/cli/skill-author/contract.d.mts.map +1 -1
  100. package/dist/cli/skill-author/contract.mjs +7 -0
  101. package/dist/cli/skill-author/contract.mjs.map +1 -1
  102. package/dist/cli/task/worktree-cleanup.d.mts +9 -1
  103. package/dist/cli/task/worktree-cleanup.d.mts.map +1 -1
  104. package/dist/cli/task/worktree-cleanup.mjs +190 -9
  105. package/dist/cli/task/worktree-cleanup.mjs.map +1 -1
  106. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  107. package/dist/plugin/agents/apt-planner.md +1 -1
  108. package/dist/plugin/skills/apt/SKILL.md +111 -5
  109. package/dist/plugin/skills/apt-author-skill/SKILL.md +11 -0
  110. package/dist/plugin/skills/apt-bootstrap/SKILL.md +1 -0
  111. package/dist/plugin/skills/apt-classify/SKILL.md +1 -0
  112. package/dist/plugin/skills/apt-close-task/SKILL.md +1 -0
  113. package/dist/plugin/skills/apt-create-docs/SKILL.md +1 -0
  114. package/dist/plugin/skills/apt-debug/SKILL.md +2 -0
  115. package/dist/plugin/skills/apt-design/SKILL.md +2 -0
  116. package/dist/plugin/skills/apt-discuss/SKILL.md +2 -0
  117. package/dist/plugin/skills/apt-docs/SKILL.md +2 -0
  118. package/dist/plugin/skills/apt-execute/SKILL.md +1 -0
  119. package/dist/plugin/skills/apt-mockup/SKILL.md +2 -0
  120. package/dist/plugin/skills/apt-pause/SKILL.md +1 -0
  121. package/dist/plugin/skills/apt-personas/SKILL.md +1 -0
  122. package/dist/plugin/skills/apt-plan/SKILL.md +2 -0
  123. package/dist/plugin/skills/apt-pr-review/SKILL.md +1 -0
  124. package/dist/plugin/skills/apt-quick/SKILL.md +2 -0
  125. package/dist/plugin/skills/apt-resume/SKILL.md +1 -0
  126. package/dist/plugin/skills/apt-review/SKILL.md +1 -0
  127. package/dist/plugin/skills/apt-roadmap/SKILL.md +1 -0
  128. package/dist/plugin/skills/apt-roundtable/SKILL.md +2 -0
  129. package/dist/plugin/skills/apt-run/SKILL.md +1 -0
  130. package/dist/plugin/skills/apt-scan/SKILL.md +1 -0
  131. package/dist/plugin/skills/apt-setup/SKILL.md +1 -0
  132. package/dist/plugin/skills/apt-ship/SKILL.md +6 -5
  133. package/dist/plugin/skills/apt-stress-test/SKILL.md +1 -0
  134. package/dist/plugin/skills/apt-terminal/SKILL.md +1 -0
  135. package/dist/plugin/skills/apt-update/SKILL.md +5 -0
  136. package/dist/plugin/skills/apt-verify/SKILL.md +1 -0
  137. package/dist/plugin/skills/apt-verify-proof/SKILL.md +1 -0
  138. package/dist/types/config.d.ts +85 -0
  139. package/dist/types/config.d.ts.map +1 -1
  140. package/package.json +125 -125
  141. package/prompts/coder.md +2 -0
  142. package/prompts/planner.md +12 -0
  143. package/prompts/spec_writer.md +9 -0
  144. package/skills/apt/SKILL.md +1 -0
  145. package/skills/apt-close-task/SKILL.md +32 -1
  146. package/skills/apt-debug/SKILL.md +39 -6
  147. package/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  148. package/skills/apt-diagram/SKILL.md +342 -0
  149. package/skills/apt-diagram/appendices/design-discipline.md +97 -0
  150. package/skills/apt-discuss/SKILL.md +25 -0
  151. package/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  152. package/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  153. package/skills/apt-execute/SKILL.md +48 -5
  154. package/skills/apt-execute/appendices/tdd-mode.md +107 -0
  155. package/skills/apt-improve/DEEPENING.md +84 -0
  156. package/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  157. package/skills/apt-improve/LANGUAGE.md +104 -0
  158. package/skills/apt-improve/SKILL.md +141 -0
  159. package/skills/apt-plan/SKILL.md +147 -4
  160. package/skills/apt-planner.md +42 -1
  161. package/skills/apt-pr-review/SKILL.md +46 -16
  162. package/skills/apt-prototype/LOGIC.md +109 -0
  163. package/skills/apt-prototype/SKILL.md +143 -0
  164. package/skills/apt-prototype/UI.md +90 -0
  165. package/skills/apt-quick/SKILL.md +30 -0
  166. package/skills/apt-review/SKILL.md +2 -0
  167. package/skills/apt-run/SKILL.md +32 -4
  168. package/skills/apt-setup/SKILL.md +128 -2
  169. package/skills/apt-ship/SKILL.md +47 -0
  170. package/skills/apt-triage/AGENT-BRIEF.md +84 -0
  171. package/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  172. package/skills/apt-triage/SKILL.md +169 -0
  173. package/skills/apt-verify/SKILL.md +3 -0
  174. package/skills/apt-verify-proof/SKILL.md +3 -0
  175. package/skills/apt-watch-ci/SKILL.md +163 -0
  176. package/skills/apt-zoom-out/SKILL.md +130 -0
  177. package/src/cli/ci-watch/lock.mjs +178 -0
  178. package/src/cli/ci-watch/state.mjs +103 -0
  179. package/src/cli/ci-watch/stop-matrix.mjs +181 -0
  180. package/src/cli/cli-wrappers/ci-watch.mjs +9 -0
  181. package/src/cli/commands/adr.mjs +243 -0
  182. package/src/cli/commands/ci-watch.mjs +503 -0
  183. package/src/cli/commands/context.mjs +244 -0
  184. package/src/cli/commands/event.mjs +63 -24
  185. package/src/cli/commands/host-detect.mjs +33 -7
  186. package/src/cli/commands/modes.mjs +215 -0
  187. package/src/cli/commands/route.mjs +1 -1
  188. package/src/cli/commands/task.mjs +125 -18
  189. package/src/cli/commands/tokens.mjs +157 -6
  190. package/src/cli/commands/triage.mjs +277 -0
  191. package/src/cli/config/post-merge-sweep.mjs +49 -3
  192. package/src/cli/consistency/registry.mjs +14 -0
  193. package/src/cli/coordination/agent-identity.mjs +12 -0
  194. package/src/cli/coordination/event-log.mjs +73 -2
  195. package/src/cli/dispatch.mjs +15 -1
  196. package/src/cli/gate/registry.mjs +14 -0
  197. package/src/cli/help.mjs +1 -0
  198. package/src/cli/host/detect.mjs +5 -0
  199. package/src/cli/route/envelope.mjs +140 -106
  200. package/src/cli/route/skill-discover.mjs +12 -0
  201. package/src/cli/skill-author/contract.mjs +7 -0
  202. package/src/cli/task/worktree-cleanup.mjs +191 -9
  203. package/templates/adr-format.md +56 -0
  204. package/templates/config.json +4 -0
  205. 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()
@@ -15,6 +15,7 @@ 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
17
  import { getTaskIsolationConfig } 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'
@@ -43,7 +44,7 @@ import { err, exitWith, ok } from '../util/result.mjs'
43
44
  export function cmdTask(subcommand, projectDir, extraArgs) {
44
45
  if (!subcommand)
45
46
  return err(
46
- 'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|prune> <project-dir> [flags]',
47
+ 'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]',
47
48
  )
48
49
  if (!projectDir) return err('Usage: apt-tools task ' + subcommand + ' <project-dir> [flags]')
49
50
 
@@ -457,6 +458,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
457
458
  const taskId = flags.get('id')
458
459
  if (!taskId) return err('Required: --id <task-id>')
459
460
  const verdict = flags.get('verdict') || 'completed'
461
+ // Internal flag set by `task close-merged` when
462
+ // GitHub confirmed state === 'MERGED'. Threads through to
463
+ // computeWorktreeCleanup so cleanup skips local merge-back and
464
+ // runs fetch + ff-only instead.
465
+ const prMerged = flags.has('pr-merged-remotely')
460
466
 
461
467
  if (!existsSync(statePath)) return err('No state.json found')
462
468
 
@@ -521,11 +527,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
521
527
 
522
528
  // Worktree cleanup hook — honors task_isolation.cleanup_on_close
523
529
  // (never | ask | auto). See src/cli/task/worktree-cleanup.mjs.
530
+ // prMerged threads from `task close-merged`
531
+ // via --pr-merged-remotely; when true, cleanup runs fetch +
532
+ // ff-only instead of local merge-back.
524
533
  let cleanup = null
525
534
  if (task.worktree_path) {
526
535
  const isolation = getTaskIsolationConfig(targetDir)
527
536
  const mode = isolation.cleanup_on_close || 'ask'
528
- cleanup = computeWorktreeCleanup(task, mode, targetDir)
537
+ cleanup = computeWorktreeCleanup(task, mode, targetDir, { prMerged })
529
538
  }
530
539
 
531
540
  // Best-effort team sync: commit shared bookkeeping so teammates
@@ -721,6 +730,21 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
721
730
  let lifecycleTransition = null
722
731
  if (flags.has('lifecycle-phase')) {
723
732
  task.lifecycle_phase = flags.get('lifecycle-phase')
733
+ // R6 (roundtable 2026-05-14) — compute elapsed_ms since the
734
+ // previous LIFECYCLE event (or task.created) for phase-boundary
735
+ // telemetry. The 'task.lifecycle.' opPrefix is critical: without
736
+ // it, the most-recent event lookup picks up high-frequency events
737
+ // like task.tokens.recorded, which would silently corrupt
738
+ // elapsed_ms attribution (PR #115 review finding LOG-001).
739
+ const prev = findMostRecentEventForTask(targetDir, taskId, 'task.lifecycle.')
740
+ let prevTime = prev ? new Date(prev.t).getTime() : null
741
+ // LOG-003: when no prior lifecycle event exists (first transition),
742
+ // fall back to task.created_at so planning phase duration is captured.
743
+ if (!prevTime && task.created_at) {
744
+ const createdMs = new Date(task.created_at).getTime()
745
+ if (Number.isFinite(createdMs)) prevTime = createdMs
746
+ }
747
+ const elapsed_ms = prevTime ? Date.now() - prevTime : null
724
748
  // R10 — emit the lifecycle transition event even when the
725
749
  // side-effect helper decides the phase spec is a no-op; the
726
750
  // caller still changed task.lifecycle_phase.
@@ -732,6 +756,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
732
756
  scope: task.scope || 'project',
733
757
  milestone_id: task.milestone_id ?? null,
734
758
  phase_id: task.phase_id ?? null,
759
+ elapsed_ms,
735
760
  },
736
761
  })
737
762
  // C56 B5 — exhaustive lifecycle→roadmap-phase map. `null`
@@ -1253,20 +1278,27 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1253
1278
  // Drive the full close path via an in-process CLI call so all of
1254
1279
  // task close's side effects (team-sync, worktree cleanup, phase flip
1255
1280
  // 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
- )
1281
+ const closeArgs = [
1282
+ resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
1283
+ 'task',
1284
+ 'close',
1285
+ targetDir,
1286
+ '--id',
1287
+ id,
1288
+ '--verdict',
1289
+ closeVerdict,
1290
+ ]
1291
+ // Only signal pr_merged_remotely when GitHub
1292
+ // confirmed MERGED. CLOSED-not-merged is a rejection path; leaving
1293
+ // cleanup behavior unchanged there is intentional (spec §11 R3).
1294
+ if (parsed.state === 'MERGED') {
1295
+ closeArgs.push('--pr-merged-remotely')
1296
+ }
1297
+ const closeRes = spawnSync('node', closeArgs, {
1298
+ cwd: targetDir,
1299
+ encoding: 'utf-8',
1300
+ env: { ...process.env },
1301
+ })
1270
1302
  if (closeRes.status !== 0) {
1271
1303
  errors.push({
1272
1304
  id,
@@ -1317,6 +1349,36 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1317
1349
  verdict: closeVerdict,
1318
1350
  mergedAt: parsed.mergedAt ?? null,
1319
1351
  })
1352
+
1353
+ // Append a pending_narration[] ledger entry on successful merged close
1354
+ // so /apt:close-task --narrate-only can drain it later. The ledger is
1355
+ // additive-only; failure to write must never undo the close above
1356
+ // (R4 ledger leak — caller can manually invoke the narrator).
1357
+ if (closeVerdict === 'approved') {
1358
+ try {
1359
+ withFileLock(
1360
+ statePath,
1361
+ () => {
1362
+ const fresh = JSON.parse(readFileSync(statePath, 'utf-8'))
1363
+ if (!Array.isArray(fresh.pending_narration)) fresh.pending_narration = []
1364
+ fresh.pending_narration.push({
1365
+ task_id: id,
1366
+ scope: task.scope || 'project',
1367
+ phase_id: task.phase_id ?? null,
1368
+ closed_at: new Date().toISOString(),
1369
+ pr_number: parsed.number ?? null,
1370
+ })
1371
+ atomicWriteJson(statePath, fresh)
1372
+ },
1373
+ { projectRoot: targetDir },
1374
+ )
1375
+ } catch (e) {
1376
+ errors.push({
1377
+ id,
1378
+ reason: `ledger-write-failed: ${e.message ?? String(e)}`,
1379
+ })
1380
+ }
1381
+ }
1320
1382
  }
1321
1383
 
1322
1384
  return ok({
@@ -1329,6 +1391,51 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1329
1391
  })
1330
1392
  }
1331
1393
 
1394
+ case 'narration-drain': {
1395
+ // Remove a single pending_narration[] row under the same withFileLock
1396
+ // guard task close uses. Caller (apt:close-task --narrate-only) invokes
1397
+ // this once per drained entry AFTER its background narrator spawn
1398
+ // returns — so a spawn failure leaves the row in place for retry.
1399
+ const taskId = flags.get('task')
1400
+ if (!taskId) return err('Required: --task <task-id>')
1401
+
1402
+ if (!existsSync(statePath)) {
1403
+ return ok({
1404
+ status: 'ok',
1405
+ command: 'task-narration-drain',
1406
+ removed: null,
1407
+ remaining: 0,
1408
+ reason: 'no-state',
1409
+ })
1410
+ }
1411
+
1412
+ let removed = null
1413
+ let remaining = 0
1414
+ withFileLock(
1415
+ statePath,
1416
+ () => {
1417
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'))
1418
+ const ledger = Array.isArray(state.pending_narration) ? state.pending_narration : []
1419
+ const idx = ledger.findIndex((row) => row && row.task_id === taskId)
1420
+ if (idx >= 0) {
1421
+ removed = ledger[idx]
1422
+ ledger.splice(idx, 1)
1423
+ state.pending_narration = ledger
1424
+ atomicWriteJson(statePath, state)
1425
+ }
1426
+ remaining = ledger.length
1427
+ },
1428
+ { projectRoot: targetDir },
1429
+ )
1430
+
1431
+ return ok({
1432
+ status: 'ok',
1433
+ command: 'task-narration-drain',
1434
+ removed,
1435
+ remaining,
1436
+ })
1437
+ }
1438
+
1332
1439
  case 'prune': {
1333
1440
  // C56 A4 — retention verb. Removes `.aperant/tasks/{id}/` directories
1334
1441
  // whose task row is in `state.completed_tasks[]` AND whose completion
@@ -1426,7 +1533,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1426
1533
 
1427
1534
  default:
1428
1535
  return err(
1429
- `Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, prune`,
1536
+ `Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`,
1430
1537
  )
1431
1538
  }
1432
1539
  }
@@ -10,10 +10,12 @@
10
10
  * - state.active_tasks[id].tokens — flat rolled-up summary (downstream).
11
11
  *
12
12
  * Subcommands:
13
- * record — append one entry from a Claude Code transcript or manual --input/--output.
14
- * tally — emit a TaskCostSummary computed via CostTracker.
15
- * compare — emit { aperant, baseline, ratio } (baseline JSON optional; C23 hook).
16
- * list — scan all .aperant/tasks/{*}/tokens.json and emit a flat sorted list.
13
+ * record — append one entry from a Claude Code transcript or manual --input/--output.
14
+ * tally — emit a TaskCostSummary computed via CostTracker.
15
+ * compare — emit { aperant, baseline, ratio } (baseline JSON optional; C23 hook).
16
+ * list — scan all .aperant/tasks/{*}/tokens.json and emit a flat sorted list.
17
+ * phase-summary — join tokens.json × task.lifecycle.* events by phase
18
+ * (roundtable 2026-05-14 R6 — phase-boundary telemetry).
17
19
  *
18
20
  * All writes go through atomicWriteJson + withFileLock (R3), state mutations
19
21
  * through createCoordinationStore.updateState (R8), and a lifecycle event is
@@ -29,6 +31,7 @@ import { dirname, join, resolve } from 'node:path'
29
31
  import { calculateCost } from '../../cost/pricing.mjs'
30
32
  import { CostTracker } from '../../cost/tracker.mjs'
31
33
  import { parseClaudeCodeTranscript } from '../../cost/transcript.mjs'
34
+ import { readTaskEventsOverDays } from '../coordination/event-log.mjs'
32
35
  import { createCoordinationStore } from '../coordination/store.mjs'
33
36
  import { withFileLock } from '../util/fs-lock.mjs'
34
37
  import { atomicWriteJson } from '../util/io.mjs'
@@ -67,6 +70,12 @@ function nowIso() {
67
70
  /**
68
71
  * Convert the on-disk JSON entry shape (snake_case + cost_usd) into the
69
72
  * canonical CostEntry shape used by CostTracker (camelCase + costUsd).
73
+ *
74
+ * R6 (roundtable 2026-05-14): `phase` is an optional per-entry tag indicating
75
+ * which pipeline phase (planning | executing | verifying | reviewing) the
76
+ * session belonged to. Carried through to enable `tokens phase-summary` to
77
+ * group by phase. Older entries lacking `phase` keep working — the field is
78
+ * additive and downstream consumers tolerate `undefined`.
70
79
  */
71
80
  function entryToCostEntry(taskId, entry) {
72
81
  return {
@@ -82,6 +91,7 @@ function entryToCostEntry(taskId, entry) {
82
91
  costUsd: typeof entry.cost_usd === 'number' ? entry.cost_usd : 0,
83
92
  timestamp: entry.timestamp,
84
93
  durationMs: entry.duration_ms || 0,
94
+ phase: entry.phase || null,
85
95
  }
86
96
  }
87
97
 
@@ -162,6 +172,20 @@ function cmdRecord(targetDir, args) {
162
172
  // DEFAULT_MODEL because there's no transcript to source it from.
163
173
  const modelOverride = flagOf(args, '--model')
164
174
  const providerId = flagOf(args, '--provider') || DEFAULT_PROVIDER
175
+ // R6 (roundtable 2026-05-14): optional --phase tags this batch of entries
176
+ // with the lifecycle phase they belong to. When omitted, phase-summary
177
+ // falls back to inferring phase from lifecycle event timestamps.
178
+ //
179
+ // SEC-001 (PR #115 review): whitelist accepted values to keep parity with
180
+ // --track (which also enforces a four-value allowlist). Unknown values
181
+ // fail closed rather than persisting arbitrary strings into tokens.json.
182
+ const phaseFlag = flagOf(args, '--phase')
183
+ const VALID_PHASES = new Set(['planning', 'executing', 'verifying', 'reviewing'])
184
+ if (phaseFlag !== null && !VALID_PHASES.has(phaseFlag)) {
185
+ throw new TokensUsageError(
186
+ `--phase must be one of: planning, executing, verifying, reviewing (got: ${phaseFlag})`,
187
+ )
188
+ }
165
189
 
166
190
  if (!transcript && (inputStr === null || outputStr === null)) {
167
191
  throw new TokensUsageError('Provide --transcript <path> OR both --input N --output N')
@@ -195,6 +219,7 @@ function cmdRecord(targetDir, args) {
195
219
  timestamp: p.timestamp || ts,
196
220
  source: 'transcript',
197
221
  transcript_path: transcript,
222
+ ...(phaseFlag ? { phase: phaseFlag } : {}),
198
223
  })
199
224
  }
200
225
  } else {
@@ -222,6 +247,7 @@ function cmdRecord(targetDir, args) {
222
247
  cost_usd: costUsd,
223
248
  timestamp: nowIso(),
224
249
  source: 'manual',
250
+ ...(phaseFlag ? { phase: phaseFlag } : {}),
225
251
  })
226
252
  }
227
253
 
@@ -419,12 +445,135 @@ function cmdList(targetDir, args) {
419
445
  })
420
446
  }
421
447
 
448
+ /**
449
+ * Read lifecycle events for a task across today + yesterday (UTC).
450
+ * Returns events sorted ascending by `t`. Best-effort: missing event log
451
+ * directories return an empty array — phase-summary degrades gracefully
452
+ * when telemetry isn't enabled.
453
+ */
454
+ function readTaskLifecycleEvents(targetDir, taskId) {
455
+ // Delegates to the shared two-day window helper in event-log.mjs so
456
+ // tokens.mjs and the lifecycle-event helpers cannot drift on the window
457
+ // strategy (PR #115 review findings QUA-001 + PRF-001).
458
+ const events = readTaskEventsOverDays(targetDir, taskId, 'task.lifecycle.')
459
+ events.sort((a, b) => Date.parse(a.t) - Date.parse(b.t))
460
+ return events
461
+ }
462
+
463
+ /**
464
+ * Infer phase for a token entry by finding the most recent lifecycle event
465
+ * whose timestamp <= entry timestamp. Returns null when no lifecycle event
466
+ * precedes the entry (e.g. entries recorded before any lifecycle transition).
467
+ */
468
+ function inferPhaseForEntry(entryTime, lifecycleEvents) {
469
+ const entryMs = Date.parse(entryTime)
470
+ if (!Number.isFinite(entryMs)) return null
471
+ let match = null
472
+ for (const evt of lifecycleEvents) {
473
+ const evtMs = Date.parse(evt.t)
474
+ // LOG-002 (PR #115 review): a malformed evt.t parses to NaN. `NaN <=
475
+ // entryMs` is false, which would trip an `else break` and drop all
476
+ // subsequent valid events. Skip NaN entries instead so a single bad
477
+ // row can't poison phase inference.
478
+ if (!Number.isFinite(evtMs)) continue
479
+ if (evtMs <= entryMs) match = evt
480
+ else break
481
+ }
482
+ return match?.data?.lifecycle_phase ?? null
483
+ }
484
+
485
+ /**
486
+ * phase-summary — join tokens.json × task.lifecycle.* events by task_id +
487
+ * phase. Roundtable 2026-05-14 R6 — provides the A/B baseline needed to
488
+ * evaluate sequential vs sub-agents mode without per-call profiling.
489
+ *
490
+ * Each row: { phase, duration_ms, input_tokens, output_tokens, cost_usd, session_count }.
491
+ * Duration is summed from lifecycle event `elapsed_ms`. Tokens are summed
492
+ * from entries whose `phase` is set (or inferred from event timestamps when
493
+ * the entry has no `phase` tag).
494
+ */
495
+ function cmdPhaseSummary(targetDir, args) {
496
+ const taskId = flagOf(args, '--task-id')
497
+ if (!taskId) throw new TokensUsageError('--task-id is required for tokens phase-summary')
498
+
499
+ const tokensPath = tokensFilePath(targetDir, taskId)
500
+ const doc = readTokensFile(tokensPath)
501
+ const entries = doc?.entries || []
502
+
503
+ const lifecycleEvents = readTaskLifecycleEvents(targetDir, taskId)
504
+
505
+ // Per-phase accumulator.
506
+ const phases = new Map()
507
+ function bump(phase, patch) {
508
+ if (!phase) return
509
+ if (!phases.has(phase)) {
510
+ phases.set(phase, {
511
+ phase,
512
+ duration_ms: 0,
513
+ input_tokens: 0,
514
+ output_tokens: 0,
515
+ cost_usd: 0,
516
+ session_count: 0,
517
+ })
518
+ }
519
+ const row = phases.get(phase)
520
+ row.duration_ms += patch.duration_ms || 0
521
+ row.input_tokens += patch.input_tokens || 0
522
+ row.output_tokens += patch.output_tokens || 0
523
+ row.cost_usd += patch.cost_usd || 0
524
+ row.session_count += patch.session_count || 0
525
+ }
526
+
527
+ // Sum durations from lifecycle events (elapsed_ms attributed to the phase
528
+ // THIS event represents — i.e. the phase that was just entered. The
529
+ // elapsed_ms is "time spent in the prior phase before transitioning into
530
+ // this one." Attribute to the prior phase to keep the model intuitive.
531
+ let priorPhase = null
532
+ for (const evt of lifecycleEvents) {
533
+ const elapsed = evt.data?.elapsed_ms
534
+ if (priorPhase && typeof elapsed === 'number') {
535
+ bump(priorPhase, { duration_ms: elapsed })
536
+ }
537
+ priorPhase = evt.data?.lifecycle_phase || null
538
+ }
539
+
540
+ // Sum tokens per phase. Prefer entry.phase when set; otherwise infer from
541
+ // lifecycle event timestamps.
542
+ for (const entry of entries) {
543
+ const phase = entry.phase || inferPhaseForEntry(entry.timestamp, lifecycleEvents)
544
+ if (!phase) continue
545
+ bump(phase, {
546
+ input_tokens: entry.input_tokens || 0,
547
+ output_tokens: entry.output_tokens || 0,
548
+ cost_usd: typeof entry.cost_usd === 'number' ? entry.cost_usd : 0,
549
+ session_count: 1,
550
+ })
551
+ }
552
+
553
+ const rows = Array.from(phases.values()).map((r) => ({
554
+ ...r,
555
+ cost_usd: Number(r.cost_usd.toFixed(6)),
556
+ }))
557
+
558
+ return ok({
559
+ status: 'ok',
560
+ command: 'tokens',
561
+ action: 'phase-summary',
562
+ task_id: taskId,
563
+ entry_count: entries.length,
564
+ lifecycle_event_count: lifecycleEvents.length,
565
+ phases: rows,
566
+ })
567
+ }
568
+
422
569
  /**
423
570
  * Top-level dispatcher.
424
571
  */
425
572
  export function cmdTokens(subcommand, projectDir, extraArgs = []) {
426
573
  if (!subcommand) {
427
- return err('Usage: apt-tools tokens <record|tally|compare|list> <project-dir> [flags]')
574
+ return err(
575
+ 'Usage: apt-tools tokens <record|tally|compare|list|phase-summary> <project-dir> [flags]',
576
+ )
428
577
  }
429
578
  if (!projectDir) {
430
579
  return err(`Usage: apt-tools tokens ${subcommand} <project-dir> [flags]`)
@@ -444,9 +593,11 @@ export function cmdTokens(subcommand, projectDir, extraArgs = []) {
444
593
  return cmdCompare(targetDir, extraArgs)
445
594
  case 'list':
446
595
  return cmdList(targetDir, extraArgs)
596
+ case 'phase-summary':
597
+ return cmdPhaseSummary(targetDir, extraArgs)
447
598
  default:
448
599
  return err(
449
- `Unknown tokens subcommand: ${subcommand}. Expected: record, tally, compare, list`,
600
+ `Unknown tokens subcommand: ${subcommand}. Expected: record, tally, compare, list, phase-summary`,
450
601
  )
451
602
  }
452
603
  } catch (e) {