@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
@@ -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) {
@@ -0,0 +1,286 @@
1
+ /**
2
+ * commands/triage.mjs — `apt-tools triage` (Pocock adoption ST-10, AC9).
3
+ *
4
+ * 5-state triage state machine with extensible metadata for v2 OSS
5
+ * contributor primitives. Backend-configured: writes to
6
+ * `.aperant/tasks/{task-id}/triage.json` by default (local-only); other
7
+ * backends (github-issues, app-inbox) ship as not-implemented stubs.
8
+ *
9
+ * Subcommands:
10
+ * apt-tools triage init <project-dir> --id <task-id> [--category bug|enhancement] [--extra '{...}']
11
+ * apt-tools triage transition <project-dir> --id <task-id> --to <state> [--reason <text>] [--by user|agent]
12
+ * apt-tools triage get <project-dir> --id <task-id>
13
+ *
14
+ * States: needs-triage | needs-info | ready-for-agent | ready-for-human | wontfix
15
+ * Categories: bug | enhancement
16
+ *
17
+ * Fast Path Guarantee (ID-05): QUICK-routed tasks NEVER enter triage.
18
+ * The CLI does NOT enforce this directly — the apt:triage SKILL.md
19
+ * routes around QUICK tasks before invoking the CLI. The CLI is the
20
+ * mechanical state-machine surface; the routing is the skill body's job.
21
+ */
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import { dirname, join, resolve } from 'node:path'
25
+ import { parseFlags } from '../util/args.mjs'
26
+ import { err, exitWith, ok } from '../util/result.mjs'
27
+
28
+ const STATES = ['needs-triage', 'needs-info', 'ready-for-agent', 'ready-for-human', 'wontfix']
29
+
30
+ const CATEGORIES = ['bug', 'enhancement']
31
+
32
+ // The 8 valid transitions per spec.md AC9. Anything else is rejected.
33
+ const VALID_TRANSITIONS = new Set([
34
+ 'needs-triage->needs-info',
35
+ 'needs-triage->ready-for-agent',
36
+ 'needs-triage->ready-for-human',
37
+ 'needs-triage->wontfix',
38
+ 'needs-info->ready-for-agent',
39
+ 'needs-info->ready-for-human',
40
+ 'needs-info->wontfix',
41
+ 'needs-info->needs-triage',
42
+ ])
43
+
44
+ const BACKENDS = new Set(['local-only', 'github-issues', 'app-inbox'])
45
+
46
+ // Two task-id shapes coexist:
47
+ // - Legacy date-first: YY-MM-DD_slug-XXXX (preserved so the ~159
48
+ // pre-slug-first tasks already on disk keep resolving).
49
+ // - Current slug-first: slug_YY-MM-DD with optional -N counter
50
+ // emitted by generateTaskId since the slug-first rename.
51
+ const TASK_ID_RE_LEGACY = /^[0-9]{2}-[0-9]{2}-[0-9]{2}_[a-z0-9-]+-[a-z0-9]{4}$/
52
+ const TASK_ID_RE_SLUG_FIRST = /^[a-z0-9][a-z0-9-]*_[0-9]{2}-[0-9]{2}-[0-9]{2}(-\d+)?$/
53
+
54
+ /**
55
+ * Read .aperant/config.json and extract task_tracking.backend.
56
+ * Defaults to 'local-only' if the file or the key is missing.
57
+ *
58
+ * @param {string} absProjectDir
59
+ * @returns {string}
60
+ */
61
+ function readBackend(absProjectDir) {
62
+ const configPath = join(absProjectDir, '.aperant', 'config.json')
63
+ if (!existsSync(configPath)) return 'local-only'
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
66
+ const backend = parsed?.task_tracking?.backend
67
+ if (typeof backend === 'string' && BACKENDS.has(backend)) return backend
68
+ return 'local-only'
69
+ } catch {
70
+ return 'local-only'
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Triage record file path.
76
+ *
77
+ * @param {string} absProjectDir
78
+ * @param {string} taskId
79
+ */
80
+ function triagePath(absProjectDir, taskId) {
81
+ return join(absProjectDir, '.aperant', 'tasks', taskId, 'triage.json')
82
+ }
83
+
84
+ /**
85
+ * Read the current triage record, or null if it doesn't exist.
86
+ *
87
+ * @param {string} path
88
+ */
89
+ function readTriage(path) {
90
+ if (!existsSync(path)) return null
91
+ try {
92
+ return JSON.parse(readFileSync(path, 'utf-8'))
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Write a triage record.
100
+ *
101
+ * @param {string} path
102
+ * @param {object} record
103
+ */
104
+ function writeTriage(path, record) {
105
+ const dir = dirname(path)
106
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
107
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
108
+ }
109
+
110
+ /**
111
+ * Apply the backend mirror step. local-only writes to disk; the other
112
+ * backends return a not-implemented envelope so callers can handle the
113
+ * stub gracefully.
114
+ *
115
+ * @param {string} backend
116
+ * @param {string} absProjectDir
117
+ * @param {string} taskId
118
+ * @param {object} record
119
+ * @returns {null | { status: 'not-implemented', backend: string, v2_issue: string }}
120
+ */
121
+ function mirrorToBackend(backend, absProjectDir, taskId, record) {
122
+ if (backend === 'local-only') {
123
+ writeTriage(triagePath(absProjectDir, taskId), record)
124
+ return null
125
+ }
126
+ // v1 stubs: github-issues and app-inbox return not-implemented.
127
+ return { status: 'not-implemented', backend, v2_issue: '#TBD' }
128
+ }
129
+
130
+ /**
131
+ * Entry: `apt-tools triage <subcommand> <project-dir> [flags]`.
132
+ */
133
+ export function cmdTriage(subcommand, projectDir, extraArgs) {
134
+ if (!subcommand || !['init', 'transition', 'get'].includes(subcommand)) {
135
+ return err(
136
+ 'Usage: apt-tools triage <init|transition|get> <project-dir> --id <task-id> [...]',
137
+ 1,
138
+ { command: 'triage' },
139
+ )
140
+ }
141
+ if (!projectDir) {
142
+ return err('project-dir is required', 1, { command: `triage-${subcommand}` })
143
+ }
144
+ const absProjectDir = resolve(projectDir)
145
+ if (!existsSync(absProjectDir)) {
146
+ return err(`project directory not found: ${absProjectDir}`, 1, {
147
+ command: `triage-${subcommand}`,
148
+ })
149
+ }
150
+
151
+ const flags = parseFlags(extraArgs ?? [])
152
+ const taskId = flags.get('id')
153
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
154
+ return err('--id <task-id> is required', 1, { command: `triage-${subcommand}` })
155
+ }
156
+ if (!TASK_ID_RE_LEGACY.test(taskId) && !TASK_ID_RE_SLUG_FIRST.test(taskId)) {
157
+ return err(
158
+ `--id must match pattern <slug_YY-MM-DD[-N]> (e.g. fix-login_24-01-15) or legacy <YY-MM-DD_slug-XXXX>`,
159
+ 1,
160
+ { command: `triage-${subcommand}` },
161
+ )
162
+ }
163
+ const backend = readBackend(absProjectDir)
164
+
165
+ if (subcommand === 'init') {
166
+ // Optional --category, optional --extra (JSON string).
167
+ const category = flags.get('category')
168
+ if (category !== undefined && !CATEGORIES.includes(String(category))) {
169
+ return err(`--category must be one of: ${CATEGORIES.join(' | ')}`, 1, {
170
+ command: 'triage-init',
171
+ })
172
+ }
173
+ let extra = {}
174
+ const extraRaw = flags.get('extra')
175
+ if (typeof extraRaw === 'string' && extraRaw.length > 0) {
176
+ try {
177
+ const parsed = JSON.parse(extraRaw)
178
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
179
+ extra = parsed
180
+ } else {
181
+ return err('--extra must be a JSON object string', 1, { command: 'triage-init' })
182
+ }
183
+ } catch (e) {
184
+ return err(`--extra is not valid JSON: ${e.message}`, 1, { command: 'triage-init' })
185
+ }
186
+ }
187
+
188
+ const existing = readTriage(triagePath(absProjectDir, taskId))
189
+ if (existing) {
190
+ return err(`triage record already exists for ${taskId}`, 1, {
191
+ command: 'triage-init',
192
+ current_state: existing.state,
193
+ })
194
+ }
195
+
196
+ const record = {
197
+ task_id: taskId,
198
+ state: 'needs-triage',
199
+ category: typeof category === 'string' ? category : null,
200
+ transitions: [],
201
+ extra,
202
+ }
203
+ const stub = mirrorToBackend(backend, absProjectDir, taskId, record)
204
+ if (stub) return exitWith({ command: 'triage-init', ...stub }, 0)
205
+ return ok({ status: 'ok', command: 'triage-init', backend, record })
206
+ }
207
+
208
+ if (subcommand === 'transition') {
209
+ const toState = flags.get('to')
210
+ if (typeof toState !== 'string' || !STATES.includes(toState)) {
211
+ return err(`--to <state> is required (one of: ${STATES.join(' | ')})`, 1, {
212
+ command: 'triage-transition',
213
+ })
214
+ }
215
+ // SEC-003: cap --reason to 1000 chars to prevent unbounded disk writes.
216
+ const rawReason = flags.get('reason')
217
+ const reason = typeof rawReason === 'string' ? rawReason.slice(0, 1000) : null
218
+ const by =
219
+ typeof flags.get('by') === 'string' && ['user', 'agent'].includes(String(flags.get('by')))
220
+ ? String(flags.get('by'))
221
+ : 'agent'
222
+
223
+ const current = readTriage(triagePath(absProjectDir, taskId))
224
+ if (!current) {
225
+ return err(`no triage record for ${taskId} — run \`triage init\` first`, 1, {
226
+ command: 'triage-transition',
227
+ })
228
+ }
229
+
230
+ const from = current.state
231
+ const transitionKey = `${from}->${toState}`
232
+ if (!VALID_TRANSITIONS.has(transitionKey)) {
233
+ return exitWith(
234
+ {
235
+ status: 'rejected',
236
+ command: 'triage-transition',
237
+ reason: 'invalid-transition',
238
+ from,
239
+ to: toState,
240
+ valid_from_current: STATES.filter((s) => VALID_TRANSITIONS.has(`${from}->${s}`)),
241
+ },
242
+ 1,
243
+ )
244
+ }
245
+
246
+ const transition = {
247
+ from,
248
+ to: toState,
249
+ at: new Date().toISOString(),
250
+ by,
251
+ reason,
252
+ }
253
+ // SEC-002: Only carry known fields forward — do NOT spread the full
254
+ // disk-backed `current` object, which could contain arbitrary unknown
255
+ // keys from a hand-edited triage.json that would silently propagate.
256
+ const next = {
257
+ task_id: current.task_id,
258
+ state: toState,
259
+ category: current.category,
260
+ extra: current.extra ?? {},
261
+ transitions: [...(current.transitions || []), transition],
262
+ }
263
+ const stub = mirrorToBackend(backend, absProjectDir, taskId, next)
264
+ if (stub) return exitWith({ command: 'triage-transition', ...stub }, 0)
265
+ return ok({
266
+ status: 'ok',
267
+ command: 'triage-transition',
268
+ backend,
269
+ transition,
270
+ record: next,
271
+ })
272
+ }
273
+
274
+ // subcommand === 'get'
275
+ const path = triagePath(absProjectDir, taskId)
276
+ const record = readTriage(path)
277
+ if (!record) {
278
+ return exitWith({ status: 'not-found', command: 'triage-get', task_id: taskId, file: path }, 0)
279
+ }
280
+ return ok({ status: 'ok', command: 'triage-get', record, backend })
281
+ }
282
+
283
+ // Exported for testing.
284
+ export const _STATES = STATES
285
+ export const _CATEGORIES = CATEGORIES
286
+ export const _VALID_TRANSITIONS = VALID_TRANSITIONS
@@ -2,6 +2,16 @@
2
2
  * post-merge-sweep.mjs — dispatch-time passive sweeper for stuck
3
3
  * `shipped-pending-merge` tasks (C57 Piece 2).
4
4
  *
5
+ * **Bookkeeping-only contract.** This hook runs the deterministic
6
+ * close half of the post-merge handoff: poll `gh pr view`, flip
7
+ * lifecycle_phase from `shipped-pending-merge` to `completed` via
8
+ * the same `task close` path, strip per-PR authorship overrides,
9
+ * and append a `pending_narration[]` ledger row. The LLM-side
10
+ * narration work (registry sync + ROADMAP prose) is explicitly
11
+ * out of scope here — the user drains the ledger via
12
+ * `/apt:close-task --narrate-only` (see packages/framework/skills/
13
+ * apt-close-task/SKILL.md §4) which is where those side effects belong.
14
+ *
5
15
  * AI agents ship a PR and walk away. Tasks then rot in
6
16
  * `shipped-pending-merge` because nothing automatic invokes
7
17
  * `apt-tools task close-merged`. This hook runs on every `apt-tools`
@@ -11,8 +21,10 @@
11
21
  * whose PRs have terminally landed.
12
22
  *
13
23
  * Non-blocking: never fails the command, never mutates exit code,
14
- * never throws. Single stderr line on actual sweep kick (not on
15
- * every dispatch).
24
+ * never throws. Two stderr lines max per dispatch: the
25
+ * `Auto-sweeping N pending PR(s)` line on actual sweep kick, plus
26
+ * the `Post-merge narration pending` banner when the ledger has
27
+ * un-drained rows. Both lines are gated by `APT_SUPPRESS_MERGE_SWEEP=1`.
16
28
  *
17
29
  * Silenced by `APT_SUPPRESS_MERGE_SWEEP=1`. Skipped when
18
30
  * `APT_INTERNAL_MERGE_SWEEP=1` is set in env (recursion guard for
@@ -90,6 +102,25 @@ export function readShippedCandidates(projectRoot) {
90
102
  return candidates.length === 0 ? null : candidates
91
103
  }
92
104
 
105
+ /**
106
+ * Pure preflight — returns the integer length of state.pending_narration[].
107
+ * Returns 0 on read/parse failure or when the ledger is absent (the banner
108
+ * is suppressed in that case).
109
+ *
110
+ * @internal
111
+ */
112
+ export function readPendingNarrationCount(projectRoot) {
113
+ const statePath = join(projectRoot, '.aperant', 'state.json')
114
+ if (!existsSync(statePath)) return 0
115
+ try {
116
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'))
117
+ const ledger = state?.pending_narration
118
+ return Array.isArray(ledger) ? ledger.length : 0
119
+ } catch {
120
+ return 0
121
+ }
122
+ }
123
+
93
124
  /**
94
125
  * Returns true if the cooldown lock is "fresh" (last sweep too recent).
95
126
  * Lock content is `{pid}:{ms-since-epoch}` but we only consult mtime.
@@ -221,9 +252,24 @@ export function maybeSweepShippedMergedTasks(projectRoot, command, options = {})
221
252
  return { kicked: false, reason: 'no-aperant-dir' }
222
253
  }
223
254
 
255
+ // Narration-pending banner — fires on EVERY dispatch cycle when
256
+ // the ledger has un-drained rows, regardless of whether a sweep is
257
+ // kicked below. Cheap read, no spawn, no cooldown. Same suppression
258
+ // env as the sweep banner (APT_SUPPRESS_MERGE_SWEEP=1 hides both).
259
+ const pendingNarration = readPendingNarrationCount(root)
260
+ if (pendingNarration > 0) {
261
+ try {
262
+ process.stderr.write(
263
+ `[apt-tools] Post-merge narration pending (${pendingNarration}) — run /apt:close-task --narrate-only to refresh features registry + ROADMAP.\n`,
264
+ )
265
+ } catch {
266
+ /* stderr write failure is non-fatal */
267
+ }
268
+ }
269
+
224
270
  const candidates = readShippedCandidates(root)
225
271
  if (!candidates) {
226
- return { kicked: false, reason: 'no-candidates' }
272
+ return { kicked: false, reason: 'no-candidates', pending_narration: pendingNarration }
227
273
  }
228
274
 
229
275
  const lockPath = join(root, '.aperant', '.merge-sweep.lock')
@@ -82,3 +82,17 @@ export function getRule(id) {
82
82
  export function __resetRegistryForTests() {
83
83
  registry.clear()
84
84
  }
85
+
86
+ /**
87
+ * Test-only helper — uniformly-named alias for the in-process cmd harness
88
+ * (run-cmd.mjs). Built-in rules self-register via the `rules/index.mjs`
89
+ * barrel at module-import time, which ESM evaluates exactly once per
90
+ * process — so the harness deliberately does NOT call this by default
91
+ * (clearing here would leave subsequent cmd invocations looking at an empty
92
+ * registry). Tests that need an empty registry import this name explicitly.
93
+ *
94
+ * @internal
95
+ */
96
+ export function __resetCaches() {
97
+ registry.clear()
98
+ }
@@ -100,3 +100,15 @@ export function getAgentIdentity(projectDir) {
100
100
  export function __resetAgentIdentityCacheForTests() {
101
101
  cache.clear()
102
102
  }
103
+
104
+ /**
105
+ * Test-only helper — uniformly-named alias for the in-process cmd harness
106
+ * (run-cmd.mjs), which imports `__resetCaches` from each cache host with a
107
+ * single consistent name. Behaviour is identical to
108
+ * `__resetAgentIdentityCacheForTests`.
109
+ *
110
+ * @internal
111
+ */
112
+ export function __resetCaches() {
113
+ cache.clear()
114
+ }