@aperant/framework 0.8.7 → 0.9.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 (52) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/agents/apt-pr-review-fixer.md +9 -4
  3. package/dist/cli/commands/init.mjs +140 -22
  4. package/dist/cli/commands/init.mjs.map +1 -1
  5. package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
  6. package/dist/cli/commands/toolchain-detect.mjs +44 -0
  7. package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
  8. package/dist/cli/commands/triage.d.mts.map +1 -1
  9. package/dist/cli/commands/triage.mjs +346 -13
  10. package/dist/cli/commands/triage.mjs.map +1 -1
  11. package/dist/cli/commands/uninstall.d.mts.map +1 -1
  12. package/dist/cli/commands/uninstall.mjs +11 -8
  13. package/dist/cli/commands/uninstall.mjs.map +1 -1
  14. package/dist/cli/install/runtime-detect.d.mts +26 -0
  15. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  16. package/dist/cli/install/runtime-detect.mjs +27 -0
  17. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  18. package/dist/cli/util/aperant-section.d.mts +12 -0
  19. package/dist/cli/util/aperant-section.d.mts.map +1 -1
  20. package/dist/cli/util/aperant-section.mjs +12 -0
  21. package/dist/cli/util/aperant-section.mjs.map +1 -1
  22. package/dist/cli/util/copy.d.mts +27 -0
  23. package/dist/cli/util/copy.d.mts.map +1 -1
  24. package/dist/cli/util/copy.mjs +49 -5
  25. package/dist/cli/util/copy.mjs.map +1 -1
  26. package/dist/cli/verify-proof/idl/index.d.mts +12 -1
  27. package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
  28. package/dist/cli/verify-proof/idl/index.mjs +50 -1
  29. package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
  30. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  31. package/dist/cli/verify-proof/resolver.mjs +51 -1
  32. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  33. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  34. package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
  35. package/dist/plugin/skills/apt-pr-review/SKILL.md +52 -15
  36. package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
  37. package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
  38. package/dist/plugin/skills/apt-update/SKILL.md +1 -1
  39. package/package.json +1 -1
  40. package/skills/apt-pr-review/SKILL.md +52 -15
  41. package/skills/apt-setup/SKILL.md +7 -6
  42. package/skills/apt-triage/SKILL.md +8 -6
  43. package/skills/apt-update/SKILL.md +1 -1
  44. package/src/cli/commands/init.mjs +144 -19
  45. package/src/cli/commands/toolchain-detect.mjs +44 -0
  46. package/src/cli/commands/triage.mjs +352 -11
  47. package/src/cli/commands/uninstall.mjs +11 -8
  48. package/src/cli/install/runtime-detect.mjs +27 -0
  49. package/src/cli/util/aperant-section.mjs +14 -0
  50. package/src/cli/util/copy.mjs +53 -8
  51. package/src/cli/verify-proof/idl/index.mjs +49 -11
  52. package/src/cli/verify-proof/resolver.mjs +49 -2
@@ -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
@@ -36,6 +36,18 @@ import { MANIFEST_FILENAME } from './manifest.mjs'
36
36
  * capitalized form of `id`. Use for runtimes
37
37
  * whose product branding differs from the
38
38
  * internal id (C58: `kilo` → "Kilo Code").
39
+ * @property {string|null} nativeInstructionFile
40
+ * The root-level instruction file this
41
+ * runtime's native CLI reads (e.g. `claude`
42
+ * reads `CLAUDE.md`, `codex` reads
43
+ * `AGENTS.md`). `init` stamps the
44
+ * `<!-- APT:framework-* -->` managed block
45
+ * into every distinct non-null target for the
46
+ * selected runtimes. `null` means the runtime
47
+ * installs to its own surface (e.g. cursor →
48
+ * `.cursor/rules`), NOT a root `.md` — `init`
49
+ * skips null entries. Nullable by design, not
50
+ * a future-work placeholder (ID-01).
39
51
  */
40
52
 
41
53
  /** @type {RuntimeDescriptor[]} */
