@aperant/framework 0.8.6 → 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 +103 -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/manifest.d.mts +13 -0
- package/dist/cli/install/manifest.d.mts.map +1 -1
- package/dist/cli/install/manifest.mjs +5 -0
- package/dist/cli/install/manifest.mjs.map +1 -1
- package/dist/cli/install/pipeline.d.mts +15 -0
- package/dist/cli/install/pipeline.d.mts.map +1 -1
- package/dist/cli/install/pipeline.mjs +27 -0
- package/dist/cli/install/pipeline.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/install/transforms/codex.d.mts +1 -1
- package/dist/cli/install/transforms/codex.d.mts.map +1 -1
- package/dist/cli/install/transforms/codex.mjs +43 -2
- package/dist/cli/install/transforms/codex.mjs.map +1 -1
- package/dist/cli/install/version-header.d.mts.map +1 -1
- package/dist/cli/install/version-header.mjs +7 -1
- package/dist/cli/install/version-header.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/manifest.mjs +5 -0
- package/src/cli/install/pipeline.mjs +28 -0
- package/src/cli/install/runtime-detect.mjs +27 -0
- package/src/cli/install/transforms/codex.mjs +51 -2
- package/src/cli/install/version-header.mjs +7 -1
- 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
|
|
@@ -39,6 +39,11 @@ export const MANIFEST_SCHEMA_VERSION = 1
|
|
|
39
39
|
* @property {string} installedHash
|
|
40
40
|
* @property {string} sourcePath
|
|
41
41
|
* @property {string} transformVersion
|
|
42
|
+
* @property {boolean} [headerless] true when the installed file legitimately
|
|
43
|
+
* carries no apt-skill-version/apt-hook-version header (e.g. Codex strips it).
|
|
44
|
+
* Drift detection skips the missing_header check for these — installedHash is
|
|
45
|
+
* the sole drift signal. Absent (undefined) on pre-0.8.6 manifests → treated
|
|
46
|
+
* as header-expected, preserving old behavior until re-install.
|
|
42
47
|
*/
|
|
43
48
|
|
|
44
49
|
/**
|
|
@@ -17,6 +17,7 @@ import { atomicWriteText } from './atomic-text.mjs'
|
|
|
17
17
|
import { addManifestEntry, newManifest, sha256, writeManifest } from './manifest.mjs'
|
|
18
18
|
import { getRuntime } from './runtime-detect.mjs'
|
|
19
19
|
import { classifyFileType } from './transforms/_shared.mjs'
|
|
20
|
+
import { parseVersionHeader } from './version-header.mjs'
|
|
20
21
|
|
|
21
22
|
const TRANSFORM_VERSION = '1'
|
|
22
23
|
|
|
@@ -67,6 +68,12 @@ function safeResolveWithin(installRoot, outputPath) {
|
|
|
67
68
|
* @property {string} canonicalRelPath
|
|
68
69
|
* @property {string} version
|
|
69
70
|
* @property {'skill'|'agent'|'hook-js'|'hook-sh'|'unknown'} fileType
|
|
71
|
+
* @property {Record<string, string>} [agentContentByName] canonical agent body
|
|
72
|
+
* keyed by agent name (basename sans `.md`, e.g. `apt-planner`). Lets a
|
|
73
|
+
* transform inline a referenced agent's prompt when the target runtime has
|
|
74
|
+
* no separate agent-spawn surface (Codex). The pipeline always supplies it
|
|
75
|
+
* (empty object when no agents discovered); optional here so unit tests can
|
|
76
|
+
* construct a TransformInput without it — transforms default it to `{}`.
|
|
70
77
|
*/
|
|
71
78
|
|
|
72
79
|
/**
|
|
@@ -180,6 +187,19 @@ export function runInstall({
|
|
|
180
187
|
const files = canonicalFiles ?? discoverCanonicalFiles(canonicalRoot)
|
|
181
188
|
const written = []
|
|
182
189
|
const skipped = []
|
|
190
|
+
// Map of agent name → canonical agent body, so a transform whose runtime
|
|
191
|
+
// has no separate agent-spawn surface (Codex) can inline a referenced
|
|
192
|
+
// agent's prompt. Keyed by basename sans `.md` (e.g. `apt-planner`). When
|
|
193
|
+
// the same name exists under both skills/ and agents/, last-wins — they are
|
|
194
|
+
// the same agent (see the de-dupe note below).
|
|
195
|
+
/** @type {Record<string, string>} */
|
|
196
|
+
const agentContentByName = {}
|
|
197
|
+
for (const f of files) {
|
|
198
|
+
if (f.fileType === 'agent') {
|
|
199
|
+
const name = f.relPath.replace(/^.*\//, '').replace(/\.md$/, '')
|
|
200
|
+
agentContentByName[name] = f.content
|
|
201
|
+
}
|
|
202
|
+
}
|
|
183
203
|
// De-dupe by output path — last-write-wins. Two canonical files can
|
|
184
204
|
// legitimately target the same output (e.g. skills/apt-planner.md and
|
|
185
205
|
// agents/apt-planner.md both producing agents/apt-planner.md). The
|
|
@@ -200,6 +220,7 @@ export function runInstall({
|
|
|
200
220
|
canonicalRelPath: f.relPath,
|
|
201
221
|
version,
|
|
202
222
|
fileType: f.fileType,
|
|
223
|
+
agentContentByName,
|
|
203
224
|
})
|
|
204
225
|
} catch (err) {
|
|
205
226
|
skipped.push(
|
|
@@ -225,12 +246,19 @@ export function runInstall({
|
|
|
225
246
|
}
|
|
226
247
|
atomicWriteText(absPath, out.content)
|
|
227
248
|
const installedHash = sha256(out.content)
|
|
249
|
+
// Record whether the installed file carries a version header. When a
|
|
250
|
+
// transform legitimately strips it (Codex frontmatter whitelist),
|
|
251
|
+
// drift detection must not flag it missing_header — installedHash is
|
|
252
|
+
// the drift signal. Only relevant for header-bearing file types.
|
|
253
|
+
const headerless =
|
|
254
|
+
/\.(md|js|mjs|sh)$/.test(out.outputPath) && parseVersionHeader(out.content) === null
|
|
228
255
|
entryByPath.set(out.outputPath, {
|
|
229
256
|
path: out.outputPath,
|
|
230
257
|
sourceHash,
|
|
231
258
|
installedHash,
|
|
232
259
|
sourcePath: `packages/framework/${f.relPath}`,
|
|
233
260
|
transformVersion: TRANSFORM_VERSION,
|
|
261
|
+
...(headerless ? { headerless: true } : {}),
|
|
234
262
|
})
|
|
235
263
|
if (!written.includes(out.outputPath)) written.push(out.outputPath)
|
|
236
264
|
}
|
|
@@ -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
|
|