@aperant/framework 0.8.7 → 0.10.0

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 (111) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/agents/apt-pr-review-fixer.md +9 -4
  3. package/agents/apt-verifier.md +6 -3
  4. package/dist/cli/commands/init.d.mts.map +1 -1
  5. package/dist/cli/commands/init.mjs +157 -23
  6. package/dist/cli/commands/init.mjs.map +1 -1
  7. package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
  8. package/dist/cli/commands/toolchain-detect.mjs +44 -0
  9. package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
  10. package/dist/cli/commands/triage.d.mts.map +1 -1
  11. package/dist/cli/commands/triage.mjs +346 -13
  12. package/dist/cli/commands/triage.mjs.map +1 -1
  13. package/dist/cli/commands/uninstall.d.mts.map +1 -1
  14. package/dist/cli/commands/uninstall.mjs +11 -8
  15. package/dist/cli/commands/uninstall.mjs.map +1 -1
  16. package/dist/cli/consistency/parse-review.mjs +5 -3
  17. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  18. package/dist/cli/consistency/rules/r5-verdict-consistency.d.mts.map +1 -1
  19. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs +10 -8
  20. package/dist/cli/consistency/rules/r5-verdict-consistency.mjs.map +1 -1
  21. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  22. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  23. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  24. package/dist/cli/install/mcp-provision.d.mts +26 -0
  25. package/dist/cli/install/mcp-provision.d.mts.map +1 -0
  26. package/dist/cli/install/mcp-provision.mjs +281 -0
  27. package/dist/cli/install/mcp-provision.mjs.map +1 -0
  28. package/dist/cli/install/mcp-server-specs.d.mts +80 -0
  29. package/dist/cli/install/mcp-server-specs.d.mts.map +1 -0
  30. package/dist/cli/install/mcp-server-specs.mjs +102 -0
  31. package/dist/cli/install/mcp-server-specs.mjs.map +1 -0
  32. package/dist/cli/install/runtime-detect.d.mts +27 -0
  33. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  34. package/dist/cli/install/runtime-detect.mjs +32 -0
  35. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  36. package/dist/cli/install/toml-merge.d.mts +40 -0
  37. package/dist/cli/install/toml-merge.d.mts.map +1 -0
  38. package/dist/cli/install/toml-merge.mjs +134 -0
  39. package/dist/cli/install/toml-merge.mjs.map +1 -0
  40. package/dist/cli/util/aperant-section.d.mts +12 -0
  41. package/dist/cli/util/aperant-section.d.mts.map +1 -1
  42. package/dist/cli/util/aperant-section.mjs +12 -0
  43. package/dist/cli/util/aperant-section.mjs.map +1 -1
  44. package/dist/cli/util/copy.d.mts +27 -0
  45. package/dist/cli/util/copy.d.mts.map +1 -1
  46. package/dist/cli/util/copy.mjs +49 -5
  47. package/dist/cli/util/copy.mjs.map +1 -1
  48. package/dist/cli/util/events-path.d.mts +8 -0
  49. package/dist/cli/util/events-path.d.mts.map +1 -0
  50. package/dist/cli/util/events-path.mjs +23 -0
  51. package/dist/cli/util/events-path.mjs.map +1 -0
  52. package/dist/cli/util/runtime-capabilities.d.mts +30 -0
  53. package/dist/cli/util/runtime-capabilities.d.mts.map +1 -0
  54. package/dist/cli/util/runtime-capabilities.mjs +214 -0
  55. package/dist/cli/util/runtime-capabilities.mjs.map +1 -0
  56. package/dist/cli/verify-proof/audit.d.mts +2 -7
  57. package/dist/cli/verify-proof/audit.d.mts.map +1 -1
  58. package/dist/cli/verify-proof/audit.mjs +11 -17
  59. package/dist/cli/verify-proof/audit.mjs.map +1 -1
  60. package/dist/cli/verify-proof/idl/index.d.mts +12 -1
  61. package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
  62. package/dist/cli/verify-proof/idl/index.mjs +50 -1
  63. package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
  64. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  65. package/dist/cli/verify-proof/resolver.mjs +51 -1
  66. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  67. package/dist/cli/verify-proof/runtime-detect.d.mts +2 -29
  68. package/dist/cli/verify-proof/runtime-detect.d.mts.map +1 -1
  69. package/dist/cli/verify-proof/runtime-detect.mjs +8 -203
  70. package/dist/cli/verify-proof/runtime-detect.mjs.map +1 -1
  71. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  72. package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
  73. package/dist/plugin/agents/apt-verifier.md +6 -3
  74. package/dist/plugin/skills/apt-pr-review/SKILL.md +106 -15
  75. package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
  76. package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
  77. package/dist/plugin/skills/apt-update/SKILL.md +1 -1
  78. package/dist/plugin/skills/apt-verify/SKILL.md +1 -1
  79. package/dist/plugin/skills/apt-verify-proof/SKILL.md +25 -1
  80. package/drivers/browser/skill/agent-browser/SKILL.md +51 -0
  81. package/drivers/tauri/README.md +107 -0
  82. package/drivers/tauri/driver.mjs +88 -0
  83. package/drivers/tauri/manifest.json +37 -0
  84. package/drivers/tauri/scripts/tauri-mcp.sh +41 -0
  85. package/package.json +1 -1
  86. package/skills/apt-pr-review/SKILL.md +106 -15
  87. package/skills/apt-setup/SKILL.md +7 -6
  88. package/skills/apt-triage/SKILL.md +8 -6
  89. package/skills/apt-update/SKILL.md +1 -1
  90. package/skills/apt-verify/SKILL.md +1 -1
  91. package/skills/apt-verify-proof/SKILL.md +25 -1
  92. package/src/cli/commands/init.mjs +163 -19
  93. package/src/cli/commands/toolchain-detect.mjs +44 -0
  94. package/src/cli/commands/triage.mjs +352 -11
  95. package/src/cli/commands/uninstall.mjs +11 -8
  96. package/src/cli/consistency/parse-review.mjs +5 -3
  97. package/src/cli/consistency/rules/r5-verdict-consistency.mjs +14 -9
  98. package/src/cli/gate/gates/review-clean.mjs +4 -2
  99. package/src/cli/install/mcp-provision.mjs +293 -0
  100. package/src/cli/install/mcp-server-specs.mjs +101 -0
  101. package/src/cli/install/runtime-detect.mjs +36 -0
  102. package/src/cli/install/toml-merge.mjs +139 -0
  103. package/src/cli/util/aperant-section.mjs +14 -0
  104. package/src/cli/util/copy.mjs +53 -8
  105. package/src/cli/util/events-path.mjs +24 -0
  106. package/src/cli/util/runtime-capabilities.mjs +216 -0
  107. package/src/cli/verify-proof/audit.mjs +11 -17
  108. package/src/cli/verify-proof/idl/index.mjs +49 -11
  109. package/src/cli/verify-proof/resolver.mjs +49 -2
  110. package/src/cli/verify-proof/runtime-detect.mjs +11 -204
  111. package/templates/config.json +2 -1
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * 5-state triage state machine with extensible metadata for v2 OSS
5
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.
6
+ * `.aperant/tasks/{task-id}/triage.json` by default (local-only). The
7
+ * `github-issues` backend mirrors triage state to a real GitHub issue via
8
+ * the `gh` CLI (create on first mirror, update/close on transition); the
9
+ * `app-inbox` backend remains a not-implemented stub (post-app-launch).
8
10
  *