@@ -46,6 +58,7 @@ export const RUNTIMES = Object.freeze([
46
58
  installRoot: '.claude',
47
59
  markers: ['.claude/settings.json', '.claude/settings.local.json'],
48
60
  cliId: 'claude-code',
61
+ nativeInstructionFile: 'CLAUDE.md',
49
62
  },
50
63
  {
51
64
  id: 'codex',
@@ -53,6 +66,7 @@ export const RUNTIMES = Object.freeze([
53
66
  installRoot: '.agents',
54
67
  markers: ['.codex/config.toml'],
55
68
  cliId: 'codex',
69
+ nativeInstructionFile: 'AGENTS.md',
56
70
  },
57
71
  {
58
72
  id: 'cursor',
@@ -60,6 +74,7 @@ export const RUNTIMES = Object.freeze([
60
74
  installRoot: '.cursor',
61
75
  markers: ['.cursor/settings.json'],
62
76
  cliId: 'cursor',
77
+ nativeInstructionFile: null,
63
78
  },
64
79
  {
65
80
  id: 'gemini',
@@ -67,6 +82,7 @@ export const RUNTIMES = Object.freeze([
67
82
  installRoot: '.gemini',
68
83
  markers: ['.gemini/settings.json'],
69
84
  cliId: 'gemini-cli',
85
+ nativeInstructionFile: 'GEMINI.md',
70
86
  },
71
87
  {
72
88
  id: 'opencode',
@@ -74,6 +90,7 @@ export const RUNTIMES = Object.freeze([
74
90
  installRoot: '.opencode',
75
91
  markers: ['.opencode/config.json'],
76
92
  cliId: 'opencode',
93
+ nativeInstructionFile: 'AGENTS.md',
77
94
  },
78
95
  {
79
96
  id: 'pi',
@@ -81,6 +98,7 @@ export const RUNTIMES = Object.freeze([
81
98
  installRoot: '.pi',
82
99
  markers: ['.pi/settings.json', '.pi/skills', '.pi/prompts'],
83
100
  cliId: 'pi',
101
+ nativeInstructionFile: null,
84
102
  },
85
103
  {
86
104
  id: 'kilo',
@@ -92,6 +110,7 @@ export const RUNTIMES = Object.freeze([
92
110
  // migration per spec ID-01). `--kilocode` is also accepted as a
93
111
  // deprecated alias of `--kilo` at the CLI flag parser layer.
94
112
  displayLabel: 'Kilo Code',
113
+ nativeInstructionFile: null,
95
114
  },
96
115
  {
97
116
  id: 'copilot',
@@ -99,6 +118,7 @@ export const RUNTIMES = Object.freeze([
99
118
  installRoot: '.github/copilot',
100
119
  markers: ['.github/copilot-instructions.md'],
101
120
  cliId: 'copilot',
121
+ nativeInstructionFile: null,
102
122
  },
103
123
  {
104
124
  id: 'antigravity',
@@ -106,6 +126,7 @@ export const RUNTIMES = Object.freeze([
106
126
  installRoot: '.antigravity',
107
127
  markers: ['.antigravity/config.json'],
108
128
  cliId: 'antigravity',
129
+ nativeInstructionFile: null,
109
130
  },
110
131
  {
111
132
  id: 'windsurf',
@@ -113,6 +134,7 @@ export const RUNTIMES = Object.freeze([
113
134
  installRoot: '.windsurf',
114
135
  markers: ['.windsurfrules'],
115
136
  cliId: 'windsurf',
137
+ nativeInstructionFile: null,
116
138
  },
117
139
  {
118
140
  id: 'augment',
@@ -120,6 +142,7 @@ export const RUNTIMES = Object.freeze([
120
142
  installRoot: '.augment',
121
143
  markers: ['.augment/config.json'],
122
144
  cliId: 'augment',
145
+ nativeInstructionFile: null,
123
146
  },
124
147
  {
125
148
  id: 'trae',
@@ -127,6 +150,7 @@ export const RUNTIMES = Object.freeze([
127
150
  installRoot: '.trae',
128
151
  markers: ['.trae/config.json'],
129
152
  cliId: 'trae',
153
+ nativeInstructionFile: null,
130
154
  },
131
155
  {
132
156
  id: 'qwen',
@@ -134,6 +158,7 @@ export const RUNTIMES = Object.freeze([
134
158
  installRoot: '.qwen',
135
159
  markers: ['.qwen/config.json'],
136
160
  cliId: 'qwen',
161
+ nativeInstructionFile: null,
137
162
  },
138
163
  {
139
164
  id: 'cline',
@@ -141,6 +166,7 @@ export const RUNTIMES = Object.freeze([
141
166
  installRoot: '.cline',
142
167
  markers: ['.cline/config.json'],
143
168
  cliId: 'cline',
169
+ nativeInstructionFile: null,
144
170
  },
145
171
  {
146
172
  id: 'codebuddy',
@@ -148,6 +174,7 @@ export const RUNTIMES = Object.freeze([
148
174
  installRoot: '.codebuddy',
149
175
  markers: ['.codebuddy/config.json'],
150
176
  cliId: 'codebuddy',
177
+ nativeInstructionFile: null,
151
178
  },
152
179
  ])
153
180
 
@@ -22,6 +22,20 @@ import { discoverSkills } from '../route/skill-discover.mjs'
22
22
 
23
23
  const SECTION_START = '<!-- APT:framework-start -->'
24
24
  const SECTION_END = '<!-- APT:framework-end -->'
25
+
26
+ /**
27
+ * Matches the full APT managed block (start marker through end marker,
28
+ * inclusive). Exported so callers in the same package can reference a
29
+ * single source rather than inlining duplicate regex literals.
30
+ *
31
+ * `APT_SECTION_RE` — no trailing newline; used for in-place replace.
32
+ * `APT_SECTION_STRIP_RE` — includes optional trailing newline; used for
33
+ * strip operations that want to remove the newline
34
+ * the block was preceded/followed by.
35
+ */
36
+ export const APT_SECTION_RE = /<!-- APT:framework-start -->[\s\S]*?<!-- APT:framework-end -->/
37
+ export const APT_SECTION_STRIP_RE =
38
+ /<!-- APT:framework-start -->[\s\S]*?<!-- APT:framework-end -->\n?/
25
39
  const SECTION_HEADING = '## Aperant Framework'
26
40
  const APPENDIX_REL_PATH = ['templates', 'aperant-claude-md-appendix.md']
27
41