@aperant/framework 0.6.7 → 0.7.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 +60 -0
- package/dist/cli/commands/ci-watch.d.mts.map +1 -1
- package/dist/cli/commands/ci-watch.mjs +39 -1
- package/dist/cli/commands/ci-watch.mjs.map +1 -1
- package/dist/cli/commands/health-check.mjs +1 -1
- package/dist/cli/commands/health-check.mjs.map +1 -1
- package/dist/cli/commands/init.d.mts +10 -1
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +94 -4
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +37 -2
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +48 -0
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +1 -0
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/host/detect.d.mts +122 -9
- package/dist/cli/host/detect.d.mts.map +1 -1
- package/dist/cli/host/detect.mjs +132 -0
- package/dist/cli/host/detect.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts +38 -0
- package/dist/cli/install/legacy-paths.d.mts.map +1 -0
- package/dist/cli/install/legacy-paths.mjs +69 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -0
- package/dist/cli/install/runtime-migrate.d.mts +84 -0
- package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
- package/dist/cli/install/runtime-migrate.mjs +244 -0
- package/dist/cli/install/runtime-migrate.mjs.map +1 -0
- package/dist/cli/route/drift-detect.d.mts +20 -0
- package/dist/cli/route/drift-detect.d.mts.map +1 -0
- package/dist/cli/route/drift-detect.mjs +107 -0
- package/dist/cli/route/drift-detect.mjs.map +1 -0
- package/dist/cli/util/aperant-section.d.mts +34 -0
- package/dist/cli/util/aperant-section.d.mts.map +1 -0
- package/dist/cli/util/aperant-section.mjs +127 -0
- package/dist/cli/util/aperant-section.mjs.map +1 -0
- package/dist/cli/util/copy.d.mts +28 -1
- package/dist/cli/util/copy.d.mts.map +1 -1
- package/dist/cli/util/copy.mjs +43 -55
- package/dist/cli/util/copy.mjs.map +1 -1
- package/dist/cli/util/semver.d.mts +17 -0
- package/dist/cli/util/semver.d.mts.map +1 -0
- package/dist/cli/util/semver.mjs +29 -0
- package/dist/cli/util/semver.mjs.map +1 -0
- package/dist/cli/util/skill-installs.d.mts +65 -9
- package/dist/cli/util/skill-installs.d.mts.map +1 -1
- package/dist/cli/util/skill-installs.mjs +130 -21
- package/dist/cli/util/skill-installs.mjs.map +1 -1
- package/dist/cli/util/version-preflight.d.mts +44 -0
- package/dist/cli/util/version-preflight.d.mts.map +1 -0
- package/dist/cli/util/version-preflight.mjs +66 -0
- package/dist/cli/util/version-preflight.mjs.map +1 -0
- package/package.json +1 -1
- package/skills/apt-close-task/SKILL.md +5 -0
- package/skills/apt-ship/SKILL.md +16 -4
- package/skills/apt-spar/SKILL.md +36 -11
- package/skills/apt-update/SKILL.md +26 -1
- package/skills/apt-watch-ci/SKILL.md +4 -1
- package/src/cli/commands/ci-watch.mjs +39 -1
- package/src/cli/commands/health-check.mjs +1 -1
- package/src/cli/commands/init.mjs +102 -2
- package/src/cli/commands/route.mjs +38 -2
- package/src/cli/commands/task.mjs +49 -0
- package/src/cli/help.mjs +1 -0
- package/src/cli/host/detect.mjs +135 -0
- package/src/cli/install/legacy-paths.mjs +69 -0
- package/src/cli/install/runtime-migrate.mjs +252 -0
- package/src/cli/route/drift-detect.mjs +107 -0
- package/src/cli/util/aperant-section.mjs +136 -0
- package/src/cli/util/copy.mjs +43 -56
- package/src/cli/util/semver.mjs +28 -0
- package/src/cli/util/skill-installs.mjs +134 -21
- package/src/cli/util/version-preflight.mjs +65 -0
- package/templates/aperant-claude-md-appendix.md +37 -0
|
@@ -26,16 +26,36 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync, readFileSync } from 'node:fs'
|
|
28
28
|
import { homedir } from 'node:os'
|
|
29
|
-
import { join, resolve } from 'node:path'
|
|
29
|
+
import { dirname, join, resolve } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
30
31
|
import { getTaskIsolationConfig, getTrustedSkillSources } from '../config/load.mjs'
|
|
31
32
|
import '../gate/gates/index.mjs'
|
|
32
33
|
import * as gateRegistry from '../gate/registry.mjs'
|
|
33
34
|
import { detectHost, SUPPORTED_HOSTS } from '../host/detect.mjs'
|
|
35
|
+
import { detectSourceWorkspaceDrift } from '../route/drift-detect.mjs'
|
|
34
36
|
import { buildEnvelope } from '../route/envelope.mjs'
|
|
35
37
|
import { discoverSkills } from '../route/skill-discover.mjs'
|
|
36
38
|
import { executionModesList, isValidExecutionMode } from '../task/execution-mode.mjs'
|
|
37
39
|
import { err, exitWith, ok } from '../util/result.mjs'
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Read the installed kernel's own version from its package.json.
|
|
43
|
+
* Same pattern as commands/check-version.mjs:108-109 — `__dirname` of
|
|
44
|
+
* the running module resolves to the installed kernel for npm-installed
|
|
45
|
+
* users and to the source workspace for in-monorepo runs. Returns null
|
|
46
|
+
* on any I/O / parse failure (drift-detect treats null as "skip").
|
|
47
|
+
*/
|
|
48
|
+
function readInstalledKernelVersion() {
|
|
49
|
+
try {
|
|
50
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
51
|
+
const pkgPath = resolve(here, '..', '..', '..', 'package.json')
|
|
52
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
53
|
+
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/**
|
|
40
60
|
* Read the shared update-check cache (written by the SessionStart hook
|
|
41
61
|
* via apt-check-update-worker.js) and extract the fields /apt's SKILL.md
|
|
@@ -254,7 +274,23 @@ export function cmdRoute(projectDir, extraArgs) {
|
|
|
254
274
|
const task_isolation = getTaskIsolationConfig(targetDir)
|
|
255
275
|
|
|
256
276
|
const updateCheck = readUpdateCheckCache()
|
|
257
|
-
|
|
277
|
+
let update_check = updateCheck ? { ...updateCheck } : null
|
|
278
|
+
|
|
279
|
+
// FRAMEWORK-BUG-017 rung 1: in-band detection of workspace-ahead-of-
|
|
280
|
+
// installed-kernel drift. Catches the self-host contributor case
|
|
281
|
+
// where the SessionStart worker can't see the local same-PR fix yet.
|
|
282
|
+
// Workspace wins when both signals fire (workspace is the source of
|
|
283
|
+
// truth for self-host).
|
|
284
|
+
const installedKernelVersion = readInstalledKernelVersion()
|
|
285
|
+
const drift = detectSourceWorkspaceDrift(targetDir, installedKernelVersion)
|
|
286
|
+
if (drift) {
|
|
287
|
+
update_check = {
|
|
288
|
+
update_available: true,
|
|
289
|
+
installed_version: drift.installed_version,
|
|
290
|
+
latest_version: drift.source_version,
|
|
291
|
+
reason: 'workspace source ahead of installed kernel — run /apt:update --from-source',
|
|
292
|
+
}
|
|
293
|
+
}
|
|
258
294
|
|
|
259
295
|
// ---------------- Deterministic branches (zero-behavior-change) ----------------
|
|
260
296
|
// These fire regardless of host detection status — missing host is not
|
|
@@ -1006,6 +1006,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1006
1006
|
updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
|
|
1007
1007
|
updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
|
|
1008
1008
|
if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
|
|
1009
|
+
// FRAMEWORK-BUG-018 Track 1: surface `scheduler_handle` at the top
|
|
1010
|
+
// level so `apt-ship/SKILL.md` §7.2 can read it via
|
|
1011
|
+
// `.scheduler_handle // empty` without dotting through `.ci_watch.*`.
|
|
1012
|
+
// `null` today (no production path populates it); Track 2 lands
|
|
1013
|
+
// non-null values without re-plumbing the envelope shape.
|
|
1014
|
+
updateOut.scheduler_handle = ciWatchResult.scheduler_handle ?? null
|
|
1009
1015
|
}
|
|
1010
1016
|
updateResult = ok(updateOut)
|
|
1011
1017
|
},
|
|
@@ -1217,6 +1223,46 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1217
1223
|
// `gh pr view`, and close the ones GitHub says merged. Per spec §B1.
|
|
1218
1224
|
const onlyTaskId = flags.get('task') || null
|
|
1219
1225
|
const dryRun = flags.has('dry-run')
|
|
1226
|
+
const narrateOnly = flags.has('narrate-only')
|
|
1227
|
+
|
|
1228
|
+
// FRAMEWORK-BUG-020 — `--narrate-only` is read-only enumeration of
|
|
1229
|
+
// the pending_narration[] ledger. No `gh pr view`, no `task close`
|
|
1230
|
+
// spawn, no ledger writes. The skill body (apt-close-task §4) drains
|
|
1231
|
+
// per row via `task narration-drain` AFTER a successful narrator
|
|
1232
|
+
// spawn — at-least-once semantics is safe because the narrator is
|
|
1233
|
+
// anchor-replace idempotent (agents/apt-team-docs-narrator.md §6/§2).
|
|
1234
|
+
if (narrateOnly) {
|
|
1235
|
+
if (!existsSync(statePath)) {
|
|
1236
|
+
return ok({
|
|
1237
|
+
status: 'ok',
|
|
1238
|
+
command: 'task-close-merged',
|
|
1239
|
+
mode: 'narrate-only',
|
|
1240
|
+
closed: [],
|
|
1241
|
+
skipped: [],
|
|
1242
|
+
errors: [],
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
const stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
1246
|
+
const ledger = Array.isArray(stateBlob.pending_narration) ? stateBlob.pending_narration : []
|
|
1247
|
+
const filtered = onlyTaskId
|
|
1248
|
+
? ledger.filter((row) => row && row.task_id === onlyTaskId)
|
|
1249
|
+
: ledger
|
|
1250
|
+
return ok({
|
|
1251
|
+
status: 'ok',
|
|
1252
|
+
command: 'task-close-merged',
|
|
1253
|
+
mode: 'narrate-only',
|
|
1254
|
+
closed: filtered.map((row) => ({
|
|
1255
|
+
task_id: row.task_id,
|
|
1256
|
+
scope: row.scope ?? null,
|
|
1257
|
+
phase_id: row.phase_id ?? null,
|
|
1258
|
+
closed_at: row.closed_at ?? null,
|
|
1259
|
+
pr_number: row.pr_number ?? null,
|
|
1260
|
+
narration_pending: true,
|
|
1261
|
+
})),
|
|
1262
|
+
skipped: [],
|
|
1263
|
+
errors: [],
|
|
1264
|
+
})
|
|
1265
|
+
}
|
|
1220
1266
|
|
|
1221
1267
|
if (!existsSync(statePath)) {
|
|
1222
1268
|
return ok({
|
|
@@ -1675,6 +1721,7 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
|
|
|
1675
1721
|
ci_watch_armed: true,
|
|
1676
1722
|
ci_watch_skip_reason: null,
|
|
1677
1723
|
ci_watch: envelope,
|
|
1724
|
+
scheduler_handle: envelope.scheduler_handle ?? null,
|
|
1678
1725
|
}
|
|
1679
1726
|
}
|
|
1680
1727
|
if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
|
|
@@ -1682,12 +1729,14 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
|
|
|
1682
1729
|
ci_watch_armed: true,
|
|
1683
1730
|
ci_watch_skip_reason: 'already-armed',
|
|
1684
1731
|
ci_watch: envelope,
|
|
1732
|
+
scheduler_handle: envelope.scheduler_handle ?? null,
|
|
1685
1733
|
}
|
|
1686
1734
|
}
|
|
1687
1735
|
return {
|
|
1688
1736
|
ci_watch_armed: false,
|
|
1689
1737
|
ci_watch_skip_reason: envelope.reason || 'start-failed',
|
|
1690
1738
|
ci_watch: envelope,
|
|
1739
|
+
scheduler_handle: envelope.scheduler_handle ?? null,
|
|
1691
1740
|
}
|
|
1692
1741
|
}
|
|
1693
1742
|
|
package/src/cli/help.mjs
CHANGED
|
@@ -31,6 +31,7 @@ Commands:
|
|
|
31
31
|
task update <dir> --id <id> [--lifecycle-phase <p>] [--pr-url <url>] [--subtasks-total <N>] [--depends-on <refs>] Update non-linkage task state; --pr-url may arm ci-watch per ci_watch.after_ship
|
|
32
32
|
task move <dir> --id <id> [--to-scope <s>] [--to-milestone <id>] [--to-phase <id>] [--to-roadmap-ref-url <url>] [--reason <text>] [--force] Reassign scope/milestone/phase with history
|
|
33
33
|
task close <dir> --id <task-id> [--verdict <v>] Close task and move to completed
|
|
34
|
+
task close-merged <dir> [--task <id>] [--narrate-only] [--dry-run] Sweep shipped-pending-merge tasks for confirmed merges (--narrate-only returns pending_narration[] entries read-only without closing).
|
|
34
35
|
task narration-drain <dir> --task <task-id> Remove a row from state.pending_narration[] under withFileLock (called by /apt:close-task --narrate-only after a successful narrator spawn)
|
|
35
36
|
task complete-subtask <dir> --task-id <id> --subtask-id <sid|a+b+c> [--commit <sha>] [--notes <text>] Atomic plan ↔ build-progress sync
|
|
36
37
|
features-audit <dir> [--diff-files <csv>|--since <ref>] [--apply-stubs] [--format json|md] Audit features registry for unregistered feature-qualifying files
|
package/src/cli/host/detect.mjs
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* for misses over silently routing into the wrong runtime.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync as fsExistsSync } from 'node:fs'
|
|
16
|
+
import { delimiter as PATH_DELIMITER, join as pathJoin } from 'node:path'
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* Canonical list of runtime identifiers accepted by detectHost's
|
|
17
20
|
* `overrideCli` parameter AND by the route command's `--runtime <id>` flag.
|
|
@@ -211,5 +214,137 @@ export function detectHost(opts = {}) {
|
|
|
211
214
|
capabilities,
|
|
212
215
|
aperant_terminal: isAperantTerminal,
|
|
213
216
|
aperant_socket: aperantSocket,
|
|
217
|
+
partner_transports: detectPartnerTransports({ env }),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Partner-transport ladder used by `apt:spar` (FRAMEWORK-BUG-024).
|
|
223
|
+
*
|
|
224
|
+
* For each partner (codex / claude / gemini) we resolve a 3-rung ladder in
|
|
225
|
+
* preference order — MCP first (cleanest integration), then plugin shim
|
|
226
|
+
* (codex only), then raw CLI on $PATH. The first rung with `detected: true`
|
|
227
|
+
* wins; the spar skill's gate fails only when all three rungs report
|
|
228
|
+
* `detected: false`.
|
|
229
|
+
*
|
|
230
|
+
* Detection is best-effort and side-effect-free w/r/t the FS — only
|
|
231
|
+
* `existsSync` reads. No spawn, no network. The MCP rung uses
|
|
232
|
+
* `detected: "runtime-host-only"` when the framework process cannot probe
|
|
233
|
+
* the host's deferred-tool registry (the common case — MCP availability
|
|
234
|
+
* is host-internal), signalling the SKILL body to self-check at invocation
|
|
235
|
+
* time rather than fail the gate.
|
|
236
|
+
*
|
|
237
|
+
* Pure function w/r/t injected `env` + `existsSync` so tests can mock both
|
|
238
|
+
* without monkey-patching `process.env` or `node:fs`.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} [opts]
|
|
241
|
+
* @param {NodeJS.ProcessEnv} [opts.env] — defaults to `process.env`
|
|
242
|
+
* @param {(p: string) => boolean} [opts.existsSync] — defaults to `node:fs` existsSync
|
|
243
|
+
*/
|
|
244
|
+
export function detectPartnerTransports(opts = {}) {
|
|
245
|
+
const env = opts.env ?? process.env
|
|
246
|
+
const exists = opts.existsSync ?? fsExistsSync
|
|
247
|
+
const pathEntries = (env.PATH ?? '').split(PATH_DELIMITER).filter(Boolean)
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
codex: [
|
|
251
|
+
detectMcpRung('codex', env),
|
|
252
|
+
detectPluginShim('codex', env, exists),
|
|
253
|
+
detectRawCli('codex', pathEntries, exists),
|
|
254
|
+
],
|
|
255
|
+
claude: [
|
|
256
|
+
detectMcpRung('claude', env),
|
|
257
|
+
// No plugin shim exists for Claude today — kept as `detected: false`
|
|
258
|
+
// for ladder-shape symmetry so consumers can index by rung uniformly.
|
|
259
|
+
{ rung: 'plugin', detected: false, reason: 'no plugin shim for claude' },
|
|
260
|
+
detectRawCli('claude', pathEntries, exists),
|
|
261
|
+
],
|
|
262
|
+
gemini: [
|
|
263
|
+
detectMcpRung('gemini', env),
|
|
264
|
+
{ rung: 'plugin', detected: false, reason: 'no plugin shim for gemini' },
|
|
265
|
+
detectRawCli('gemini', pathEntries, exists),
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* MCP rung — detected if the host exposes its deferred-tool manifest via
|
|
272
|
+
* `CLAUDE_MCP_DEFERRED_TOOLS` (a JSON array of tool names) AND a matching
|
|
273
|
+
* `mcp__<partner>-mcp__*` entry is present. When the env var is absent the
|
|
274
|
+
* framework cannot observe the host's tool registry from outside, so we
|
|
275
|
+
* return `detected: "runtime-host-only"` — the SKILL body interprets this
|
|
276
|
+
* as "try it; if the host has the tool, use it; otherwise fall through".
|
|
277
|
+
*/
|
|
278
|
+
function detectMcpRung(partner, env) {
|
|
279
|
+
const toolPrefix = mcpToolPrefix(partner)
|
|
280
|
+
const manifest = env.CLAUDE_MCP_DEFERRED_TOOLS
|
|
281
|
+
if (manifest) {
|
|
282
|
+
try {
|
|
283
|
+
const tools = JSON.parse(manifest)
|
|
284
|
+
if (
|
|
285
|
+
Array.isArray(tools) &&
|
|
286
|
+
tools.some((t) => typeof t === 'string' && t.startsWith(toolPrefix))
|
|
287
|
+
) {
|
|
288
|
+
return { rung: 'mcp', detected: true, invocation: `${toolPrefix}*` }
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
rung: 'mcp',
|
|
292
|
+
detected: false,
|
|
293
|
+
reason: `no ${toolPrefix}* tool in CLAUDE_MCP_DEFERRED_TOOLS`,
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// Malformed manifest — fall through to runtime-deferred
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { rung: 'mcp', detected: 'runtime-host-only', invocation: `${toolPrefix}*` }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function mcpToolPrefix(partner) {
|
|
303
|
+
if (partner === 'codex') return 'mcp__codex-mcp__'
|
|
304
|
+
if (partner === 'claude') return 'mcp__claude-code-mcp__'
|
|
305
|
+
if (partner === 'gemini') return 'mcp__gemini-mcp__'
|
|
306
|
+
return `mcp__${partner}-mcp__`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Plugin-shim rung — Claude Code plugins drop helper scripts under
|
|
311
|
+
* `${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`. Only codex
|
|
312
|
+
* ships a companion today; the caller passes `'codex'` to probe.
|
|
313
|
+
*/
|
|
314
|
+
function detectPluginShim(partner, env, exists) {
|
|
315
|
+
const root = env.CLAUDE_PLUGIN_ROOT
|
|
316
|
+
if (!root) {
|
|
317
|
+
return { rung: 'plugin', detected: false, reason: 'CLAUDE_PLUGIN_ROOT unset' }
|
|
318
|
+
}
|
|
319
|
+
const script = pathJoin(root, 'scripts', `${partner}-companion.mjs`)
|
|
320
|
+
if (!exists(script)) {
|
|
321
|
+
return { rung: 'plugin', detected: false, reason: `${script} not found` }
|
|
214
322
|
}
|
|
323
|
+
return { rung: 'plugin', detected: true, invocation: `node "${script}" task` }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Raw-CLI rung — probe pre-split $PATH entries for the partner binary using
|
|
328
|
+
* existsSync (no spawn). Accepts a pre-split array so detectPartnerTransports
|
|
329
|
+
* can parse $PATH once and share it across all partner probes.
|
|
330
|
+
*
|
|
331
|
+
* The binary name matches the partner id for all three currently-supported
|
|
332
|
+
* partners (codex / claude / gemini).
|
|
333
|
+
*/
|
|
334
|
+
function detectRawCli(partner, pathEntries, exists) {
|
|
335
|
+
const bin = partner
|
|
336
|
+
for (const dir of pathEntries) {
|
|
337
|
+
const candidate = pathJoin(dir, bin)
|
|
338
|
+
if (exists(candidate)) {
|
|
339
|
+
return { rung: 'cli', detected: true, invocation: rawCliInvocation(partner) }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { rung: 'cli', detected: false, reason: `${bin} not found on $PATH` }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function rawCliInvocation(partner) {
|
|
346
|
+
if (partner === 'codex') return 'codex exec'
|
|
347
|
+
if (partner === 'claude') return 'claude -p'
|
|
348
|
+
if (partner === 'gemini') return 'gemini -m'
|
|
349
|
+
return partner
|
|
215
350
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install/legacy-paths.mjs — shared registry of legacy install topologies
|
|
3
|
+
* the framework once wrote to but no longer owns. Single source of truth
|
|
4
|
+
* for both:
|
|
5
|
+
*
|
|
6
|
+
* - BUG-021: `runLegacyCleanup` in `install/runtime-migrate.mjs` knows
|
|
7
|
+
* which files under a legacy root are framework-owned (safe to
|
|
8
|
+
* remove) vs user-added (preserve).
|
|
9
|
+
* - BUG-022: `readSkillInstallState` in `util/skill-installs.mjs` falls
|
|
10
|
+
* back to `version_file` here when no manifest is present, so a
|
|
11
|
+
* pre-manifest install still surfaces as "installed" rather than
|
|
12
|
+
* "missing-skills".
|
|
13
|
+
*
|
|
14
|
+
* Derivation: the codex `framework_files` list was extracted from
|
|
15
|
+
* `git show 1a8dd8a8 --stat` (the original "make Codex a first-class
|
|
16
|
+
* skill install surface" commit) — every `.agents/skills/…` path the
|
|
17
|
+
* installer once wrote, including the `.apt-version` stamp and the
|
|
18
|
+
* apt-pr-review / apt-discuss / apt-verify-proof sub-files. When the
|
|
19
|
+
* codex installer was retargeted to `.codex/`, that entire tree became
|
|
20
|
+
* orphaned on upgrading projects.
|
|
21
|
+
*
|
|
22
|
+
* Schema:
|
|
23
|
+
* { [runtimeId]: {
|
|
24
|
+
* install_root: relative path the runtime once wrote into,
|
|
25
|
+
* version_file: relative path of the legacy `.apt-version` stamp,
|
|
26
|
+
* framework_files: array of relative paths the installer once owned
|
|
27
|
+
* (under install_root).
|
|
28
|
+
* } }
|
|
29
|
+
*
|
|
30
|
+
* Frozen via Object.freeze so callers cannot mutate the shared registry.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {Record<string, { install_root: string, version_file: string, framework_files: ReadonlyArray<string> }>} */
|
|
34
|
+
export const LEGACY_INSTALL_PATHS = Object.freeze({
|
|
35
|
+
codex: Object.freeze({
|
|
36
|
+
install_root: '.agents/skills',
|
|
37
|
+
version_file: '.agents/skills/.apt-version',
|
|
38
|
+
framework_files: Object.freeze([
|
|
39
|
+
'.apt-version',
|
|
40
|
+
'apt/SKILL.md',
|
|
41
|
+
'apt-debug/SKILL.md',
|
|
42
|
+
'apt-discuss/SKILL.md',
|
|
43
|
+
'apt-discuss/examples/rationalization-example.md',
|
|
44
|
+
'apt-docs/SKILL.md',
|
|
45
|
+
'apt-execute/SKILL.md',
|
|
46
|
+
'apt-pause/SKILL.md',
|
|
47
|
+
'apt-plan/SKILL.md',
|
|
48
|
+
'apt-pr-review/SCHEMA.md',
|
|
49
|
+
'apt-pr-review/SKILL.md',
|
|
50
|
+
'apt-pr-review/prompts/fp-gate-codex.md',
|
|
51
|
+
'apt-pr-review/templates/external-comment.md',
|
|
52
|
+
'apt-quick/SKILL.md',
|
|
53
|
+
'apt-resume/SKILL.md',
|
|
54
|
+
'apt-review/SKILL.md',
|
|
55
|
+
'apt-roadmap/SKILL.md',
|
|
56
|
+
'apt-roundtable/SKILL.md',
|
|
57
|
+
'apt-run/SKILL.md',
|
|
58
|
+
'apt-scan/SKILL.md',
|
|
59
|
+
'apt-setup/SKILL.md',
|
|
60
|
+
'apt-ship/SKILL.md',
|
|
61
|
+
'apt-stress-test/SKILL.md',
|
|
62
|
+
'apt-verify/SKILL.md',
|
|
63
|
+
'apt-verify-proof/SKILL.md',
|
|
64
|
+
'apt-verify-proof/references/multi-model-setup.md',
|
|
65
|
+
'apt-verify-proof/references/multi-model-verification.md',
|
|
66
|
+
'apt-verify-proof/references/proof-capture.md',
|
|
67
|
+
]),
|
|
68
|
+
}),
|
|
69
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install/runtime-migrate.mjs — at-install cleanup of legacy install
|
|
3
|
+
* trees the framework no longer owns (FRAMEWORK-BUG-021).
|
|
4
|
+
*
|
|
5
|
+
* Codex was originally installed into `.agents/skills/` (commit 1a8dd8a8)
|
|
6
|
+
* and later moved to `.codex/`. Projects that upgraded across that move
|
|
7
|
+
* end up with a stale `.agents/skills/` tree that:
|
|
8
|
+
*
|
|
9
|
+
* - keeps `.apt-version` on disk → the legacy fallback in
|
|
10
|
+
* `readSkillInstallState` reports a stale codex install.
|
|
11
|
+
* - shows up in `health-check` as `stale-skills`.
|
|
12
|
+
* - leaves dozens of orphaned SKILL.md files the runtime no longer
|
|
13
|
+
* reads.
|
|
14
|
+
*
|
|
15
|
+
* `runLegacyCleanup` runs once per `apt-tools init` when codex is in the
|
|
16
|
+
* requested runtime set. It is conservative by default:
|
|
17
|
+
*
|
|
18
|
+
* - Only files listed in `LEGACY_INSTALL_PATHS.codex.framework_files`
|
|
19
|
+
* are removed. User-added skill directories (`my-skill/`, …) are
|
|
20
|
+
* preserved. If anything beyond the known framework set remains,
|
|
21
|
+
* `retained[]` lists those paths.
|
|
22
|
+
* - A present-and-current `apt-file-manifest.json` is treated as the
|
|
23
|
+
* authoritative "this is a managed install, leave it alone" signal:
|
|
24
|
+
* the function no-ops with `reason: 'managed'`.
|
|
25
|
+
* - The caller can suppress cleanup entirely via `--keep-legacy` at the
|
|
26
|
+
* init.mjs site; the function itself is dumb about flags.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
|
|
30
|
+
import { dirname, join } from 'node:path'
|
|
31
|
+
import { LEGACY_INSTALL_PATHS } from './legacy-paths.mjs'
|
|
32
|
+
import { MANIFEST_FILENAME } from './manifest.mjs'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} LegacyCodexDetection
|
|
36
|
+
* @property {boolean} present True when `.agents/skills/` exists.
|
|
37
|
+
* @property {string[]} frameworkFiles Subset of `framework_files` that
|
|
38
|
+
* actually exist on disk (relative
|
|
39
|
+
* to install_root).
|
|
40
|
+
* @property {string[]} userFiles Everything else under
|
|
41
|
+
* install_root (relative to
|
|
42
|
+
* install_root). May include both
|
|
43
|
+
* files and directories. Drives the
|
|
44
|
+
* `retained[]` warning.
|
|
45
|
+
* @property {boolean} hasAptVersion Whether the legacy `.apt-version`
|
|
46
|
+
* stamp is present.
|
|
47
|
+
* @property {boolean} hasManifest Whether an
|
|
48
|
+
* `apt-file-manifest.json` is
|
|
49
|
+
* present at install_root (signals
|
|
50
|
+
* a managed install — cleanup
|
|
51
|
+
* should no-op).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const LEGACY = LEGACY_INSTALL_PATHS.codex
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Recursively enumerate every file path under `dir`, returning paths
|
|
58
|
+
* relative to `dir`. Used to compute the "user files" set (anything not
|
|
59
|
+
* in the framework_files list).
|
|
60
|
+
*
|
|
61
|
+
* Returns [] when the dir doesn't exist. Swallows per-entry errors so a
|
|
62
|
+
* single unreadable symlink doesn't abort detection.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} dir
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
function walkRelative(dir) {
|
|
68
|
+
/** @type {string[]} */
|
|
69
|
+
const out = []
|
|
70
|
+
if (!existsSync(dir)) return out
|
|
71
|
+
/** @param {string} current @param {string} rel */
|
|
72
|
+
const visit = (current, rel) => {
|
|
73
|
+
let entries
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
76
|
+
} catch {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const childPath = join(current, entry.name)
|
|
81
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
visit(childPath, childRel)
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
out.push(childRel)
|
|
86
|
+
}
|
|
87
|
+
// Symlinks / other entry types are intentionally ignored — the
|
|
88
|
+
// framework only ever writes regular files.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
visit(dir, '')
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Probe the codex legacy install tree at `<targetDir>/.agents/skills/`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} targetDir
|
|
99
|
+
* @returns {LegacyCodexDetection}
|
|
100
|
+
*/
|
|
101
|
+
export function detectLegacyCodexTree(targetDir) {
|
|
102
|
+
const installRoot = join(targetDir, LEGACY.install_root)
|
|
103
|
+
if (!existsSync(installRoot)) {
|
|
104
|
+
return {
|
|
105
|
+
present: false,
|
|
106
|
+
frameworkFiles: [],
|
|
107
|
+
userFiles: [],
|
|
108
|
+
hasAptVersion: false,
|
|
109
|
+
hasManifest: false,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const onDisk = walkRelative(installRoot)
|
|
114
|
+
const frameworkSet = new Set(LEGACY.framework_files)
|
|
115
|
+
const frameworkFiles = onDisk.filter((p) => frameworkSet.has(p))
|
|
116
|
+
const userFiles = onDisk.filter((p) => !frameworkSet.has(p) && p !== MANIFEST_FILENAME)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
present: true,
|
|
120
|
+
frameworkFiles,
|
|
121
|
+
userFiles,
|
|
122
|
+
hasAptVersion: onDisk.includes('.apt-version'),
|
|
123
|
+
hasManifest: onDisk.includes(MANIFEST_FILENAME),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Try to remove a directory if it is empty. Swallows ENOTEMPTY / ENOENT
|
|
129
|
+
* so the caller can blindly sweep candidate dirs in any order.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} dir
|
|
132
|
+
* @returns {boolean} true iff the dir was actually removed.
|
|
133
|
+
*/
|
|
134
|
+
function rmEmptyDir(dir) {
|
|
135
|
+
if (!existsSync(dir)) return false
|
|
136
|
+
try {
|
|
137
|
+
rmdirSync(dir)
|
|
138
|
+
return true
|
|
139
|
+
} catch {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Clean up the legacy `.agents/skills/` tree if it looks orphaned.
|
|
146
|
+
*
|
|
147
|
+
* Branches:
|
|
148
|
+
* 1. `present: false` OR `frameworkFiles.length === 0` → no-op,
|
|
149
|
+
* `{ ran: false, reason: 'no-legacy' }`.
|
|
150
|
+
* 2. `hasManifest: true` → managed install, no-op,
|
|
151
|
+
* `{ ran: false, reason: 'managed' }`.
|
|
152
|
+
* 3. Otherwise: per-file `unlinkSync` each framework-owned file;
|
|
153
|
+
* `rmdirSync` any now-empty `apt-*` subdirectory; sweep
|
|
154
|
+
* `.agents/skills/` only if it is empty; sweep `.agents/` only if
|
|
155
|
+
* its `skills/` got removed and `.agents/` is empty.
|
|
156
|
+
*
|
|
157
|
+
* Returns `{ ran: true, removed, retained, rmdirs }` on the cleanup
|
|
158
|
+
* branch:
|
|
159
|
+
* - `removed` — relative paths under `install_root` that were unlinked.
|
|
160
|
+
* - `retained` — relative paths under `install_root` that were NOT
|
|
161
|
+
* removed (user-added skills / files not in the legacy set).
|
|
162
|
+
* - `rmdirs` — absolute paths of directories that were removed
|
|
163
|
+
* (apt-* subdirs, the install_root itself, .agents/), in removal order.
|
|
164
|
+
*
|
|
165
|
+
* Atomicity: per-file unlink is non-atomic. A partial-failure scenario
|
|
166
|
+
* (process killed mid-loop) is tolerable — the next `init` run will
|
|
167
|
+
* re-detect the remaining framework files and finish the sweep.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ targetDir: string, dryRun?: boolean }} args
|
|
170
|
+
* @returns {
|
|
171
|
+
* | { ran: false, reason: 'no-legacy' | 'managed' }
|
|
172
|
+
* | { ran: true, removed: string[], retained: string[], rmdirs: string[] }
|
|
173
|
+
* }
|
|
174
|
+
*/
|
|
175
|
+
export function runLegacyCleanup({ targetDir, dryRun = false }) {
|
|
176
|
+
const detection = detectLegacyCodexTree(targetDir)
|
|
177
|
+
|
|
178
|
+
if (!detection.present || detection.frameworkFiles.length === 0) {
|
|
179
|
+
return { ran: false, reason: 'no-legacy' }
|
|
180
|
+
}
|
|
181
|
+
if (detection.hasManifest) {
|
|
182
|
+
return { ran: false, reason: 'managed' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const installRoot = join(targetDir, LEGACY.install_root)
|
|
186
|
+
const removed = []
|
|
187
|
+
const rmdirs = []
|
|
188
|
+
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
// Dry-run: report what WOULD be removed without touching the filesystem.
|
|
191
|
+
// `retained` is the user-file set from the pre-walk (no sweep occurred).
|
|
192
|
+
// `rmdirs` is the set of apt-* subdirs that would become empty after removal.
|
|
193
|
+
const wouldRemove = detection.frameworkFiles
|
|
194
|
+
const retained = detection.userFiles
|
|
195
|
+
const candidateDirs = new Set()
|
|
196
|
+
for (const rel of wouldRemove) {
|
|
197
|
+
let parent = dirname(rel)
|
|
198
|
+
while (parent !== '.' && parent !== '') {
|
|
199
|
+
candidateDirs.add(parent)
|
|
200
|
+
parent = dirname(parent)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const wouldRmdirs = [...candidateDirs]
|
|
204
|
+
.sort((a, b) => b.length - a.length)
|
|
205
|
+
.map((rel) => join(installRoot, rel))
|
|
206
|
+
return { ran: true, dryRun: true, removed: wouldRemove, retained, rmdirs: wouldRmdirs }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const rel of detection.frameworkFiles) {
|
|
210
|
+
const abs = join(installRoot, rel)
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(abs)
|
|
213
|
+
removed.push(rel)
|
|
214
|
+
} catch {
|
|
215
|
+
/* best-effort: leave a partial sweep; next run finishes it */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Sweep any now-empty `apt-*/` subdirs the unlinks left behind.
|
|
220
|
+
// Walk parent dirs of every removed file in reverse-depth order so
|
|
221
|
+
// nested `apt-pr-review/prompts/` empties before `apt-pr-review/`.
|
|
222
|
+
const candidateDirs = new Set()
|
|
223
|
+
for (const rel of removed) {
|
|
224
|
+
let parent = dirname(rel)
|
|
225
|
+
while (parent !== '.' && parent !== '') {
|
|
226
|
+
candidateDirs.add(parent)
|
|
227
|
+
parent = dirname(parent)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const sortedDirs = [...candidateDirs].sort((a, b) => b.length - a.length)
|
|
231
|
+
for (const rel of sortedDirs) {
|
|
232
|
+
const abs = join(installRoot, rel)
|
|
233
|
+
if (rmEmptyDir(abs)) rmdirs.push(abs)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Sweep `.agents/skills/` only if fully empty after the per-file
|
|
237
|
+
// pass — preserves any user-added skill dirs.
|
|
238
|
+
if (rmEmptyDir(installRoot)) {
|
|
239
|
+
rmdirs.push(installRoot)
|
|
240
|
+
// Sweep `.agents/` only if it became empty too (i.e. no other
|
|
241
|
+
// `.agents/<sibling>` content the user keeps there).
|
|
242
|
+
const agentsRoot = dirname(installRoot)
|
|
243
|
+
if (rmEmptyDir(agentsRoot)) rmdirs.push(agentsRoot)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Use the pre-walk's userFiles as `retained` — the post-walk only existed
|
|
247
|
+
// to defensively re-scan, but pre-walk already captured user files accurately.
|
|
248
|
+
// (PRF-002: avoid redundant detectLegacyCodexTree call.)
|
|
249
|
+
const retained = detection.userFiles
|
|
250
|
+
|
|
251
|
+
return { ran: true, removed, retained, rmdirs }
|
|
252
|
+
}
|