@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.
- package/CHANGELOG.md +74 -0
- package/agents/apt-pr-review-fixer.md +9 -4
- package/dist/cli/commands/init.mjs +140 -22
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
- package/dist/cli/commands/toolchain-detect.mjs +44 -0
- package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts.map +1 -1
- package/dist/cli/commands/triage.mjs +346 -13
- package/dist/cli/commands/triage.mjs.map +1 -1
- package/dist/cli/commands/uninstall.d.mts.map +1 -1
- package/dist/cli/commands/uninstall.mjs +11 -8
- package/dist/cli/commands/uninstall.mjs.map +1 -1
- package/dist/cli/install/runtime-detect.d.mts +26 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +27 -0
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/util/aperant-section.d.mts +12 -0
- package/dist/cli/util/aperant-section.d.mts.map +1 -1
- package/dist/cli/util/aperant-section.mjs +12 -0
- package/dist/cli/util/aperant-section.mjs.map +1 -1
- package/dist/cli/util/copy.d.mts +27 -0
- package/dist/cli/util/copy.d.mts.map +1 -1
- package/dist/cli/util/copy.mjs +49 -5
- package/dist/cli/util/copy.mjs.map +1 -1
- package/dist/cli/verify-proof/idl/index.d.mts +12 -1
- package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
- package/dist/cli/verify-proof/idl/index.mjs +50 -1
- package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
- package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
- package/dist/cli/verify-proof/resolver.mjs +51 -1
- package/dist/cli/verify-proof/resolver.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
- package/dist/plugin/skills/apt-pr-review/SKILL.md +52 -15
- package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
- package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
- package/dist/plugin/skills/apt-update/SKILL.md +1 -1
- package/package.json +1 -1
- package/skills/apt-pr-review/SKILL.md +52 -15
- package/skills/apt-setup/SKILL.md +7 -6
- package/skills/apt-triage/SKILL.md +8 -6
- package/skills/apt-update/SKILL.md +1 -1
- package/src/cli/commands/init.mjs +144 -19
- package/src/cli/commands/toolchain-detect.mjs +44 -0
- package/src/cli/commands/triage.mjs +352 -11
- package/src/cli/commands/uninstall.mjs +11 -8
- package/src/cli/install/runtime-detect.mjs +27 -0
- package/src/cli/util/aperant-section.mjs +14 -0
- package/src/cli/util/copy.mjs +53 -8
- package/src/cli/verify-proof/idl/index.mjs +49 -11
- 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)
|
|
7
|
-
*
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
|
204
|
-
if (
|
|
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
|
|
11
|
-
* `<!-- APT:framework-start -->` / `<!-- APT:framework-end -->`
|
|
12
|
-
* (same injection surface as `injectInstructionFile`). If the file
|
|
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 = [
|
|
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
|
|
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
|
|