@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
@@ -0,0 +1,503 @@
1
+ /**
2
+ * commands/ci-watch.mjs — Layer 1 cmd for `/apt:watch-ci` (C28).
3
+ *
4
+ * Subcommands: start | stop | status | tick
5
+ *
6
+ * Layer 1 rule: cmdCiWatch returns `{envelope, exitCode}` and MUST NOT call
7
+ * output() / process.exit() / fail() directly. The Layer 2 wrapper in
8
+ * dispatch.mjs is responsible for transport.
9
+ *
10
+ * `tick` is single-execution per invocation (D-01). The recurring beat is
11
+ * delivered externally — by CC's CronCreate, Pi's pi-schedule-prompt, or by
12
+ * the user re-invoking `--tick` on non-cron hosts. The cmd MUST NOT enter a
13
+ * polling loop.
14
+ *
15
+ * Test seam: when `APT_TEST_GH_STUB` is set to a path on disk, the tick path
16
+ * reads the gh payload from that file instead of shelling out. The stub
17
+ * shape is `{ checks: [...], prMetadata: {...} }` — matches the classifier
18
+ * input contract in stop-matrix.mjs.
19
+ */
20
+
21
+ import { randomBytes } from 'node:crypto'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { resolve } from 'node:path'
24
+ import { acquireLock, releaseLock } from '../ci-watch/lock.mjs'
25
+ import { deleteState, listWatches, readState, writeState } from '../ci-watch/state.mjs'
26
+ import { classify, isFailing } from '../ci-watch/stop-matrix.mjs'
27
+ import { parseFlags } from '../util/args.mjs'
28
+ import { err, exitWith, ok } from '../util/result.mjs'
29
+
30
+ const DEFAULT_TICK_INTERVAL_MS = 300_000 // 5 minutes
31
+ const MAX_TICK_INTERVAL_MS = 30 * 60 * 1000 // 30 min
32
+ const WALL_BUDGET_MS = 4 * 60 * 60 * 1000 // 4 hours
33
+
34
+ function newSessionId() {
35
+ return randomBytes(8).toString('hex')
36
+ }
37
+
38
+ function nowIso() {
39
+ return new Date().toISOString()
40
+ }
41
+
42
+ /**
43
+ * Validate `--pr` is a positive-integer string before it gets interpolated
44
+ * into a filesystem path. Without this guard, a value like `--pr ../escape`
45
+ * would write the per-PR state/lock file outside `.aperant/ci-watches/`
46
+ * (path traversal via `join(..., '../escape.json')`). PR numbers on GitHub
47
+ * are positive integers, so digits-only is the correct shape.
48
+ *
49
+ * Returns the validated PR string on success, or an err() CommandResult on
50
+ * failure — the caller returns it directly.
51
+ *
52
+ * @param {string|undefined} pr
53
+ * @returns {string | import('../util/result.mjs').CommandResult}
54
+ */
55
+ function validatePr(pr) {
56
+ if (!pr) return err('Required: --pr <N>')
57
+ if (!/^[1-9]\d*$/.test(pr)) {
58
+ return err(`Invalid --pr value: ${JSON.stringify(pr)}. Expected a positive integer.`)
59
+ }
60
+ return pr
61
+ }
62
+
63
+ /**
64
+ * @param {string} subcommand
65
+ * @param {string} projectDir
66
+ * @param {string[]} extraArgs
67
+ */
68
+ export function cmdCiWatch(subcommand, projectDir, extraArgs) {
69
+ if (!subcommand) {
70
+ return err('Usage: apt-tools ci-watch <start|stop|status|tick> <project-dir> [flags]')
71
+ }
72
+ if (!projectDir) {
73
+ return err(`Usage: apt-tools ci-watch ${subcommand} <project-dir> [flags]`)
74
+ }
75
+
76
+ const root = resolve(projectDir)
77
+ const flags = parseFlags(extraArgs)
78
+
79
+ switch (subcommand) {
80
+ case 'start':
81
+ return doStart(root, flags)
82
+ case 'stop':
83
+ return doStop(root, flags)
84
+ case 'status':
85
+ return doStatus(root, flags)
86
+ case 'tick':
87
+ return doTick(root, flags)
88
+ default:
89
+ return err(`Unknown ci-watch subcommand: ${subcommand}. Expected: start, stop, status, tick`)
90
+ }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // start
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function doStart(root, flags) {
98
+ const prCheck = validatePr(flags.get('pr'))
99
+ if (typeof prCheck !== 'string') return prCheck
100
+ const pr = prCheck
101
+ const taskId = flags.get('task-id')
102
+ if (!taskId) return err('Required: --task-id <id>')
103
+ const host = flags.get('host') || 'unknown'
104
+
105
+ // Refuse to start a second watcher for the same PR — exit 2 with a
106
+ // busy envelope so the caller knows the PR is already armed. The
107
+ // orchestrator skill prints the holder's session and exits.
108
+ const existingState = readState(root, pr)
109
+ if (existingState && !existingState.error) {
110
+ return exitWith(
111
+ {
112
+ status: 'busy',
113
+ command: 'ci-watch-start',
114
+ pr: Number(pr),
115
+ reason: 'already-armed',
116
+ existing: {
117
+ task_id: existingState.task_id,
118
+ host: existingState.host,
119
+ attempts: existingState.attempts,
120
+ started_at: existingState.started_at,
121
+ deadline: existingState.deadline,
122
+ },
123
+ },
124
+ 2,
125
+ )
126
+ }
127
+
128
+ const sessionId = newSessionId()
129
+ const lockResult = acquireLock(root, pr, sessionId)
130
+ if (lockResult.status !== 'ok') {
131
+ return exitWith(
132
+ {
133
+ status: 'busy',
134
+ command: 'ci-watch-start',
135
+ pr: Number(pr),
136
+ reason: 'lock-held',
137
+ holder: lockResult.holder,
138
+ },
139
+ 2,
140
+ )
141
+ }
142
+
143
+ try {
144
+ const existing = readState(root, pr)
145
+ const existingObj = existing && !existing.error ? existing : {}
146
+
147
+ const startedAt = existingObj.started_at || nowIso()
148
+ const deadline = existingObj.deadline || new Date(Date.now() + WALL_BUDGET_MS).toISOString()
149
+ // Snapshot auto_merge from config at start so config changes mid-watch don't
150
+ // surprise the orchestrator (spec.md §8).
151
+ const autoMergeFromConfig = readAutoMergeFromConfig(root)
152
+ const state = {
153
+ pr: Number(pr),
154
+ task_id: taskId,
155
+ host,
156
+ scheduler_handle: existingObj.scheduler_handle ?? null,
157
+ attempts: existingObj.attempts || 0,
158
+ started_at: startedAt,
159
+ deadline,
160
+ last_poll: existingObj.last_poll || nowIso(),
161
+ last_status: existingObj.last_status ?? null,
162
+ next_tick_interval_ms: existingObj.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS,
163
+ paused: existingObj.paused || false,
164
+ auto_merge_on_green: autoMergeFromConfig,
165
+ }
166
+ writeState(root, pr, state)
167
+ return ok({
168
+ status: 'ok',
169
+ command: 'ci-watch-start',
170
+ pr: Number(pr),
171
+ host,
172
+ scheduler_handle: state.scheduler_handle,
173
+ deadline: state.deadline,
174
+ })
175
+ } finally {
176
+ releaseLock(root, pr, sessionId)
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // stop
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function doStop(root, flags) {
185
+ const prCheck = validatePr(flags.get('pr'))
186
+ if (typeof prCheck !== 'string') return prCheck
187
+ const pr = prCheck
188
+ const reason = flags.get('reason') || 'user-stop'
189
+
190
+ const existing = readState(root, pr)
191
+ if (!existing || existing.error) {
192
+ // Idempotent — stopping a non-existent watch is a no-op.
193
+ return ok({
194
+ status: 'ok',
195
+ command: 'ci-watch-stop',
196
+ pr: Number(pr),
197
+ scheduler_handle_removed: false,
198
+ reason,
199
+ })
200
+ }
201
+ const schedulerHandle = existing.scheduler_handle ?? null
202
+ deleteState(root, pr)
203
+ // Also remove any leftover lock file in case the watcher crashed mid-tick.
204
+ const stopSessionId = newSessionId()
205
+ const acq = acquireLock(root, pr, stopSessionId)
206
+ if (acq.status === 'ok') {
207
+ releaseLock(root, pr, stopSessionId)
208
+ }
209
+
210
+ return ok({
211
+ status: 'ok',
212
+ command: 'ci-watch-stop',
213
+ pr: Number(pr),
214
+ scheduler_handle_removed: schedulerHandle !== null,
215
+ reason,
216
+ })
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // status
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function doStatus(root, flags) {
224
+ if (flags.has('pr')) {
225
+ const prCheck = validatePr(flags.get('pr'))
226
+ if (typeof prCheck !== 'string') return prCheck
227
+ const pr = prCheck
228
+ const s = readState(root, pr)
229
+ if (!s || s.error) {
230
+ return ok({ status: 'ok', command: 'ci-watch-status', watches: [] })
231
+ }
232
+ return ok({
233
+ status: 'ok',
234
+ command: 'ci-watch-status',
235
+ watches: [summarize(s)],
236
+ })
237
+ }
238
+ const watches = listWatches(root).map(summarize)
239
+ return ok({ status: 'ok', command: 'ci-watch-status', watches })
240
+ }
241
+
242
+ function summarize(s) {
243
+ return {
244
+ pr: s.pr,
245
+ attempts: s.attempts,
246
+ last_status: s.last_status,
247
+ deadline: s.deadline,
248
+ last_poll: s.last_poll,
249
+ paused: s.paused,
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // tick
255
+ // ---------------------------------------------------------------------------
256
+
257
+ function doTick(root, flags) {
258
+ const prCheck = validatePr(flags.get('pr'))
259
+ if (typeof prCheck !== 'string') return prCheck
260
+ const pr = prCheck
261
+
262
+ const sessionId = newSessionId()
263
+ const lockResult = acquireLock(root, pr, sessionId)
264
+ if (lockResult.status !== 'ok') {
265
+ return exitWith(
266
+ {
267
+ status: 'busy',
268
+ command: 'ci-watch-tick',
269
+ pr: Number(pr),
270
+ reason: 'lock-held',
271
+ holder: lockResult.holder,
272
+ },
273
+ 2,
274
+ )
275
+ }
276
+
277
+ try {
278
+ const state = readState(root, pr)
279
+ if (!state || state.error) {
280
+ return err(`No active watch for PR ${pr} — run \`ci-watch start\` first`)
281
+ }
282
+ if (state.paused) {
283
+ return ok({
284
+ status: 'stopped',
285
+ command: 'ci-watch-tick',
286
+ pr: Number(pr),
287
+ action: 'reviewer-pause',
288
+ attempts_used: state.attempts,
289
+ reason: 'watch is paused (reviewer-pause); resume with /apt:watch-ci --resume',
290
+ })
291
+ }
292
+
293
+ const payload = fetchGhPayload(pr)
294
+ if (payload.error) {
295
+ // gh CLI not available / auth failed → infra-backoff. Don't count it,
296
+ // double the next-tick interval (capped at MAX_TICK_INTERVAL_MS), and reschedule.
297
+ state.last_poll = nowIso()
298
+ state.next_tick_interval_ms = Math.min(
299
+ (state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS) * 2,
300
+ MAX_TICK_INTERVAL_MS,
301
+ )
302
+ writeState(root, pr, state)
303
+ return ok({
304
+ status: 'ok',
305
+ command: 'ci-watch-tick',
306
+ pr: Number(pr),
307
+ action: 'infra-backoff',
308
+ attempts_used: state.attempts,
309
+ next_tick_at: nextTickAt(state),
310
+ reason: `gh payload fetch failed: ${payload.error}`,
311
+ })
312
+ }
313
+
314
+ const result = classify({
315
+ checks: payload.checks,
316
+ state,
317
+ prMetadata: payload.prMetadata,
318
+ })
319
+ const { action, counts_toward_budget, reason } = result
320
+
321
+ // Apply action-specific state mutation.
322
+ if (action === 'green' || action === 'merged' || action === 'closed') {
323
+ // Terminal — delete state before releasing lock so no concurrent tick
324
+ // can slip in between the release and the delete.
325
+ deleteState(root, pr)
326
+ releaseLock(root, pr, sessionId)
327
+ return ok({
328
+ status: 'stopped',
329
+ command: 'ci-watch-tick',
330
+ pr: Number(pr),
331
+ action,
332
+ attempts_used: state.attempts,
333
+ reason,
334
+ })
335
+ }
336
+ if (action === 'attempts-cap' || action === 'wall-cap' || action === 'identical-log') {
337
+ deleteState(root, pr)
338
+ releaseLock(root, pr, sessionId)
339
+ return ok({
340
+ status: 'stopped',
341
+ command: 'ci-watch-tick',
342
+ pr: Number(pr),
343
+ action,
344
+ attempts_used: state.attempts,
345
+ reason,
346
+ })
347
+ }
348
+ if (action === 'reviewer-pause') {
349
+ state.paused = true
350
+ state.last_poll = nowIso()
351
+ writeState(root, pr, state)
352
+ return ok({
353
+ status: 'stopped',
354
+ command: 'ci-watch-tick',
355
+ pr: Number(pr),
356
+ action,
357
+ attempts_used: state.attempts,
358
+ reason,
359
+ })
360
+ }
361
+ if (action === 'flake-skip') {
362
+ state.last_poll = nowIso()
363
+ // Reset interval on a clean (non-infra) tick.
364
+ state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
365
+ writeState(root, pr, state)
366
+ return ok({
367
+ status: 'ok',
368
+ command: 'ci-watch-tick',
369
+ pr: Number(pr),
370
+ action,
371
+ attempts_used: state.attempts,
372
+ next_tick_at: nextTickAt(state),
373
+ reason,
374
+ })
375
+ }
376
+ if (action === 'infra-backoff') {
377
+ state.last_poll = nowIso()
378
+ state.next_tick_interval_ms = Math.min(
379
+ (state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS) * 2,
380
+ MAX_TICK_INTERVAL_MS,
381
+ )
382
+ writeState(root, pr, state)
383
+ return ok({
384
+ status: 'ok',
385
+ command: 'ci-watch-tick',
386
+ pr: Number(pr),
387
+ action,
388
+ attempts_used: state.attempts,
389
+ next_tick_at: nextTickAt(state),
390
+ reason,
391
+ })
392
+ }
393
+ if (action === 'wait') {
394
+ state.last_poll = nowIso()
395
+ state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
396
+ writeState(root, pr, state)
397
+ return ok({
398
+ status: 'ok',
399
+ command: 'ci-watch-tick',
400
+ pr: Number(pr),
401
+ action,
402
+ attempts_used: state.attempts,
403
+ next_tick_at: nextTickAt(state),
404
+ reason,
405
+ })
406
+ }
407
+ // fix / diverged-rebased / diverged-bailed — all count toward budget.
408
+ if (counts_toward_budget) {
409
+ state.attempts = (state.attempts || 0) + 1
410
+ }
411
+ state.last_poll = nowIso()
412
+ state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
413
+ // Track the failed-check fingerprint aggregate for next-tick identical-log detection.
414
+ const failed = (payload.checks || []).filter(isFailing)
415
+ state.last_status =
416
+ failed.length === 0
417
+ ? null
418
+ : failed
419
+ .map((f) => `${f.name}:${f.log_fingerprint ?? ''}`)
420
+ .sort()
421
+ .join('|')
422
+ writeState(root, pr, state)
423
+ return ok({
424
+ status: 'ok',
425
+ command: 'ci-watch-tick',
426
+ pr: Number(pr),
427
+ action,
428
+ attempts_used: state.attempts,
429
+ next_tick_at: nextTickAt(state),
430
+ reason,
431
+ })
432
+ } finally {
433
+ // `try/finally` so every branch releases the lock — even thrown errors.
434
+ // The terminal branches above release explicitly before deleting state
435
+ // (releaseLock no-ops on a missing file, so calling it twice is safe).
436
+ releaseLock(root, pr, sessionId)
437
+ }
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // helpers
442
+ // ---------------------------------------------------------------------------
443
+
444
+ function nextTickAt(state) {
445
+ return new Date(
446
+ Date.now() + (state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS),
447
+ ).toISOString()
448
+ }
449
+
450
+ /**
451
+ * Fetch the gh CLI payload OR read the test-stub JSON. Returns
452
+ * { checks, prMetadata } on success
453
+ * { error: '<message>' } when gh / stub failed.
454
+ *
455
+ * Note: this is a thin wrapper — the orchestrator skill is responsible for
456
+ * the actual `gh run view --log` + fix-CLI shell-out (spec.md §5.5). The
457
+ * cmd's tick only reads checks + classifies + updates state; the heavy
458
+ * fix-loop work happens in the skill body.
459
+ */
460
+ function fetchGhPayload(_pr) {
461
+ const isTestRuntime =
462
+ process.env.NODE_ENV === 'test' ||
463
+ process.env.VITEST != null ||
464
+ process.env.APT_INTERNAL_TEST != null
465
+ const stub = isTestRuntime ? process.env.APT_TEST_GH_STUB : undefined
466
+ if (stub) {
467
+ try {
468
+ if (!existsSync(stub)) {
469
+ return { error: 'APT_TEST_GH_STUB path not found' }
470
+ }
471
+ const data = JSON.parse(readFileSync(stub, 'utf-8'))
472
+ return {
473
+ checks: data.checks || [],
474
+ prMetadata: data.prMetadata || { state: 'OPEN' },
475
+ }
476
+ } catch (_e) {
477
+ return { error: 'stub parse error' }
478
+ }
479
+ }
480
+ // Real gh shell-out lives in the skill body. The cmd's tick treats
481
+ // "no stub + no payload" as infra-backoff so unit tests can exercise the
482
+ // path without a live gh binary. The orchestrator skill is responsible
483
+ // for pre-fetching and re-invoking with APT_TEST_GH_STUB set, OR for
484
+ // passing real data via a future --payload-file flag (out of scope here).
485
+ return {
486
+ error: 'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests',
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Read `ci_watch.auto_merge_on_green` from `.aperant/config.json` (default
492
+ * false). Best-effort — missing file or unparsable JSON returns false.
493
+ */
494
+ function readAutoMergeFromConfig(root) {
495
+ try {
496
+ const path = resolve(root, '.aperant', 'config.json')
497
+ if (!existsSync(path)) return false
498
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'))
499
+ return parsed?.ci_watch?.auto_merge_on_green === true
500
+ } catch {
501
+ return false
502
+ }
503
+ }