@aperant/framework 0.6.3 → 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 (244) hide show
  1. package/CHANGELOG.md +61 -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/artifacts/classification.d.mts.map +1 -1
  10. package/dist/cli/artifacts/classification.mjs +10 -0
  11. package/dist/cli/artifacts/classification.mjs.map +1 -1
  12. package/dist/cli/ci-watch/lock.d.mts +53 -0
  13. package/dist/cli/ci-watch/lock.d.mts.map +1 -0
  14. package/dist/cli/ci-watch/lock.mjs +172 -0
  15. package/dist/cli/ci-watch/lock.mjs.map +1 -0
  16. package/dist/cli/ci-watch/state.d.mts +36 -0
  17. package/dist/cli/ci-watch/state.d.mts.map +1 -0
  18. package/dist/cli/ci-watch/state.mjs +103 -0
  19. package/dist/cli/ci-watch/state.mjs.map +1 -0
  20. package/dist/cli/ci-watch/stop-matrix.d.mts +58 -0
  21. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -0
  22. package/dist/cli/ci-watch/stop-matrix.mjs +164 -0
  23. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -0
  24. package/dist/cli/cli-wrappers/ci-watch.d.mts +2 -0
  25. package/dist/cli/cli-wrappers/ci-watch.d.mts.map +1 -0
  26. package/dist/cli/cli-wrappers/ci-watch.mjs +9 -0
  27. package/dist/cli/cli-wrappers/ci-watch.mjs.map +1 -0
  28. package/dist/cli/commands/adr.d.mts +5 -0
  29. package/dist/cli/commands/adr.d.mts.map +1 -0
  30. package/dist/cli/commands/adr.mjs +228 -0
  31. package/dist/cli/commands/adr.mjs.map +1 -0
  32. package/dist/cli/commands/ci-watch.d.mts +7 -0
  33. package/dist/cli/commands/ci-watch.d.mts.map +1 -0
  34. package/dist/cli/commands/ci-watch.mjs +465 -0
  35. package/dist/cli/commands/ci-watch.mjs.map +1 -0
  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 +59 -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/init.d.mts.map +1 -1
  48. package/dist/cli/commands/init.mjs +73 -5
  49. package/dist/cli/commands/init.mjs.map +1 -1
  50. package/dist/cli/commands/modes.d.mts +13 -0
  51. package/dist/cli/commands/modes.d.mts.map +1 -0
  52. package/dist/cli/commands/modes.mjs +220 -0
  53. package/dist/cli/commands/modes.mjs.map +1 -0
  54. package/dist/cli/commands/pr-review-audit-fixer.d.mts +41 -2
  55. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  56. package/dist/cli/commands/pr-review-audit-fixer.mjs +91 -14
  57. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  58. package/dist/cli/commands/route.d.mts.map +1 -1
  59. package/dist/cli/commands/route.mjs +11 -2
  60. package/dist/cli/commands/route.mjs.map +1 -1
  61. package/dist/cli/commands/task.d.mts.map +1 -1
  62. package/dist/cli/commands/task.mjs +136 -6
  63. package/dist/cli/commands/task.mjs.map +1 -1
  64. package/dist/cli/commands/tokens.d.mts.map +1 -1
  65. package/dist/cli/commands/tokens.mjs +150 -6
  66. package/dist/cli/commands/tokens.mjs.map +1 -1
  67. package/dist/cli/commands/triage.d.mts +8 -0
  68. package/dist/cli/commands/triage.d.mts.map +1 -0
  69. package/dist/cli/commands/triage.mjs +259 -0
  70. package/dist/cli/commands/triage.mjs.map +1 -0
  71. package/dist/cli/config/post-merge-sweep.d.mts +18 -0
  72. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  73. package/dist/cli/config/post-merge-sweep.mjs +48 -3
  74. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  75. package/dist/cli/consistency/registry.d.mts +11 -0
  76. package/dist/cli/consistency/registry.d.mts.map +1 -1
  77. package/dist/cli/consistency/registry.mjs +13 -0
  78. package/dist/cli/consistency/registry.mjs.map +1 -1
  79. package/dist/cli/coordination/agent-identity.d.mts +9 -0
  80. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  81. package/dist/cli/coordination/agent-identity.mjs +11 -0
  82. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  83. package/dist/cli/coordination/event-log.d.mts +27 -0
  84. package/dist/cli/coordination/event-log.d.mts.map +1 -1
  85. package/dist/cli/coordination/event-log.mjs +72 -2
  86. package/dist/cli/coordination/event-log.mjs.map +1 -1
  87. package/dist/cli/design/frontmatter-schema.d.mts +3 -3
  88. package/dist/cli/design/frontmatter-schema.d.mts.map +1 -1
  89. package/dist/cli/design/frontmatter-schema.mjs +3 -1
  90. package/dist/cli/design/frontmatter-schema.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 +1 -0
  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/route/envelope.d.mts +68 -4
  106. package/dist/cli/route/envelope.d.mts.map +1 -1
  107. package/dist/cli/route/envelope.mjs +140 -103
  108. package/dist/cli/route/envelope.mjs.map +1 -1
  109. package/dist/cli/route/skill-discover.d.mts +11 -0
  110. package/dist/cli/route/skill-discover.d.mts.map +1 -1
  111. package/dist/cli/route/skill-discover.mjs +46 -1
  112. package/dist/cli/route/skill-discover.mjs.map +1 -1
  113. package/dist/cli/skill-author/contract.d.mts +20 -0
  114. package/dist/cli/skill-author/contract.d.mts.map +1 -1
  115. package/dist/cli/skill-author/contract.mjs +27 -0
  116. package/dist/cli/skill-author/contract.mjs.map +1 -1
  117. package/dist/cli/skill-author/skill-template.d.mts.map +1 -1
  118. package/dist/cli/skill-author/skill-template.mjs +4 -3
  119. package/dist/cli/skill-author/skill-template.mjs.map +1 -1
  120. package/dist/cli/task/worktree-cleanup.d.mts +9 -1
  121. package/dist/cli/task/worktree-cleanup.d.mts.map +1 -1
  122. package/dist/cli/task/worktree-cleanup.mjs +190 -9
  123. package/dist/cli/task/worktree-cleanup.mjs.map +1 -1
  124. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  125. package/dist/plugin/agents/apt-planner.md +1 -1
  126. package/dist/plugin/skills/apt/SKILL.md +111 -5
  127. package/dist/plugin/skills/apt-author-skill/SKILL.md +11 -0
  128. package/dist/plugin/skills/apt-bootstrap/SKILL.md +1 -0
  129. package/dist/plugin/skills/apt-classify/SKILL.md +1 -0
  130. package/dist/plugin/skills/apt-close-task/SKILL.md +1 -0
  131. package/dist/plugin/skills/apt-create-docs/SKILL.md +1 -0
  132. package/dist/plugin/skills/apt-debug/SKILL.md +2 -0
  133. package/dist/plugin/skills/apt-design/SKILL.md +2 -0
  134. package/dist/plugin/skills/apt-discuss/SKILL.md +2 -0
  135. package/dist/plugin/skills/apt-docs/SKILL.md +2 -0
  136. package/dist/plugin/skills/apt-execute/SKILL.md +1 -0
  137. package/dist/plugin/skills/apt-mockup/SKILL.md +2 -0
  138. package/dist/plugin/skills/apt-pause/SKILL.md +1 -0
  139. package/dist/plugin/skills/apt-personas/SKILL.md +1 -0
  140. package/dist/plugin/skills/apt-plan/SKILL.md +2 -0
  141. package/dist/plugin/skills/apt-pr-review/SKILL.md +1 -0
  142. package/dist/plugin/skills/apt-quick/SKILL.md +2 -0
  143. package/dist/plugin/skills/apt-resume/SKILL.md +1 -0
  144. package/dist/plugin/skills/apt-review/SKILL.md +1 -0
  145. package/dist/plugin/skills/apt-roadmap/SKILL.md +1 -0
  146. package/dist/plugin/skills/apt-roundtable/SKILL.md +2 -0
  147. package/dist/plugin/skills/apt-run/SKILL.md +1 -0
  148. package/dist/plugin/skills/apt-scan/SKILL.md +1 -0
  149. package/dist/plugin/skills/apt-setup/SKILL.md +1 -0
  150. package/dist/plugin/skills/apt-ship/SKILL.md +6 -5
  151. package/dist/plugin/skills/apt-stress-test/SKILL.md +1 -0
  152. package/dist/plugin/skills/apt-terminal/SKILL.md +1 -0
  153. package/dist/plugin/skills/apt-update/SKILL.md +5 -0
  154. package/dist/plugin/skills/apt-verify/SKILL.md +1 -0
  155. package/dist/plugin/skills/apt-verify-proof/SKILL.md +1 -0
  156. package/dist/types/config.d.ts +85 -0
  157. package/dist/types/config.d.ts.map +1 -1
  158. package/package.json +125 -122
  159. package/prompts/coder.md +2 -0
  160. package/prompts/planner.md +12 -0
  161. package/prompts/spec_writer.md +9 -0
  162. package/skills/apt/SKILL.md +112 -5
  163. package/skills/apt-author-skill/SKILL.md +11 -0
  164. package/skills/apt-bootstrap/SKILL.md +1 -0
  165. package/skills/apt-classify/SKILL.md +1 -0
  166. package/skills/apt-close-task/SKILL.md +33 -1
  167. package/skills/apt-create-docs/SKILL.md +1 -0
  168. package/skills/apt-debug/SKILL.md +41 -6
  169. package/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  170. package/skills/apt-design/SKILL.md +2 -0
  171. package/skills/apt-diagram/SKILL.md +342 -0
  172. package/skills/apt-diagram/appendices/design-discipline.md +97 -0
  173. package/skills/apt-discuss/SKILL.md +27 -0
  174. package/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  175. package/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  176. package/skills/apt-docs/SKILL.md +2 -0
  177. package/skills/apt-execute/SKILL.md +49 -5
  178. package/skills/apt-execute/appendices/tdd-mode.md +107 -0
  179. package/skills/apt-improve/DEEPENING.md +84 -0
  180. package/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  181. package/skills/apt-improve/LANGUAGE.md +104 -0
  182. package/skills/apt-improve/SKILL.md +141 -0
  183. package/skills/apt-mockup/SKILL.md +2 -0
  184. package/skills/apt-pause/SKILL.md +1 -0
  185. package/skills/apt-personas/SKILL.md +1 -0
  186. package/skills/apt-plan/SKILL.md +149 -4
  187. package/skills/apt-planner.md +42 -1
  188. package/skills/apt-pr-review/SKILL.md +47 -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 +32 -0
  193. package/skills/apt-resume/SKILL.md +1 -0
  194. package/skills/apt-review/SKILL.md +3 -0
  195. package/skills/apt-roadmap/SKILL.md +1 -0
  196. package/skills/apt-roundtable/SKILL.md +2 -0
  197. package/skills/apt-run/SKILL.md +33 -4
  198. package/skills/apt-scan/SKILL.md +1 -0
  199. package/skills/apt-setup/SKILL.md +129 -2
  200. package/skills/apt-ship/SKILL.md +52 -4
  201. package/skills/apt-stress-test/SKILL.md +1 -0
  202. package/skills/apt-terminal/SKILL.md +1 -0
  203. package/skills/apt-triage/AGENT-BRIEF.md +84 -0
  204. package/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  205. package/skills/apt-triage/SKILL.md +169 -0
  206. package/skills/apt-update/SKILL.md +3 -0
  207. package/skills/apt-verify/SKILL.md +4 -0
  208. package/skills/apt-verify-proof/SKILL.md +4 -0
  209. package/skills/apt-watch-ci/SKILL.md +163 -0
  210. package/skills/apt-zoom-out/SKILL.md +130 -0
  211. package/src/cli/artifacts/classification.mjs +10 -0
  212. package/src/cli/ci-watch/lock.mjs +178 -0
  213. package/src/cli/ci-watch/state.mjs +103 -0
  214. package/src/cli/ci-watch/stop-matrix.mjs +181 -0
  215. package/src/cli/cli-wrappers/ci-watch.mjs +9 -0
  216. package/src/cli/commands/adr.mjs +243 -0
  217. package/src/cli/commands/ci-watch.mjs +503 -0
  218. package/src/cli/commands/context.mjs +244 -0
  219. package/src/cli/commands/event.mjs +63 -24
  220. package/src/cli/commands/host-detect.mjs +33 -7
  221. package/src/cli/commands/init.mjs +83 -5
  222. package/src/cli/commands/modes.mjs +215 -0
  223. package/src/cli/commands/pr-review-audit-fixer.mjs +95 -16
  224. package/src/cli/commands/route.mjs +11 -2
  225. package/src/cli/commands/task.mjs +152 -18
  226. package/src/cli/commands/tokens.mjs +157 -6
  227. package/src/cli/commands/triage.mjs +277 -0
  228. package/src/cli/config/post-merge-sweep.mjs +49 -3
  229. package/src/cli/consistency/registry.mjs +14 -0
  230. package/src/cli/coordination/agent-identity.mjs +12 -0
  231. package/src/cli/coordination/event-log.mjs +73 -2
  232. package/src/cli/design/frontmatter-schema.mjs +3 -1
  233. package/src/cli/dispatch.mjs +15 -1
  234. package/src/cli/gate/registry.mjs +14 -0
  235. package/src/cli/help.mjs +1 -0
  236. package/src/cli/host/detect.mjs +5 -0
  237. package/src/cli/route/envelope.mjs +140 -106
  238. package/src/cli/route/skill-discover.mjs +46 -1
  239. package/src/cli/skill-author/contract.mjs +29 -0
  240. package/src/cli/skill-author/skill-template.mjs +4 -3
  241. package/src/cli/task/worktree-cleanup.mjs +191 -9
  242. package/templates/adr-format.md +56 -0
  243. package/templates/config.json +4 -0
  244. package/templates/context-format.md +34 -0