9
11
  * Subcommands:
10
12
  * apt-tools triage init <project-dir> --id <task-id> [--category bug|enhancement] [--extra '{...}']
@@ -20,6 +22,7 @@
20
22
  * mechanical state-machine surface; the routing is the skill body's job.
21
23
  */
22
24
 
25
+ import { execFileSync } from 'node:child_process'
23
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
27
  import { dirname, join, resolve } from 'node:path'
25
28
  import { parseFlags } from '../util/args.mjs'
@@ -71,6 +74,29 @@ function readBackend(absProjectDir) {
71
74
  }
72
75
  }
73
76
 
77
+ /**
78
+ * Read task_tracking.tracker_label_vocabulary from .aperant/config.json.
79
+ * Absent or "default" → use the `triage:<state>` prefix; any other
80
+ * non-empty string → that string is used as the label prefix instead
81
+ * (`<vocab>:<state>`). Per spec ID-03 / AC5. This is the ONLY label
82
+ * configurability — no speculative label-map.
83
+ *
84
+ * @param {string} absProjectDir
85
+ * @returns {string} the label prefix (default 'triage')
86
+ */
87
+ function readLabelVocabulary(absProjectDir) {
88
+ const configPath = join(absProjectDir, '.aperant', 'config.json')
89
+ if (!existsSync(configPath)) return 'triage'
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
92
+ const vocab = parsed?.task_tracking?.tracker_label_vocabulary
93
+ if (typeof vocab === 'string' && vocab.length > 0 && vocab !== 'default') return vocab
94
+ return 'triage'
95
+ } catch {
96
+ return 'triage'
97
+ }
98
+ }
99
+
74
100
  /**
75
101
  * Triage record file path.
76
102
  *
@@ -108,25 +134,321 @@ function writeTriage(path, record) {
108
134
  }
109
135
 
110
136
  /**
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.
137
+ * Map a `gh` invocation's arg array to a stable subcommand key for the
138
+ * test fixture (ID-07). The fixture maps these keys to {stdout, exitCode}.
139
+ *
140
+ * @param {string[]} args
141
+ * @returns {string}
142
+ */
143
+ function ghFixtureKey(args) {
144
+ if (args[0] === '--version') return 'version'
145
+ if (args[0] === 'auth' && args[1] === 'status') return 'auth-status'
146
+ if (args[0] === 'repo' && args[1] === 'view') return 'repo-view'
147
+ if (args[0] === 'issue' && args[1] === 'create') return 'issue-create'
148
+ if (args[0] === 'issue' && args[1] === 'edit') return 'issue-edit'
149
+ if (args[0] === 'issue' && args[1] === 'close') return 'issue-close'
150
+ return args.slice(0, 2).join('-')
151
+ }
152
+
153
+ /**
154
+ * The single injection seam for `gh` shell-outs (ID-07). When
155
+ * process.env.APT_TRIAGE_GH_FIXTURE is set (a JSON map of subcommand key →
156
+ * {stdout, exitCode}), it returns the scripted response instead of
157
+ * shelling out — keeping unit tests hermetic (no network). Otherwise it
158
+ * runs `execFileSync('gh', args, ...)` with an arg array (NEVER a shell
159
+ * string — titles/bodies derive from task ids/reasons, ID-01).
160
+ *
161
+ * Returns a uniform `{ stdout, exitCode }`. A non-zero exit (or ENOENT) is
162
+ * reported via the return shape, NOT thrown — the caller maps it to an
163
+ * error envelope. The only thrown-then-caught path is ENOENT (gh missing),
164
+ * surfaced as exitCode:127 with code:'ENOENT' on the result.
165
+ *
166
+ * @param {string[]} args
167
+ * @param {string} cwd
168
+ * @returns {{ stdout: string, stderr: string, exitCode: number, code?: string }}
169
+ */
170
+ function runGh(args, cwd) {
171
+ const fixtureRaw = process.env.APT_TRIAGE_GH_FIXTURE
172
+ if (fixtureRaw) {
173
+ let fixture
174
+ try {
175
+ fixture = JSON.parse(fixtureRaw)
176
+ } catch {
177
+ return { stdout: '', stderr: 'invalid APT_TRIAGE_GH_FIXTURE JSON', exitCode: 1 }
178
+ }
179
+ const key = ghFixtureKey(args)
180
+ const scripted = fixture[key]
181
+ if (!scripted) {
182
+ // No scripted entry → treat as gh missing so tests must be explicit.
183
+ return { stdout: '', stderr: `no fixture for ${key}`, exitCode: 127, code: 'ENOENT' }
184
+ }
185
+ return {
186
+ stdout: typeof scripted.stdout === 'string' ? scripted.stdout : '',
187
+ stderr: typeof scripted.stderr === 'string' ? scripted.stderr : '',
188
+ exitCode: typeof scripted.exitCode === 'number' ? scripted.exitCode : 0,
189
+ ...(scripted.code ? { code: scripted.code } : {}),
190
+ }
191
+ }
192
+ try {
193
+ const stdout = execFileSync('gh', args, {
194
+ cwd,
195
+ encoding: 'utf-8',
196
+ stdio: ['ignore', 'pipe', 'pipe'],
197
+ })
198
+ return { stdout: stdout ?? '', stderr: '', exitCode: 0 }
199
+ } catch (e) {
200
+ // execFileSync throws on non-zero exit AND on spawn failure (ENOENT).
201
+ return {
202
+ stdout: typeof e?.stdout === 'string' ? e.stdout : '',
203
+ stderr: typeof e?.stderr === 'string' ? e.stderr : (e?.message ?? String(e)),
204
+ exitCode: typeof e?.status === 'number' ? e.status : 1,
205
+ ...(e?.code ? { code: e.code } : {}),
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Truncate a gh stderr message to a plain ~200-char string for the error
212
+ * envelope. NEVER returns an Error object / stacktrace (AC6).
213
+ *
214
+ * @param {string} stderr
215
+ * @returns {string}
216
+ */
217
+ function ghErrorMessage(stderr) {
218
+ return String(stderr ?? '')
219
+ .trim()
220
+ .slice(0, 200)
221
+ }
222
+
223
+ /**
224
+ * Build the structured `gh` error envelope (ID-04). The adapter NEVER
225
+ * throws past its own boundary; every failure routes here.
226
+ *
227
+ * @param {string} reason
228
+ * @param {string} message
229
+ */
230
+ function ghError(reason, message) {
231
+ return { status: 'error', backend: 'github-issues', reason, error: ghErrorMessage(message) }
232
+ }
233
+
234
+ /**
235
+ * Preflight: gh present? authenticated? remote/repo resolvable? (ID-05).
236
+ * Short-circuits to an error envelope on the first failure; on success
237
+ * returns the resolved `owner/repo` for deterministic `--repo` passing.
238
+ *
239
+ * @param {string} cwd
240
+ * @returns {{ ok: true, repo: string } | { ok: false, envelope: object }}
241
+ */
242
+ function ghPreflight(cwd) {
243
+ const version = runGh(['--version'], cwd)
244
+ if (version.code === 'ENOENT' || version.exitCode === 127) {
245
+ return { ok: false, envelope: ghError('gh-unavailable', 'gh CLI not found on PATH') }
246
+ }
247
+ if (version.exitCode !== 0) {
248
+ return { ok: false, envelope: ghError('gh-unavailable', version.stderr) }
249
+ }
250
+ const auth = runGh(['auth', 'status'], cwd)
251
+ if (auth.exitCode !== 0) {
252
+ return { ok: false, envelope: ghError('gh-unauthenticated', auth.stderr) }
253
+ }
254
+ const repo = runGh(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd)
255
+ if (repo.exitCode !== 0) {
256
+ return { ok: false, envelope: ghError('no-remote', repo.stderr) }
257
+ }
258
+ const nameWithOwner = repo.stdout.trim()
259
+ if (!nameWithOwner) {
260
+ return { ok: false, envelope: ghError('no-remote', 'gh repo view returned no nameWithOwner') }
261
+ }
262
+ return { ok: true, repo: nameWithOwner }
263
+ }
264
+
265
+ /**
266
+ * Parse the issue number + url from `gh issue create` stdout. gh prints
267
+ * the issue URL as the trailing line; the number is the path tail
268
+ * (`.../issues/N`). Documented parse assumption (spec Further Notes).
269
+ *
270
+ * @param {string} stdout
271
+ * @returns {{ issue_number: number, issue_url: string } | null}
272
+ */
273
+ function parseCreatedIssue(stdout) {
274
+ const url = String(stdout ?? '')
275
+ .trim()
276
+ .split('\n')
277
+ .filter((l) => l.includes('/issues/'))
278
+ .pop()
279
+ if (!url) return null
280
+ const m = url.match(/\/issues\/(\d+)\b/)
281
+ if (!m) return null
282
+ return { issue_number: Number(m[1]), issue_url: url.trim() }
283
+ }
284
+
285
+ /**
286
+ * github-issues adapter (ID-01/02/03/04/05). Mirrors triage state to a
287
+ * real GitHub issue via the `gh` CLI: first mirror CREATES an issue,
288
+ * subsequent transitions UPDATE the SAME issue (label swap), and a
289
+ * transition to `wontfix` CLOSES it as "not planned". Persists the issue
290
+ * pointer onto `triage.json` (like local-only) so the pointer survives.
291
+ *
292
+ * Precedence (spec Further Notes): the local triage.json write is the
293
+ * caller's responsibility for local-only; here, the github backend is the
294
+ * sole persistence path, so this adapter writes triage.json itself with
295
+ * the github block attached. Label ops are best-effort (AC8): a failed
296
+ * label edit degrades to a warnings[] entry, the open/closed state change
297
+ * still lands.
298
+ *
299
+ * @param {string} absProjectDir
300
+ * @param {string} taskId
301
+ * @param {object} record the (init or post-transition) triage record; mutated to carry `record.github`
302
+ * @returns {{ status: string, backend: 'github-issues', [k: string]: unknown }}
303
+ */
304
+ function mirrorToGithubIssues(absProjectDir, taskId, record) {
305
+ const cwd = absProjectDir
306
+ const prefix = readLabelVocabulary(absProjectDir)
307
+ const stateLabel = (state) => `${prefix}:${state}`
308
+
309
+ const pre = ghPreflight(cwd)
310
+ if (!pre.ok) return pre.envelope
311
+ const repo = pre.repo
312
+
313
+ const warnings = []
314
+ const persist = () => writeTriage(triagePath(absProjectDir, taskId), record)
315
+
316
+ // CREATE path: no issue yet on the record.
317
+ if (!record.github || typeof record.github.issue_number !== 'number') {
318
+ const labels = []
319
+ if (typeof record.category === 'string' && record.category) labels.push(record.category)
320
+ labels.push(stateLabel(record.state))
321
+ const createArgs = [
322
+ 'issue',
323
+ 'create',
324
+ '--title',
325
+ `triage: ${taskId}`,
326
+ '--body',
327
+ `Triage state: ${record.state}${record.category ? ` (${record.category})` : ''}`,
328
+ '--repo',
329
+ repo,
330
+ ]
331
+ for (const l of labels) createArgs.push('--label', l)
332
+ let res = runGh(createArgs, cwd)
333
+ if (res.exitCode !== 0) {
334
+ // AC8: a label that doesn't exist in the repo fails the create.
335
+ // Retry WITHOUT label flags so the issue still gets created.
336
+ const noLabelArgs = createArgs.filter(
337
+ (a, i) => a !== '--label' && createArgs[i - 1] !== '--label',
338
+ )
339
+ const retry = runGh(noLabelArgs, cwd)
340
+ if (retry.exitCode !== 0) {
341
+ return ghError('gh-command-failed', res.stderr || retry.stderr)
342
+ }
343
+ warnings.push(`label(s) not applied on create: ${ghErrorMessage(res.stderr)}`)
344
+ res = retry
345
+ }
346
+ const parsed = parseCreatedIssue(res.stdout)
347
+ if (!parsed) {
348
+ return ghError('gh-command-failed', `could not parse issue URL from: ${res.stdout}`)
349
+ }
350
+ record.github = { issue_number: parsed.issue_number, issue_url: parsed.issue_url, repo }
351
+ persist()
352
+ return {
353
+ status: 'ok',
354
+ backend: 'github-issues',
355
+ action: 'created',
356
+ issue_number: parsed.issue_number,
357
+ issue_url: parsed.issue_url,
358
+ warnings,
359
+ }
360
+ }
361
+
362
+ // UPDATE / CLOSE path: the record already points at an issue.
363
+ const issueNumber = record.github.issue_number
364
+ const issueRepo = typeof record.github.repo === 'string' ? record.github.repo : repo
365
+ // The previous state is the `from` of the last transition (the one that
366
+ // produced this record); on init there are no transitions.
367
+ const txs = Array.isArray(record.transitions) ? record.transitions : []
368
+ const lastTx = txs.length > 0 ? txs[txs.length - 1] : null
369
+ const oldLabel = lastTx ? stateLabel(lastTx.from) : null
370
+ const newLabel = stateLabel(record.state)
371
+
372
+ const editArgs = ['issue', 'edit', String(issueNumber), '--repo', issueRepo]
373
+ if (oldLabel) editArgs.push('--remove-label', oldLabel)
374
+ editArgs.push('--add-label', newLabel)
375
+ const edit = runGh(editArgs, cwd)
376
+ if (edit.exitCode !== 0) {
377
+ // AC8: label op failed (e.g. label not in repo). Degrade: skip the
378
+ // label swap, still perform the open/closed state change, warn.
379
+ warnings.push(`label swap skipped: ${ghErrorMessage(edit.stderr)}`)
380
+ }
381
+
382
+ if (record.state === 'wontfix') {
383
+ const close = runGh(
384
+ ['issue', 'close', String(issueNumber), '--repo', issueRepo, '--reason', 'not planned'],
385
+ cwd,
386
+ )
387
+ if (close.exitCode !== 0) {
388
+ return ghError('gh-command-failed', close.stderr)
389
+ }
390
+ record.github = {
391
+ issue_number: issueNumber,
392
+ issue_url: record.github.issue_url,
393
+ repo: issueRepo,
394
+ }
395
+ persist()
396
+ return {
397
+ status: 'ok',
398
+ backend: 'github-issues',
399
+ action: 'closed',
400
+ issue_number: issueNumber,
401
+ issue_url: record.github.issue_url,
402
+ warnings,
403
+ }
404
+ }
405
+
406
+ record.github = { issue_number: issueNumber, issue_url: record.github.issue_url, repo: issueRepo }
407
+ persist()
408
+ return {
409
+ status: 'ok',
410
+ backend: 'github-issues',
411
+ action: 'updated',
412
+ issue_number: issueNumber,
413
+ issue_url: record.github.issue_url,
414
+ warnings,
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Apply the backend mirror step. local-only writes to disk; github-issues
420
+ * dispatches to the gh-CLI adapter; app-inbox stays a not-implemented stub
421
+ * so callers can handle it gracefully.
114
422
  *
115
423
  * @param {string} backend
116
424
  * @param {string} absProjectDir
117
425
  * @param {string} taskId
118
426
  * @param {object} record
119
- * @returns {null | { status: 'not-implemented', backend: string, v2_issue: string }}
427
+ * @returns {null | { status: string, backend?: string, [k: string]: unknown }}
120
428
  */
121
429
  function mirrorToBackend(backend, absProjectDir, taskId, record) {
122
430
  if (backend === 'local-only') {
123
431
  writeTriage(triagePath(absProjectDir, taskId), record)
124
432
  return null
125
433
  }
126
- // v1 stubs: github-issues and app-inbox return not-implemented.
434
+ if (backend === 'github-issues') {
435
+ return mirrorToGithubIssues(absProjectDir, taskId, record)
436
+ }
437
+ // v1 stub: app-inbox returns not-implemented (out of scope per spec).
127
438
  return { status: 'not-implemented', backend, v2_issue: '#TBD' }
128
439
  }
129
440
 
441
+ /**
442
+ * Map a non-null mirror envelope to its exit code. github-issues errors
443
+ * exit 1 (ID-04); the not-implemented stub and ok envelopes exit 0.
444
+ *
445
+ * @param {{ status: string, [k: string]: unknown }} mirror
446
+ * @returns {number}
447
+ */
448
+ function mirrorExitCode(mirror) {
449
+ return mirror.status === 'error' ? 1 : 0
450
+ }
451
+
130
452
  /**
131
453
  * Entry: `apt-tools triage <subcommand> <project-dir> [flags]`.
132
454
  */
@@ -200,8 +522,17 @@ export function cmdTriage(subcommand, projectDir, extraArgs) {
200
522
  transitions: [],
201
523
  extra,
202
524
  }
203
- const stub = mirrorToBackend(backend, absProjectDir, taskId, record)
204
- if (stub) return exitWith({ command: 'triage-init', ...stub }, 0)
525
+ const mirror = mirrorToBackend(backend, absProjectDir, taskId, record)
526
+ if (mirror) {
527
+ // github-issues success merges its action/issue fields and echoes
528
+ // the persisted record (which now carries `github`); errors + the
529
+ // app-inbox not-implemented stub pass through as-is.
530
+ const merged =
531
+ mirror.status === 'ok'
532
+ ? { command: 'triage-init', ...mirror, record }
533
+ : { command: 'triage-init', ...mirror }
534
+ return exitWith(merged, mirrorExitCode(mirror))
535
+ }
205
536
  return ok({ status: 'ok', command: 'triage-init', backend, record })
206
537
  }
207
538
 
@@ -259,9 +590,19 @@ export function cmdTriage(subcommand, projectDir, extraArgs) {
259
590
  category: current.category,
260
591
  extra: current.extra ?? {},
261
592
  transitions: [...(current.transitions || []), transition],
593
+ // ID-02: carry the github issue pointer forward so subsequent
594
+ // transitions target the SAME issue instead of creating a duplicate.
595
+ // Only set when present (local-only records never carry it).
596
+ ...(current.github ? { github: current.github } : {}),
597
+ }
598
+ const mirror = mirrorToBackend(backend, absProjectDir, taskId, next)
599
+ if (mirror) {
600
+ const merged =
601
+ mirror.status === 'ok'
602
+ ? { command: 'triage-transition', ...mirror, transition, record: next }
603
+ : { command: 'triage-transition', ...mirror }
604
+ return exitWith(merged, mirrorExitCode(mirror))
262
605
  }
263
- const stub = mirrorToBackend(backend, absProjectDir, taskId, next)
264
- if (stub) return exitWith({ command: 'triage-transition', ...stub }, 0)
265
606
  return ok({
266
607
  status: 'ok',
267
608
  command: 'triage-transition',
@@ -7,10 +7,10 @@
7
7
  * root — unknown files are left alone and the directory is preserved if
8
8
  * it would otherwise be non-empty.
9
9
  *
10
- * The APT section in `CLAUDE.md` / `AGENTS.md` is stripped between the
11
- * `<!-- APT:framework-start -->` / `<!-- APT:framework-end -->` markers
12
- * (same injection surface as `injectInstructionFile`). If the file becomes
13
- * empty after the strip, the file is removed too.
10
+ * The APT section in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` is stripped
11
+ * between the `<!-- APT:framework-start -->` / `<!-- APT:framework-end -->`
12
+ * markers (same injection surface as `injectInstructionFile`). If the file
13
+ * becomes empty after the strip, the file is removed too.
14
14
  *
15
15
  * Flags:
16
16
  * --purge Also delete `.aperant/` state. Default: preserve
@@ -34,15 +34,18 @@ import {
34
34
  import { join, resolve } from 'node:path'
35
35
  import { MANIFEST_FILENAME } from '../install/manifest.mjs'
36
36
  import { RUNTIMES } from '../install/runtime-detect.mjs'
37
+ import { APT_SECTION_STRIP_RE as APT_SECTION_RE } from '../util/aperant-section.mjs'
37
38
  import { err, ok } from '../util/result.mjs'
38
39
 
39
- const APT_SECTION_RE = /<!-- APT:framework-start -->[\s\S]*?<!-- APT:framework-end -->\n?/
40
40
  const APT_GITIGNORE_BLOCK_RE = /# APT:gitignore-start[\s\S]*?# APT:gitignore-end\n?/
41
41
 
42
42
  /**
43
43
  * Files inside the project root that may carry the APT-injected section.
44
+ * Derived from RUNTIMES.nativeInstructionFile so the list stays in sync with
45
+ * whatever targets `init` writes — the same drift class as the bug this PR
46
+ * fixes cannot occur here.
44
47
  */
45
- const INSTRUCTION_FILES = ['CLAUDE.md', 'AGENTS.md']
48
+ const INSTRUCTION_FILES = [...new Set(RUNTIMES.map((r) => r.nativeInstructionFile).filter(Boolean))]
46
49
 
47
50
  /**
48
51
  * @typedef {Object} UninstallFlags
@@ -289,8 +292,8 @@ function removeRuntime(target, flags) {
289
292
  }
290
293
 
291
294
  /**
292
- * Strip the APT-injected section from CLAUDE.md / AGENTS.md. If the
293
- * remaining file is empty, the file itself is removed.
295
+ * Strip the APT-injected section from CLAUDE.md / AGENTS.md / GEMINI.md. If
296
+ * the remaining file is empty, the file itself is removed.
294
297
  *
295
298
  * @param {string} targetDir
296
299
  * @param {UninstallFlags} flags
@@ -97,9 +97,11 @@ function normalizeStatus(f) {
97
97
  if (STATUS_MAP[s]) return STATUS_MAP[s]
98
98
  // BUG-009: reviewer-skill writes `deferred-per-spec`, `deferred-followup`,
99
99
  // etc. for findings that are intentionally not being fixed in the
100
- // current scope. Collapse the variant prefix back to canonical
101
- // `deferred` so downstream gates (review-clean, ship) treat them
102
- // the same as a bare `status: "deferred"`.
100
+ // current scope. Collapse the variant prefix back to canonical `deferred`.
101
+ // Note: for blocking severities (critical/major), `deferred` no longer
102
+ // clears review-clean or R5 (G42 Phase 1). Only `status: "fixed"` clears
103
+ // those gates. Non-blocking severities (suggestion/note) are dropped by
104
+ // normalizeSeverity and never reach the blocking filter.
103
105
  if (s.startsWith('deferred-')) return 'deferred'
104
106
  }
105
107
  return 'open'
@@ -3,12 +3,13 @@
3
3
  * consistency/rules/r5-verdict-consistency.mjs — R5 (C15).
4
4
  *
5
5
  * Fails when:
6
- * - qa.verdict === 'approved' AND review has >=1 unresolved critical finding
6
+ * - qa.verdict === 'approved' or 'approved-with-notes' AND review has >=1
7
+ * unresolved critical or major finding
7
8
  * - qa.verdict === 'rejected' AND qa has no findings at all AND review has
8
9
  * zero unresolved findings (rejected-without-evidence)
9
10
  *
10
- * `approved-with-notes` is the escape hatch passes regardless of unresolved
11
- * criticals in review.json.
11
+ * `approved-with-notes` no longer escapes unresolved critical or major
12
+ * findings (G42 Phase 1). It still passes for lower-severity issues.
12
13
  *
13
14
  * Severity: error. Stages: verify, ship.
14
15
  */
@@ -26,23 +27,27 @@ const rule = {
26
27
  }
27
28
  const verdict = parsed.qa.verdict ?? 'unknown'
28
29
  const reviewFindings = parsed?.review?.findings ?? []
29
- const unresolvedCritical = reviewFindings.filter(
30
- (f) => f.severity === 'critical' && f.status !== 'fixed',
30
+ const unresolvedBlocking = reviewFindings.filter(
31
+ (f) => (f.severity === 'critical' || f.severity === 'major') && f.status !== 'fixed',
31
32
  )
32
- if (verdict === 'approved' && unresolvedCritical.length > 0) {
33
+ if (
34
+ (verdict === 'approved' || verdict === 'approved-with-notes') &&
35
+ unresolvedBlocking.length > 0
36
+ ) {
33
37
  return {
34
38
  status: 'fail',
35
39
  drift: [
36
40
  {
37
41
  verdict,
38
- blockers: unresolvedCritical.map((f) => ({
42
+ blockers: unresolvedBlocking.map((f) => ({
39
43
  id: f.id,
40
44
  title: f.title,
41
45
  })),
42
- reason: 'approved verdict with unresolved critical finding(s)',
46
+ reason:
47
+ 'approved/approved-with-notes verdict with unresolved critical or major finding(s) (G42 Phase 1)',
43
48
  },
44
49
  ],
45
- reason: `approved verdict contradicts ${unresolvedCritical.length} unresolved critical finding(s)`,
50
+ reason: `approved/approved-with-notes verdict contradicts ${unresolvedBlocking.length} unresolved critical/major finding(s)`,
46
51
  }
47
52
  }
48
53
  if (verdict === 'rejected') {
@@ -8,7 +8,9 @@
8
8
  * shapes the reviewer skill writes: `fixed: true`, `resolved: true`,
9
9
  * `status: "fixed"` (string), and `status: "deferred-*"` variants
10
10
  * (BUG-009). Fails if any post-normalization finding is severity
11
- * critical|major AND status is neither `fixed` nor `deferred`.
11
+ * critical|major AND status is not `fixed`. `deferred` no longer clears
12
+ * blocking findings (G42 Phase 1 — only `--force --reason` override may
13
+ * bypass blocking severity deferrals).
12
14
  */
13
15
 
14
16
  import { existsSync, readFileSync } from 'node:fs'
@@ -55,7 +57,7 @@ const gate = {
55
57
  // hole and picks up `deferred-*` variants for free.
56
58
  const { findings: normalized } = parseReview(doc)
57
59
  const unresolved = normalized.filter(
58
- (f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed' && f.status !== 'deferred',
60
+ (f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed',
59
61
  )
60
62
  if (unresolved.length > 0) {
61
63
  return {