@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  3. package/dist/cli/commands/ci-watch.mjs +39 -1
  4. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  5. package/dist/cli/commands/health-check.mjs +1 -1
  6. package/dist/cli/commands/health-check.mjs.map +1 -1
  7. package/dist/cli/commands/init.d.mts +10 -1
  8. package/dist/cli/commands/init.d.mts.map +1 -1
  9. package/dist/cli/commands/init.mjs +94 -4
  10. package/dist/cli/commands/init.mjs.map +1 -1
  11. package/dist/cli/commands/route.d.mts.map +1 -1
  12. package/dist/cli/commands/route.mjs +37 -2
  13. package/dist/cli/commands/route.mjs.map +1 -1
  14. package/dist/cli/commands/task.d.mts.map +1 -1
  15. package/dist/cli/commands/task.mjs +48 -0
  16. package/dist/cli/commands/task.mjs.map +1 -1
  17. package/dist/cli/help.d.mts.map +1 -1
  18. package/dist/cli/help.mjs +1 -0
  19. package/dist/cli/help.mjs.map +1 -1
  20. package/dist/cli/host/detect.d.mts +122 -9
  21. package/dist/cli/host/detect.d.mts.map +1 -1
  22. package/dist/cli/host/detect.mjs +132 -0
  23. package/dist/cli/host/detect.mjs.map +1 -1
  24. package/dist/cli/install/legacy-paths.d.mts +38 -0
  25. package/dist/cli/install/legacy-paths.d.mts.map +1 -0
  26. package/dist/cli/install/legacy-paths.mjs +69 -0
  27. package/dist/cli/install/legacy-paths.mjs.map +1 -0
  28. package/dist/cli/install/runtime-migrate.d.mts +84 -0
  29. package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
  30. package/dist/cli/install/runtime-migrate.mjs +244 -0
  31. package/dist/cli/install/runtime-migrate.mjs.map +1 -0
  32. package/dist/cli/route/drift-detect.d.mts +20 -0
  33. package/dist/cli/route/drift-detect.d.mts.map +1 -0
  34. package/dist/cli/route/drift-detect.mjs +107 -0
  35. package/dist/cli/route/drift-detect.mjs.map +1 -0
  36. package/dist/cli/util/aperant-section.d.mts +34 -0
  37. package/dist/cli/util/aperant-section.d.mts.map +1 -0
  38. package/dist/cli/util/aperant-section.mjs +127 -0
  39. package/dist/cli/util/aperant-section.mjs.map +1 -0
  40. package/dist/cli/util/copy.d.mts +28 -1
  41. package/dist/cli/util/copy.d.mts.map +1 -1
  42. package/dist/cli/util/copy.mjs +43 -55
  43. package/dist/cli/util/copy.mjs.map +1 -1
  44. package/dist/cli/util/semver.d.mts +17 -0
  45. package/dist/cli/util/semver.d.mts.map +1 -0
  46. package/dist/cli/util/semver.mjs +29 -0
  47. package/dist/cli/util/semver.mjs.map +1 -0
  48. package/dist/cli/util/skill-installs.d.mts +65 -9
  49. package/dist/cli/util/skill-installs.d.mts.map +1 -1
  50. package/dist/cli/util/skill-installs.mjs +130 -21
  51. package/dist/cli/util/skill-installs.mjs.map +1 -1
  52. package/dist/cli/util/version-preflight.d.mts +44 -0
  53. package/dist/cli/util/version-preflight.d.mts.map +1 -0
  54. package/dist/cli/util/version-preflight.mjs +66 -0
  55. package/dist/cli/util/version-preflight.mjs.map +1 -0
  56. package/package.json +1 -1
  57. package/skills/apt-close-task/SKILL.md +5 -0
  58. package/skills/apt-ship/SKILL.md +16 -4
  59. package/skills/apt-spar/SKILL.md +36 -11
  60. package/skills/apt-update/SKILL.md +26 -1
  61. package/skills/apt-watch-ci/SKILL.md +4 -1
  62. package/src/cli/commands/ci-watch.mjs +39 -1
  63. package/src/cli/commands/health-check.mjs +1 -1
  64. package/src/cli/commands/init.mjs +102 -2
  65. package/src/cli/commands/route.mjs +38 -2
  66. package/src/cli/commands/task.mjs +49 -0
  67. package/src/cli/help.mjs +1 -0
  68. package/src/cli/host/detect.mjs +135 -0
  69. package/src/cli/install/legacy-paths.mjs +69 -0
  70. package/src/cli/install/runtime-migrate.mjs +252 -0
  71. package/src/cli/route/drift-detect.mjs +107 -0
  72. package/src/cli/util/aperant-section.mjs +136 -0
  73. package/src/cli/util/copy.mjs +43 -56
  74. package/src/cli/util/semver.mjs +28 -0
  75. package/src/cli/util/skill-installs.mjs +134 -21
  76. package/src/cli/util/version-preflight.mjs +65 -0
  77. 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
- const update_check = updateCheck ? { ...updateCheck } : null
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
@@ -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
+ }