@@ -174,6 +174,11 @@ const RULES = Object.freeze([
174
174
  class: ARTIFACT_CLASS.EPHEMERAL,
175
175
  why: 'Append-only daily log of lifecycle transitions.',
176
176
  },
177
+ {
178
+ pattern: '.aperant/install/**',
179
+ class: ARTIFACT_CLASS.EPHEMERAL,
180
+ why: 'Install telemetry/summaries (last-init/last-install) — runtime artifacts.',
181
+ },
177
182
  {
178
183
  pattern: '.aperant/team-status/**',
179
184
  class: ARTIFACT_CLASS.EPHEMERAL,
@@ -249,6 +254,11 @@ const RULES = Object.freeze([
249
254
  class: ARTIFACT_CLASS.EPHEMERAL,
250
255
  why: 'Design-scan outputs (tokens, extracted repo, synthesis) — rebuildable.',
251
256
  },
257
+ {
258
+ pattern: '.aperant/product-context.json',
259
+ class: ARTIFACT_CLASS.EPHEMERAL,
260
+ why: 'Derived/read-through sidecar used by CLI flows; rebuilt from onboarding context.',
261
+ },
252
262
  // ── Explicit ephemeral: secrets/tokens MUST never classify committable ─
253
263
  {
254
264
  pattern: '.aperant/secrets/**',
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ci-watch/lock.mjs — per-PR O_EXCL atomic lock (D-05).
3
+ *
4
+ * Mirrors Claude Code's `cronTasksLock.ts`: a per-PR `.aperant/ci-watches/{pr}.lock`
5
+ * file created with `openSync(..., 'wx')` (O_EXCL). Two concurrent callers
6
+ * cannot both create the file; the loser reads the existing lock and decides
7
+ * whether to declare BUSY or recover a stale lock.
8
+ *
9
+ * Stale-recovery (spec.md §8): a lock is recoverable iff
10
+ * (a) holder PID is dead (`!isPidAlive`), AND
11
+ * (b) `Date.now() - acquiredAt > ttl_seconds*1000`.
12
+ * Live PID alone is NEVER recoverable, even past TTL — TTL alone is not
13
+ * enough. Per D-05: PID-based stale recovery.
14
+ *
15
+ * Lock file shape (spec.md §8):
16
+ * { sessionId, pid, acquiredAt, ttl_seconds }
17
+ *
18
+ * Return shapes:
19
+ * acquireLock → { status: 'ok', lock: {...} }
20
+ * OR { status: 'busy', holder: {...} }
21
+ * releaseLock → boolean (true on release, false if owner mismatch / missing)
22
+ * readLock → lock object or null
23
+ */
24
+
25
+ import {
26
+ closeSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ openSync,
30
+ readFileSync,
31
+ unlinkSync,
32
+ writeSync,
33
+ } from 'node:fs'
34
+ import { join, resolve } from 'node:path'
35
+ import { isPidAlive } from '../lock/pid.mjs'
36
+
37
+ const TTL_SECONDS = 1800 // 30 min — D-05
38
+
39
+ function watchesDir(root) {
40
+ return join(resolve(root), '.aperant', 'ci-watches')
41
+ }
42
+
43
+ function lockPath(root, pr) {
44
+ return join(watchesDir(root), `${pr}.lock`)
45
+ }
46
+
47
+ /**
48
+ * @param {string} root
49
+ * @param {number|string} pr
50
+ */
51
+ export function readLock(root, pr) {
52
+ const path = lockPath(root, pr)
53
+ if (!existsSync(path)) return null
54
+ try {
55
+ return JSON.parse(readFileSync(path, 'utf-8'))
56
+ } catch {
57
+ // A corrupted lock is treated as no lock — re-acquire will overwrite.
58
+ return null
59
+ }
60
+ }
61
+
62
+ function writeLockFile(path, payload) {
63
+ mkdirSync(resolve(path, '..'), { recursive: true })
64
+ const fd = openSync(path, 'wx')
65
+ try {
66
+ writeSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 0, 'utf-8')
67
+ } finally {
68
+ closeSync(fd)
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Try to acquire the per-PR lock. On EEXIST, parse the existing lock — if the
74
+ * holder PID is dead AND TTL has elapsed, unlink + retry once. Otherwise
75
+ * return `{status:'busy', holder}`.
76
+ *
77
+ * @param {string} root
78
+ * @param {number|string} pr
79
+ * @param {string} sessionId
80
+ * @param {number} [pid=process.pid]
81
+ */
82
+ export function acquireLock(root, pr, sessionId, pid = process.pid) {
83
+ const path = lockPath(root, pr)
84
+ const payload = {
85
+ sessionId,
86
+ pid,
87
+ acquiredAt: new Date().toISOString(),
88
+ ttl_seconds: TTL_SECONDS,
89
+ }
90
+
91
+ try {
92
+ writeLockFile(path, payload)
93
+ return { status: 'ok', lock: payload }
94
+ } catch (err) {
95
+ if (err.code !== 'EEXIST') throw err
96
+ }
97
+
98
+ // Lock file exists — inspect.
99
+ const existing = readLock(root, pr)
100
+ if (!existing) {
101
+ // Corrupted lock — treat as recoverable, unlink + retry once.
102
+ try {
103
+ unlinkSync(path)
104
+ } catch {
105
+ /* race — another caller cleaned up */
106
+ }
107
+ try {
108
+ writeLockFile(path, payload)
109
+ return { status: 'ok', lock: payload }
110
+ } catch (err) {
111
+ if (err.code === 'EEXIST') {
112
+ return { status: 'busy', holder: readLock(root, pr) }
113
+ }
114
+ throw err
115
+ }
116
+ }
117
+
118
+ if (isStale(existing)) {
119
+ try {
120
+ unlinkSync(path)
121
+ } catch {
122
+ /* race — another caller cleaned up */
123
+ }
124
+ try {
125
+ writeLockFile(path, payload)
126
+ return { status: 'ok', lock: payload }
127
+ } catch (err) {
128
+ if (err.code === 'EEXIST') {
129
+ return { status: 'busy', holder: readLock(root, pr) }
130
+ }
131
+ throw err
132
+ }
133
+ }
134
+
135
+ return { status: 'busy', holder: existing }
136
+ }
137
+
138
+ /**
139
+ * Release the per-PR lock. Owner must match (`sessionId` equality). Returns
140
+ * `true` if released, `false` if the lock is missing or owned by someone
141
+ * else.
142
+ *
143
+ * @param {string} root
144
+ * @param {number|string} pr
145
+ * @param {string} sessionId
146
+ */
147
+ export function releaseLock(root, pr, sessionId) {
148
+ const path = lockPath(root, pr)
149
+ if (!existsSync(path)) return false
150
+ const existing = readLock(root, pr)
151
+ if (!existing) {
152
+ // Corrupted — fail open (let next caller clean up).
153
+ return false
154
+ }
155
+ if (existing.sessionId !== sessionId) return false
156
+ try {
157
+ unlinkSync(path)
158
+ return true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Is the given lock stale (i.e., recoverable by a new caller)? Per D-05,
166
+ * BOTH conditions are required: dead PID AND past TTL.
167
+ *
168
+ * Exposed for unit tests; the orchestrator does not call this directly.
169
+ *
170
+ * @param {{ pid: number, acquiredAt: string, ttl_seconds: number }} lockData
171
+ */
172
+ export function isStale(lockData) {
173
+ if (!lockData || typeof lockData !== 'object') return false
174
+ const ttlMs = (lockData.ttl_seconds || TTL_SECONDS) * 1000
175
+ const age = Date.now() - new Date(lockData.acquiredAt).getTime()
176
+ if (age <= ttlMs) return false
177
+ return !isPidAlive(lockData.pid)
178
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ci-watch/state.mjs — per-PR state-file primitives for the /apt:watch-ci
3
+ * post-ship watcher (D-04).
4
+ *
5
+ * Storage shape (`.aperant/ci-watches/{pr}.json`), spec.md §8:
6
+ *
7
+ * {
8
+ * pr, task_id, host, scheduler_handle | null,
9
+ * attempts, started_at, deadline, last_poll,
10
+ * last_status | null, next_tick_interval_ms,
11
+ * paused, auto_merge_on_green
12
+ * }
13
+ *
14
+ * This module is pure read/write — classification logic lives in
15
+ * stop-matrix.mjs, the orchestrator lives in commands/ci-watch.mjs.
16
+ *
17
+ * Crash-safety: `writeState` goes through `atomicWriteJson` (tmp + fsync +
18
+ * rename). `readState` returns `null` for a missing file and never throws on
19
+ * a missing path — callers can distinguish "no watch yet" from "watch is
20
+ * corrupted".
21
+ *
22
+ * The corrupted-JSON branch returns a recoverable error envelope rather than
23
+ * throwing so the orchestrator can surface it without crashing the cron
24
+ * payload mid-tick.
25
+ */
26
+
27
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+ import { atomicWriteJson } from '../util/io.mjs'
30
+
31
+ function watchesDir(root) {
32
+ return join(resolve(root), '.aperant', 'ci-watches')
33
+ }
34
+
35
+ function stateFile(root, pr) {
36
+ return join(watchesDir(root), `${pr}.json`)
37
+ }
38
+
39
+ /**
40
+ * Read the per-PR state file. Returns `null` when the file does not exist.
41
+ * Returns `{ error: 'corrupted', message }` when the file exists but is not
42
+ * parseable JSON — callers route that to the infra-backoff branch.
43
+ *
44
+ * @param {string} root
45
+ * @param {number|string} pr
46
+ */
47
+ export function readState(root, pr) {
48
+ const path = stateFile(root, pr)
49
+ if (!existsSync(path)) return null
50
+ try {
51
+ return JSON.parse(readFileSync(path, 'utf-8'))
52
+ } catch (err) {
53
+ return { error: 'corrupted', message: err.message }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Atomically write the per-PR state file. Caller passes the full state object
59
+ * — this function does not merge, it overwrites. Use `readState` first if a
60
+ * merge is needed.
61
+ *
62
+ * @param {string} root
63
+ * @param {number|string} pr
64
+ * @param {object} state
65
+ */
66
+ export function writeState(root, pr, state) {
67
+ atomicWriteJson(stateFile(root, pr), state)
68
+ }
69
+
70
+ /**
71
+ * Delete the per-PR state file. No-op when the file does not exist.
72
+ *
73
+ * @param {string} root
74
+ * @param {number|string} pr
75
+ */
76
+ export function deleteState(root, pr) {
77
+ const path = stateFile(root, pr)
78
+ if (existsSync(path)) unlinkSync(path)
79
+ }
80
+
81
+ /**
82
+ * Enumerate every per-PR state file under `.aperant/ci-watches/`. Returns an
83
+ * array of parsed state objects in lexicographic filename order. Skips files
84
+ * whose name does not match `<pr>.json` and skips corrupted files (their
85
+ * presence does not block the rest of the listing).
86
+ *
87
+ * @param {string} root
88
+ */
89
+ export function listWatches(root) {
90
+ const dir = watchesDir(root)
91
+ if (!existsSync(dir)) return []
92
+ const watches = []
93
+ for (const f of readdirSync(dir).sort()) {
94
+ if (!/^\d+\.json$/.test(f)) continue
95
+ try {
96
+ watches.push(JSON.parse(readFileSync(join(dir, f), 'utf-8')))
97
+ } catch {
98
+ // Skip corrupted state files — listWatches is a read-only enumerator
99
+ // and must not throw on partial corruption.
100
+ }
101
+ }
102
+ return watches
103
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ci-watch/stop-matrix.mjs — pure stop-matrix classifier (D-06).
3
+ *
4
+ * `classify({checks, state, prMetadata})` returns
5
+ * { action, counts_toward_budget, reason }
6
+ *
7
+ * No I/O, no fs, no gh CLI shell-outs — caller pre-fetches `checks` (the
8
+ * payload from `gh pr checks --json name,bucket,state,conclusion,detailsUrl`)
9
+ * and `prMetadata` (`gh pr view --json state,reviewDecision`) and passes them
10
+ * in. The orchestrator in commands/ci-watch.mjs handles the side effects
11
+ * implied by the action.
12
+ *
13
+ * Predicate order matters — short-circuits go first (merged/closed/green/cap
14
+ * fire before any per-check inspection). See spec.md §9 table for the full
15
+ * ordering.
16
+ *
17
+ * The 11 actions:
18
+ * merged, closed, green, attempts-cap, wall-cap, reviewer-pause,
19
+ * identical-log, flake-skip, infra-backoff, diverged-rebased,
20
+ * diverged-bailed, fix
21
+ *
22
+ * `counts_toward_budget` is `true` for fix attempts (including rebase
23
+ * attempts and rebase-bail); `false` for flake-skip / infra-backoff; `false`
24
+ * (with a sentinel meaning "n/a") for terminal actions.
25
+ */
26
+
27
+ const ATTEMPTS_CAP = 5
28
+
29
+ const INFRA_FAILURE_CLASSES = new Set(['runner-oom', 'runner-cancelled', 'gh-5xx', 'timeout'])
30
+
31
+ /**
32
+ * @typedef {Object} CheckRow
33
+ * @property {string} [name]
34
+ * @property {string} [conclusion] e.g. 'success' | 'failure' | 'cancelled' | 'timed_out' | 'neutral'
35
+ * @property {string} [state]
36
+ * @property {string} [bucket]
37
+ * @property {string} [failure_class] — optional pre-classified hint (test-only seam)
38
+ * @property {string} [log_fingerprint] — optional digest for identical-log detection
39
+ *
40
+ * @typedef {Object} ClassifyInput
41
+ * @property {CheckRow[]} checks
42
+ * @property {Object} state — per-PR state (see state.mjs §8)
43
+ * @property {Object} prMetadata — { state: 'OPEN'|'CLOSED'|'MERGED', reviewDecision?: string }
44
+ *
45
+ * @typedef {Object} ClassifyOutput
46
+ * @property {string} action
47
+ * @property {boolean} counts_toward_budget
48
+ * @property {string} reason
49
+ *
50
+ * @param {ClassifyInput} input
51
+ * @returns {ClassifyOutput}
52
+ */
53
+ export function classify({ checks, state, prMetadata }) {
54
+ const pr = prMetadata || {}
55
+ const st = state || {}
56
+ const rows = Array.isArray(checks) ? checks : []
57
+
58
+ // 1. MERGED.
59
+ if (pr.state === 'MERGED') {
60
+ return { action: 'merged', counts_toward_budget: false, reason: 'pr is merged' }
61
+ }
62
+ // 2. CLOSED (not merged).
63
+ if (pr.state === 'CLOSED') {
64
+ return { action: 'closed', counts_toward_budget: false, reason: 'pr is closed unmerged' }
65
+ }
66
+
67
+ const failed = rows.filter((c) => isFailing(c))
68
+ const allGreen = rows.length > 0 && rows.every((c) => c.conclusion === 'success')
69
+
70
+ // 3. All checks green.
71
+ if (allGreen) {
72
+ return { action: 'green', counts_toward_budget: false, reason: 'all checks succeeded' }
73
+ }
74
+
75
+ // 3b. No checks registered yet — CI hasn't enqueued jobs. Wait without
76
+ // burning a fix attempt.
77
+ if (rows.length === 0) {
78
+ return { action: 'wait', counts_toward_budget: false, reason: 'no checks registered yet' }
79
+ }
80
+
81
+ // 4. Attempts cap (only when red present — otherwise green/merged/closed would have fired).
82
+ const attempts = st.attempts || 0
83
+ if (attempts >= ATTEMPTS_CAP && failed.length > 0) {
84
+ return {
85
+ action: 'attempts-cap',
86
+ counts_toward_budget: false,
87
+ reason: `${attempts} fix attempts used; cap is ${ATTEMPTS_CAP}`,
88
+ }
89
+ }
90
+
91
+ // 5. Wall-clock cap.
92
+ if (st.deadline && Date.now() > new Date(st.deadline).getTime() && failed.length > 0) {
93
+ return {
94
+ action: 'wall-cap',
95
+ counts_toward_budget: false,
96
+ reason: 'past 4h deadline',
97
+ }
98
+ }
99
+
100
+ // 6. Reviewer asked for changes.
101
+ if (pr.reviewDecision === 'CHANGES_REQUESTED') {
102
+ return {
103
+ action: 'reviewer-pause',
104
+ counts_toward_budget: false,
105
+ reason: 'reviewer requested changes — pausing',
106
+ }
107
+ }
108
+
109
+ // 7. Identical-log → non-converging fixer.
110
+ // Compare aggregate fingerprints (same format as the write side in ci-watch.mjs).
111
+ if (attempts > 0 && st.last_status && failed.length > 0) {
112
+ const currentFingerprint = failed
113
+ .map((f) => `${f.name}:${f.log_fingerprint ?? ''}`)
114
+ .sort()
115
+ .join('|')
116
+ if (currentFingerprint === st.last_status) {
117
+ return {
118
+ action: 'identical-log',
119
+ counts_toward_budget: false,
120
+ reason: 'failed-check fingerprint identical to last attempt',
121
+ }
122
+ }
123
+ }
124
+
125
+ // 8. Flake-skip — same check failed then passed on rerun, no commit between.
126
+ // The classifier sees this as an explicit hint from the caller: failed rows
127
+ // classified as `bucket === 'flake'` or `failure_class === 'flake'` are the
128
+ // flaky-test variety and must not burn a fix attempt. Fires when EVERY
129
+ // remaining failure is a flake — if any non-flake failure is present, we
130
+ // fall through and the real failure governs.
131
+ if (
132
+ failed.length > 0 &&
133
+ failed.every((c) => c.bucket === 'flake' || c.failure_class === 'flake')
134
+ ) {
135
+ return {
136
+ action: 'flake-skip',
137
+ counts_toward_budget: false,
138
+ reason: 'check transient — passed on rerun',
139
+ }
140
+ }
141
+
142
+ // 9. Infra failure class.
143
+ if (failed.some((c) => INFRA_FAILURE_CLASSES.has(c.failure_class))) {
144
+ return {
145
+ action: 'infra-backoff',
146
+ counts_toward_budget: false,
147
+ reason: 'infra-class failure — backoff and retry',
148
+ }
149
+ }
150
+
151
+ // 10. Diverged branch — caller has already done the fetch + dry-rebase and
152
+ // passes the outcome in `prMetadata.diverged`:
153
+ // { rebased: true } → action 'diverged-rebased' (counts)
154
+ // { rebased: false } → action 'diverged-bailed' (counts)
155
+ if (pr.diverged && typeof pr.diverged === 'object') {
156
+ if (pr.diverged.rebased === true) {
157
+ return {
158
+ action: 'diverged-rebased',
159
+ counts_toward_budget: true,
160
+ reason: 'origin ahead; rebased successfully',
161
+ }
162
+ }
163
+ return {
164
+ action: 'diverged-bailed',
165
+ counts_toward_budget: true,
166
+ reason: 'origin ahead; rebase conflict',
167
+ }
168
+ }
169
+
170
+ // 11. Default red — go fix.
171
+ return {
172
+ action: 'fix',
173
+ counts_toward_budget: true,
174
+ reason: 'red checks present — attempting fix',
175
+ }
176
+ }
177
+
178
+ export function isFailing(check) {
179
+ const c = check.conclusion
180
+ return c === 'failure' || c === 'cancelled' || c === 'timed_out' || c === 'action_required'
181
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * cli-wrappers/ci-watch.mjs — Layer 2 wrapper for the ci-watch cmd.
3
+ *
4
+ * Today this is a passthrough — DISPATCH calls wrap(cmdCiWatch) directly.
5
+ * Re-exporting under cliCiWatch keeps the file-per-cmd structural seam
6
+ * (mirrors task.mjs / pr-review.mjs).
7
+ */
8
+
9
+ export { cmdCiWatch as cliCiWatch } from '../commands/ci-watch.mjs'