@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
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Preflight check: is the installed kernel new enough to support the
3
+ * named feature?
4
+ *
5
+ * - Returns `null` when the install is at-or-above `introducedIn` (pass).
6
+ * - Returns `null` when `installedVersion` is malformed (fail-open — a
7
+ * corrupted `package.json` shouldn't lock users out of every cmd; the
8
+ * route-level drift check still catches the same install via the
9
+ * version mismatch path).
10
+ * - Returns a `version_drift` error envelope when the install predates
11
+ * `introducedIn`. Callers `exitWith(envelope, 1)`.
12
+ *
13
+ * @param {Object} args
14
+ * @param {string} args.feature Short name of the feature being gated.
15
+ * @param {string} args.introducedIn Minimum framework version that ships the feature.
16
+ * @param {string} args.installedVersion The installed kernel's version.
17
+ * @param {string} args.missingFlag The CLI flag that requires this version (e.g. '--payload-file').
18
+ * @returns {null | {
19
+ * status: 'error',
20
+ * command: 'version-preflight',
21
+ * error_code: 'version_drift',
22
+ * feature: string,
23
+ * introduced_in: string,
24
+ * installed_version: string,
25
+ * missing_flag: string,
26
+ * reason: string,
27
+ * }}
28
+ */
29
+ export function requireMinVersion({ feature, introducedIn, installedVersion, missingFlag }: {
30
+ feature: string;
31
+ introducedIn: string;
32
+ installedVersion: string;
33
+ missingFlag: string;
34
+ }): null | {
35
+ status: "error";
36
+ command: "version-preflight";
37
+ error_code: "version_drift";
38
+ feature: string;
39
+ introduced_in: string;
40
+ installed_version: string;
41
+ missing_flag: string;
42
+ reason: string;
43
+ };
44
+ //# sourceMappingURL=version-preflight.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version-preflight.d.mts","sourceRoot":"","sources":["../../../src/cli/util/version-preflight.mjs"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,4FAfG;IAAqB,OAAO,EAApB,MAAM;IACO,YAAY,EAAzB,MAAM;IACO,gBAAgB,EAA7B,MAAM;IACO,WAAW,EAAxB,MAAM;CACd,GAAU,IAAI,GAAG;IACf,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,mBAAmB,CAAC;IAC7B,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB,CAmBH"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * util/version-preflight.mjs — FRAMEWORK-BUG-017 (rung 2 of 3).
3
+ *
4
+ * Pure helper for cmds that depend on a flag / behavior introduced in a
5
+ * specific framework version. When the installed kernel predates that
6
+ * version, returning a structured `version_drift` envelope is strictly
7
+ * better than the silent infra-backoff loop that bit us in
8
+ * FRAMEWORK-BUG-010.
9
+ *
10
+ * Scope (Codex review edit 1): this is a **future-guard** for the next
11
+ * framework fix that introduces a new flag. A stale 0.6.6 install
12
+ * obviously cannot execute 0.6.8 preflight code (the helper doesn't
13
+ * exist there). The actual catch-point for already-stale installs is
14
+ * the route-side `detectSourceWorkspaceDrift`. This helper makes the
15
+ * NEXT bug bundle cheaper to ship safely.
16
+ */
17
+ import { compareVersions, SEMVER_RE } from './semver.mjs';
18
+ /**
19
+ * Preflight check: is the installed kernel new enough to support the
20
+ * named feature?
21
+ *
22
+ * - Returns `null` when the install is at-or-above `introducedIn` (pass).
23
+ * - Returns `null` when `installedVersion` is malformed (fail-open — a
24
+ * corrupted `package.json` shouldn't lock users out of every cmd; the
25
+ * route-level drift check still catches the same install via the
26
+ * version mismatch path).
27
+ * - Returns a `version_drift` error envelope when the install predates
28
+ * `introducedIn`. Callers `exitWith(envelope, 1)`.
29
+ *
30
+ * @param {Object} args
31
+ * @param {string} args.feature Short name of the feature being gated.
32
+ * @param {string} args.introducedIn Minimum framework version that ships the feature.
33
+ * @param {string} args.installedVersion The installed kernel's version.
34
+ * @param {string} args.missingFlag The CLI flag that requires this version (e.g. '--payload-file').
35
+ * @returns {null | {
36
+ * status: 'error',
37
+ * command: 'version-preflight',
38
+ * error_code: 'version_drift',
39
+ * feature: string,
40
+ * introduced_in: string,
41
+ * installed_version: string,
42
+ * missing_flag: string,
43
+ * reason: string,
44
+ * }}
45
+ */
46
+ export function requireMinVersion({ feature, introducedIn, installedVersion, missingFlag }) {
47
+ if (!installedVersion || typeof installedVersion !== 'string')
48
+ return null;
49
+ if (!SEMVER_RE.test(installedVersion))
50
+ return null;
51
+ if (!SEMVER_RE.test(introducedIn))
52
+ return null;
53
+ if (compareVersions(installedVersion, introducedIn) >= 0)
54
+ return null;
55
+ return {
56
+ status: 'error',
57
+ command: 'version-preflight',
58
+ error_code: 'version_drift',
59
+ feature,
60
+ introduced_in: introducedIn,
61
+ installed_version: installedVersion,
62
+ missing_flag: missingFlag,
63
+ reason: `cmd needs feature ${feature} introduced in version ${introducedIn}; installed version is ${installedVersion} — run /apt:update`,
64
+ };
65
+ }
66
+ //# sourceMappingURL=version-preflight.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version-preflight.mjs","sourceRoot":"","sources":["../../../src/cli/util/version-preflight.mjs"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,iBAAiB,CAAC,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,WAAW,EAAE;IACzF,IAAI,CAAC,gBAAgB,IAAI,OAAO,gBAAgB,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC1E,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,gBAAgB,CAAC;QAAE,OAAO,IAAI,CAAA;IAClD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAA;IAE9C,IAAI,eAAe,CAAC,gBAAgB,EAAE,YAAY,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAErE,OAAO;QACN,MAAM,EAAE,OAAO;QACf,OAAO,EAAE,mBAAmB;QAC5B,UAAU,EAAE,eAAe;QAC3B,OAAO;QACP,aAAa,EAAE,YAAY;QAC3B,iBAAiB,EAAE,gBAAgB;QACnC,YAAY,EAAE,WAAW;QACzB,MAAM,EAAE,qBAAqB,OAAO,0BAA0B,YAAY,0BAA0B,gBAAgB,oBAAoB;KACxI,CAAA;AACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperant/framework",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "AI coding framework — composable skills for planning, executing, verifying, and reviewing code with any LLM provider. Works as Claude Code commands, Codex tasks, or programmatically.",
5
5
  "author": "Mikalsen AI <hello@mikalsen.ai>",
6
6
  "type": "module",
@@ -123,6 +123,8 @@ close-merged emits an envelope with:
123
123
 
124
124
  Report the summary verbatim. If anything landed in `skipped[]` with a recoverable reason (`offline`, `rate_limited`), suggest the user re-run once the network clears.
125
125
 
126
+ **Local cleanup recap (best-effort).** On confirmed merge, `task close-merged` runs `computeWorktreeCleanup` against the primary repo (`packages/framework/src/cli/task/worktree-cleanup.mjs:140-230`): `git fetch origin <base>` (failure → recorded as `fetch_failed` warning, cleanup proceeds), `git checkout <base>` (failure → returns `action: 'checkout_failed'` and preserves BOTH the worktree and the task branch — nothing is removed), `git merge --ff-only origin/<base>` (failure → `ff_only_failed` warning, GC still runs), `git worktree remove <wtPath>`, and `git branch -d <taskBranch>` (failure on squash/rebase merges → `branch_delete_failed` warning, `branch_deleted: false` in the envelope; user can `git branch -D` manually). This is why `/apt:watch-ci` auto-merge MUST NOT pass `gh pr merge --delete-branch` — that flag would try to do the same work from inside the worktree where `git switch <base>` cannot succeed (see FRAMEWORK-BUG-019).
127
+
126
128
  ## 4. Narrate-only mode
127
129
 
128
130
  When invoked with `--narrate-only`, skip §1 (no `gh pr view` polling) and §2 (no destructive close path). The deterministic close already ran via the passive post-merge sweep — your job is to drain the `state.pending_narration[]` ledger the sweep parked.
@@ -133,10 +135,13 @@ Flags:
133
135
 
134
136
  Per ledger entry `{ task_id, scope, phase_id, closed_at, pr_number }`:
135
137
 
138
+ 0. **Enumerate the ledger first.** Run `apt-tools task close-merged . --narrate-only [--task <id>]`. The envelope's `closed[]` is the read-only list of pending narration rows (each carries `narration_pending: true`); state.json is not mutated. When `closed[]` is empty, exit early with `{status: 'ok', narrated: [], skipped: [], remaining: 0}` — there is nothing to drain.
136
139
  1. Run `apt-tools features-audit . --apply-stubs --task <task_id>` so the feature registry catches whatever the closed task introduced. If the subprocess exits non-zero, skip the entry with `reason: 'features-audit-failed'` — never block narration on registry update.
137
140
  2. When `phase_id` is non-null, spawn `apt-team-docs-narrator` (background, fire-and-forget) using the same shape as §2.5: `subagent_type: apt-team-docs-narrator`, `run_in_background: true`, `description: Narrate shipped phase {phase_id}`, `prompt: Run in phase-ship mode with --phase {phase_id} --scope {scope}.`. When `phase_id` is null, skip the spawn with `reason: 'no-phase-id'` (quick tasks have no phase to narrate).
138
141
  3. On successful spawn (or successful no-phase skip), drain the row via `apt-tools task narration-drain . --task <task_id>` which acquires `withFileLock(state.json)` and removes the matching `pending_narration[]` entry. On spawn failure, leave the row in place with `reason: 'narrator-spawn-failed'` so the user can re-invoke.
139
142
 
143
+ **At-least-once retry semantics (FRAMEWORK-BUG-020).** Step 0 is read-only — it does NOT remove rows. Steps 1–3 are the existing per-row drain contract: a row stays in `pending_narration[]` until step 3 calls `narration-drain` successfully. If the orchestrator crashes between step 2 (narrator spawn) and step 3 (drain), the row remains parked and a retry will spawn narration again. Re-spawning is safe because the narrator is **anchor-replace idempotent** by design (see `agents/apt-team-docs-narrator.md` §6 / §2 and the "Idempotent operations only" rule in §notes) — it uses HTML-comment anchors to bound the replaced region in ROADMAP.md, so a second run replaces the same span rather than appending.
144
+
140
145
  Emit a summary envelope:
141
146
 
142
147
  ```json
@@ -412,19 +412,30 @@ ci_watch_armed=$(echo "$ship_update" | jq -r '.ci_watch_armed // false')
412
412
  ci_watch_skip_reason=$(echo "$ship_update" | jq -r '.ci_watch_skip_reason // empty')
413
413
  ci_watch_host=$(echo "$ship_update" | jq -r '.ci_watch.host // "unknown"')
414
414
  ci_watch_deadline=$(echo "$ship_update" | jq -r '.ci_watch.deadline // empty')
415
+ ci_watch_scheduler_handle=$(echo "$ship_update" | jq -r '.scheduler_handle // empty')
415
416
  host_cron=$(node packages/framework/bin/apt-tools.mjs host-detect | jq -r '.host.capabilities.cron')
416
417
  ```
417
418
 
418
419
  **Autonomy interaction** (apply BEFORE branching, same rule as 7.1): when `autonomy === 3` (YOLO), degrade `ask` → `auto`.
419
420
 
420
- **Branch on the task-update envelope:**
421
+ **Branch on the task-update envelope** (FRAMEWORK-BUG-018 — Track 1: distinguish state-recording from wake-registration):
421
422
 
422
- - **`ci_watch_armed=true` AND `host_cron=true`** — emit:
423
+ - **`ci_watch_armed=true` AND `host_cron=true` AND `ci_watch_scheduler_handle` non-empty** — emit:
423
424
 
424
425
  ```
425
426
  [APT:ship] CI watcher armed (host cron: {ci_watch_host}). Deadline: {ci_watch_deadline}. Stop: /apt:watch-ci --stop --pr {N}.
426
427
  ```
427
428
 
429
+ This is the only honest "armed" case — a scheduler handle proves the wake is registered.
430
+
431
+ - **`ci_watch_armed=true` AND `host_cron=true` AND `ci_watch_scheduler_handle` empty** — emit:
432
+
433
+ ```
434
+ [APT:ship] CI watcher state created (host: {ci_watch_host}). Register a wake-up to drive ticks: CronCreate every ~3min running '/apt:watch-ci --tick --pr {N}'. Deadline: {ci_watch_deadline}. Stop: /apt:watch-ci --stop --pr {N}.
435
+ ```
436
+
437
+ State is recorded but no scheduler is registered — the orchestrator MUST register a wake or ticks will never fire. Track 2 (auto-wake on arm) will populate `scheduler_handle` and route into the first branch instead.
438
+
428
439
  - **`ci_watch_armed=true` AND `host_cron=false`** — emit:
429
440
 
430
441
  ```
@@ -442,10 +453,11 @@ host_cron=$(node packages/framework/bin/apt-tools.mjs host-detect | jq -r '.host
442
453
  On `Y` (or empty enter), call:
443
454
 
444
455
  ```bash
445
- node packages/framework/bin/apt-tools.mjs ci-watch start . --pr {pr_number} --task-id {task-id} --host {cli}
456
+ arm_envelope=$(node packages/framework/bin/apt-tools.mjs ci-watch start . --pr {pr_number} --task-id {task-id} --host {cli})
457
+ ci_watch_scheduler_handle=$(echo "$arm_envelope" | jq -r '.scheduler_handle // empty')
446
458
  ```
447
459
 
448
- Then emit the same host-cron-aware success banner used for `ci_watch_armed=true`. On `N` or any other response, proceed silently to Section 8.
460
+ Then route into the same four-way logic above — branch 1 (armed with handle), branch 2 (state-created, needs wake), or branch 3 (host has no cron). On `N` or any other response, proceed silently to Section 8.
449
461
 
450
462
  - **`ci_watch_skip_reason=disabled`** — silent. Proceed directly to Section 8.
451
463
 
@@ -129,14 +129,26 @@ If `--with` resolves to the same CLI as the host (e.g. `--with claude` when `hos
129
129
 
130
130
  Do NOT proceed; self-sparring is sycophancy by construction (the host cannot objectively challenge itself). This check runs after `host-detect` and before any partner availability check.
131
131
 
132
- **Partner unavailable.** If the resolved partner CLI binary or script is not on `$PATH` or, for the codex-companion path, if `CLAUDE_PLUGIN_ROOT` is unset OR `${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs` does not exist print a clean fallback and stop:
132
+ **Partner transport ladder (FRAMEWORK-BUG-024).** Read `host.partner_transports.<partner>` from the host-detect envelope (`jq '.host.partner_transports.codex'`, etc.). Each partner has a 3-rung ladder evaluated in preference order **MCP-first** for cleaner tool-call integration, with automatic fallthrough to plugin shim then raw CLI:
133
+
134
+ 1. **Rung 1 — MCP tool** (`mcp__<partner>-mcp__*`, e.g. `mcp__codex-mcp__codex`). Detected if the host exposes its deferred-tool manifest via `CLAUDE_MCP_DEFERRED_TOOLS` and the tool is registered. When `detected: "runtime-host-only"` the framework process cannot probe the host's tool registry from outside — treat as candidate and attempt the tool call; if the tool is genuinely unavailable in the host's visible registry, fall through to rung 2.
135
+ 2. **Rung 2 — Plugin shim** (`${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`). Codex only (claude / gemini have no plugin shim).
136
+ 3. **Rung 3 — Raw CLI on `$PATH`** (`codex exec`, `claude -p`, `gemini -m`).
137
+
138
+ Walk the ladder in order; pick the first rung with `detected: true` (or `"runtime-host-only"` for MCP). **MCP rung has a hard timeout** — default 60000ms, configurable via `apt-spar.mcp_timeout_ms` in `.aperant/config.json`. Empirical: the Codex MCP `claude_code` tool has been observed to hang past 120s. On MCP timeout, **automatically fall through to rung 2 or rung 3** — do NOT fail the whole spar. Treat MCP timeout as "rung unavailable for this invocation."
139
+
140
+ If all three rungs are unavailable (every rung `detected: false`, or MCP `runtime-host-only` timed out AND rungs 2+3 are `detected: false`), print:
133
141
 
134
142
  ```
135
- [apt:spar] partner `<id>` unavailable. Either:
136
- - install the partner CLI (Codex: install the `codex` Claude Code plugin so `CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs` resolves; Claude: install the `claude` CLI on $PATH; Gemini: install the `gemini` CLI on $PATH)
137
- - re-run with `--with <other-id>` to pick a different partner
143
+ [apt:spar] partner `<id>` unavailable. Tried:
144
+ - MCP tool `mcp__<partner>-mcp__*`: <not loaded in this host | runtime-host-only + timed out at <ms>ms>
145
+ - Plugin shim `${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`: <missing reason>
146
+ - Raw CLI `<bin>` on $PATH: not found
147
+ Install any one of the three or re-run with `--with <other-id>`.
138
148
  ```
139
149
 
150
+ The failure message MUST name all three transports tried (`mcp`, `plugin shim`, `raw CLI`) so the user can pick the cheapest fix.
151
+
140
152
  Do NOT silently degrade to the host (sparring with yourself is sycophancy by construction).
141
153
 
142
154
  ## 4. Frame the Round
@@ -159,13 +171,26 @@ The frame is one shot per round — no multi-turn conversation with the partner
159
171
 
160
172
  **Wrap every partner Bash call with an explicit timeout** (default 90000ms, override via `--timeout-ms`). The empirical reason: the Codex MCP `claude_code` tool has been observed to hang past 120s while the `claude -p` CLI returns in seconds. A hung partner CLI must NOT stall the spar loop.
161
173
 
162
- The table below shows the canonical command shape; the actual invocation MUST use the heredoc pattern in the next subsection — never raw double-quoted interpolation.
163
-
164
- | Host | Command | Notes |
165
- |---|---|---|
166
- | `host=claude-code` (or `--with codex`) | `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)"` | **NO `--write`. NO `--effort` override.** Per SPAR-04 — spar is conversation, not patching. |
167
- | `host=codex` (or `--with claude`) | `claude -p "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)" --output-format text` | Symmetric. Claude has no `--write` equivalent — asymmetric flags are a smell. |
168
- | `--with gemini` (any host) | `gemini -m <model-from-router.llm.providers.gemini-cli> "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)"` | Read the exact model name from `.aperant/config.json` at runtime — do not hardcode. |
174
+ The table below shows the canonical command shape per (partner × rung); the actual invocation for shell-based rungs (plugin shim, raw CLI) MUST use the heredoc pattern in the next subsection — never raw double-quoted interpolation. The MCP rung is a structured tool call — no shell quoting concern applies.
175
+
176
+ <!--
177
+ Future contributor: the `codex exec` flag below is taken from the precedent
178
+ at `packages/framework/skills/apt-pr-review/SKILL.md:1084`. Codex CLI flag
179
+ surface evolves; verify with `codex --help` at execution time and update
180
+ this table if the non-interactive invocation shape changes.
181
+ -->
182
+
183
+ | Partner | Rung | Command | Notes |
184
+ |---|---|---|---|
185
+ | codex | mcp | tool call: `mcp__codex-mcp__codex({prompt})` | Preferred. 60s timeout (configurable `apt-spar.mcp_timeout_ms`); on timeout fall through to plugin then CLI. |
186
+ | codex | plugin | `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)"` | **NO `--write`. NO `--effort` override.** Per SPAR-04 — spar is conversation, not patching. |
187
+ | codex | cli | `codex exec "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)"` | Precedent: `apt-pr-review/SKILL.md:1084`. Verify with `codex --help`. |
188
+ | claude | mcp | tool call: `mcp__claude-code-mcp__claude_code({prompt})` | Preferred. Same 60s timeout + fallthrough rule. |
189
+ | claude | plugin | n/a | No plugin shim exists for Claude. Skip this rung. |
190
+ | claude | cli | `claude -p "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)" --output-format text` | Symmetric. Claude has no `--write` equivalent — asymmetric flags are a smell. |
191
+ | gemini | mcp | tool call: `mcp__gemini-mcp__*({prompt})` | Preferred. Same 60s timeout + fallthrough rule. Any `mcp__gemini-mcp__*` tool satisfies the rung. |
192
+ | gemini | plugin | n/a | No plugin shim exists for Gemini. Skip this rung. |
193
+ | gemini | cli | `gemini -m <model-from-router.llm.providers.gemini-cli> "$(cat <<'APT_SPAR_PROMPT_EOF' ... APT_SPAR_PROMPT_EOF\n)"` | Read the exact model name from `.aperant/config.json` at runtime — do not hardcode. |
169
194
 
170
195
  Set the Bash tool's `timeout` parameter to `--timeout-ms` (default 90000).
171
196
 
@@ -15,7 +15,7 @@ execution_modes:
15
15
  - auto
16
16
  - step
17
17
  allowed-tools: "Bash, Read, Write"
18
- argument-hint: "apt:update"
18
+ argument-hint: "apt:update [--from-source]"
19
19
  gates: []
20
20
  ---
21
21
 
@@ -90,6 +90,30 @@ npm install --prefix .aperant/deps @aperant/framework@<latest_version> --no-save
90
90
 
91
91
  On `npm 404` (`E404` in stderr) treat it as `not_published` and skip the kernel bump (proceed to step 3). On any other npm failure, surface the npm stderr verbatim and STOP — do NOT proceed to init with a half-installed kernel.
92
92
 
93
+ ## 1.6. Self-host case — bump from local workspace (FRAMEWORK-BUG-017)
94
+
95
+ When the user invokes `/apt:update --from-source` OR the route envelope's `update_check.reason` mentions `workspace source ahead of installed kernel`, the source of truth is the local `packages/framework/` workspace — NOT npm. This is the Aperant-contributor self-host case: a same-PR framework fix bumps the workspace `packages/framework/package.json:version` but the kernel under `.aperant/deps/node_modules/@aperant/framework/` still reflects the previous version. Step 1.5's `npm view` would fetch the published version (still the OLD one), so we must skip it.
96
+
97
+ The reliable bump mechanism mirrors what `runKernelInstall` does today — `pnpm pack` produces a versioned tarball, then `npm install --prefix` consumes it. Symlinking is fragile across re-installs and `pnpm install --filter` doesn't relocate the published artifact into `.aperant/deps`.
98
+
99
+ Walk up from the project root looking for a sibling `packages/framework/package.json`. When found AND its `name === "@aperant/framework"`:
100
+
101
+ ```bash
102
+ # 1. Pack the workspace source into a versioned tarball.
103
+ cd <workspace-root>
104
+ pnpm --filter @aperant/framework pack --pack-destination /tmp
105
+
106
+ # 2. Install the tarball into the project's kernel root.
107
+ cd <project-root>
108
+ npm install --prefix .aperant/deps /tmp/aperant-framework-<version>.tgz --no-save --no-package-lock --no-audit --no-fund
109
+ ```
110
+
111
+ Replace `<version>` with the workspace `package.json:version` you just read. The tarball name follows npm's `pack` convention (`@aperant/framework@X.Y.Z` → `aperant-framework-X.Y.Z.tgz`).
112
+
113
+ If `--from-source` was passed but no `packages/framework/package.json` is found anywhere above the project root, surface a one-line error (`⚠ --from-source: no workspace package.json found — falling back to npm`) and proceed with the standard step 1.5 npm path.
114
+
115
+ Then proceed to step 3 to re-install the runtime files at the new kernel version. Skip step 4 (cache clear) — the cache is keyed on `latest_version` from npm, and a `--from-source` bump never wrote to it, so there's nothing to invalidate.
116
+
93
117
  ## 2. Classify the update scenario
94
118
 
95
119
  Pick ONE of three scenarios based on what you parsed in step 1:
@@ -206,5 +230,6 @@ Runtime files were still refreshed; `/apt` will still work from PATH if `apt-too
206
230
  - **Worker hasn't run yet**: the cache file is missing. That's fine — run check-version directly (step 1 is robust to a missing cache). Proceed normally.
207
231
  - **Re-install fails (non-zero exit from init)**: surface the JSON `error` field verbatim in a single line, then stop. Do NOT delete the cache file — the chip should remain visible so the user sees the failure persists.
208
232
  - **Kernel install fails but runtime install succeeds**: see Scenario D above. Do not treat this as a total failure; the user's skills still work via PATH fallback.
233
+ - **CLAUDE.md / AGENTS.md foreign rows (exit 2, `instruction-file.foreign-rows`)** — 0.7.0+ — init now refuses to silently clobber custom rows in the `<!-- APT:framework-* -->` block. If `init` exits 2 with `code: instruction-file.foreign-rows`, the `foreignRowIds[]` field lists the rows the generator did NOT produce. Two paths: (a) remove the foreign rows from CLAUDE.md / AGENTS.md by hand and re-run init, or (b) re-run with `--force-overwrite-catalog` to regenerate anyway. Unattended upgrade flows should detect this code and surface it to the operator — silent bypass is a regression.
209
234
 
210
235
  </edge_cases>
@@ -87,7 +87,10 @@ For the `--tick` mode, execute exactly the following sequence — once — insid
87
87
 
88
88
  When `action ∈ {green, merged, closed, attempts-cap, wall-cap, identical-log}`:
89
89
 
90
- - **green** — print "CI green for PR {N}." If `ci_watch.auto_merge_on_green` is `true` in `.aperant/config.json`, run `gh pr merge {N} --auto --squash` then suggest `/apt:close-task`. Else print the exact D-08 text:
90
+ - **green** — print "CI green for PR {N}." If `ci_watch.auto_merge_on_green` is `true` in `.aperant/config.json`, run `gh pr merge {N} --auto --squash` then suggest `/apt:close-task`.
91
+ - **DO NOT pass `--delete-branch`** to `gh pr merge` from this auto-merge path — ever, unconditionally. The watcher's cwd is the task worktree, where `gh`'s `--delete-branch` post-merge cleanup tries to `git switch <base>` and fails because `<base>` is checked out in the primary worktree. The remote merge has already succeeded by then; local branch + worktree cleanup is `/apt:close-task`'s job via `apt-tools task close-merged` → `computeWorktreeCleanup` (see FRAMEWORK-BUG-019).
92
+
93
+ Else print the exact D-08 text:
91
94
 
92
95
  ```
93
96
  ✅ CI green — merge in browser or run /apt:close-task post-merge
@@ -26,12 +26,33 @@
26
26
 
27
27
  import { randomBytes } from 'node:crypto'
28
28
  import { existsSync, readFileSync } from 'node:fs'
29
- import { resolve } from 'node:path'
29
+ import { dirname, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
30
31
  import { acquireLock, releaseLock } from '../ci-watch/lock.mjs'
31
32
  import { deleteState, listWatches, readState, writeState } from '../ci-watch/state.mjs'
32
33
  import { classify, isFailing } from '../ci-watch/stop-matrix.mjs'
33
34
  import { parseFlags } from '../util/args.mjs'
34
35
  import { err, exitWith, ok } from '../util/result.mjs'
36
+ import { requireMinVersion } from '../util/version-preflight.mjs'
37
+
38
+ /**
39
+ * Installed kernel version — read once at module scope, same pattern as
40
+ * commands/check-version.mjs:108-109. Used by the version-preflight in
41
+ * `doTick` to fail loud (instead of infra-backoff-loop) when a future
42
+ * framework fix introduces a new tick flag and the user is on a stale
43
+ * install. See FRAMEWORK-BUG-017 rung 2 + the helper docstring for the
44
+ * "future-guard" framing.
45
+ */
46
+ const INSTALLED_KERNEL_VERSION = (() => {
47
+ try {
48
+ const here = dirname(fileURLToPath(import.meta.url))
49
+ const pkgPath = resolve(here, '..', '..', '..', 'package.json')
50
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
51
+ return typeof pkg?.version === 'string' ? pkg.version : null
52
+ } catch {
53
+ return null
54
+ }
55
+ })()
35
56
 
36
57
  const DEFAULT_TICK_INTERVAL_MS = 300_000 // 5 minutes
37
58
  const MAX_TICK_INTERVAL_MS = 30 * 60 * 1000 // 30 min
@@ -119,12 +140,14 @@ function doStart(root, flags) {
119
140
  command: 'ci-watch-start',
120
141
  pr: Number(pr),
121
142
  reason: 'already-armed',
143
+ scheduler_handle: existingState.scheduler_handle ?? null,
122
144
  existing: {
123
145
  task_id: existingState.task_id,
124
146
  host: existingState.host,
125
147
  attempts: existingState.attempts,
126
148
  started_at: existingState.started_at,
127
149
  deadline: existingState.deadline,
150
+ scheduler_handle: existingState.scheduler_handle ?? null,
128
151
  },
129
152
  },
130
153
  2,
@@ -261,6 +284,21 @@ function summarize(s) {
261
284
  // ---------------------------------------------------------------------------
262
285
 
263
286
  function doTick(root, flags) {
287
+ // FRAMEWORK-BUG-017 rung 2 (future-guard): emit a structured
288
+ // version_drift envelope when a future framework fix requires a
289
+ // new tick flag and the installed kernel predates it. The current
290
+ // pin to 0.6.7 (the version that introduced --payload-file) is a
291
+ // no-op on every 0.6.7+ install — but the scaffolding is in place
292
+ // for the NEXT new-flag fix to bump `introducedIn` without
293
+ // re-discovering the silent-infra-backoff failure mode.
294
+ const versionGate = requireMinVersion({
295
+ feature: 'payload-file',
296
+ introducedIn: '0.6.7',
297
+ installedVersion: INSTALLED_KERNEL_VERSION,
298
+ missingFlag: '--payload-file',
299
+ })
300
+ if (versionGate) return exitWith(versionGate, 1)
301
+
264
302
  const prCheck = validatePr(flags.get('pr'))
265
303
  if (typeof prCheck !== 'string') return prCheck
266
304
  const pr = prCheck
@@ -140,7 +140,7 @@ export function cmdHealthCheck(projectDir) {
140
140
  issues.push({
141
141
  type: 'missing-skills',
142
142
  severity: 'warning',
143
- detail: 'No installed skill version stamps found under .claude/skills or .agents/skills',
143
+ detail: 'No apt install manifests or version stamps found for any supported runtime',
144
144
  install_surfaces: installs,
145
145
  })
146
146
  }
@@ -41,6 +41,7 @@ import {
41
41
  writeLastInstallSummary,
42
42
  } from '../install/pipeline.mjs'
43
43
  import { RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
44
+ import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
44
45
  import {
45
46
  CLASS_A_SETTINGS,
46
47
  CLASS_B_SETTINGS,
@@ -48,6 +49,7 @@ import {
48
49
  parseClassFlags,
49
50
  } from '../install/setting-class.mjs'
50
51
  import { detectAgentRuntime } from '../util/agent-runtime.mjs'
52
+ import { buildAptSection } from '../util/aperant-section.mjs'
51
53
  import { atomicWriteJson } from '../util/atomic-write.mjs'
52
54
  import { aperantBanner } from '../util/banner.mjs'
53
55
  import { copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
@@ -88,8 +90,15 @@ const TRANSFORM_LOADERS = new Map(
88
90
  * additive: callers without the flag still get the no-op short-circuit
89
91
  * (preserves `init-upgrade-drift.test.ts` case (a)).
90
92
  *
93
+ * 0.7.0 — `--force-overwrite-catalog` (FRAMEWORK-BUG-023) bypasses the
94
+ * foreign-row defensive check in `injectInstructionFile`. Power-user
95
+ * flag only — no interview prompt. Without it, an instruction file
96
+ * carrying rows the generator didn't produce causes init to exit 2 with
97
+ * `code: 'instruction-file.foreign-rows'` so unattended upgrade flows
98
+ * surface the would-be data loss instead of silently clobbering it.
99
+ *
91
100
  * @param {string[]} args
92
- * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean }}
101
+ * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean, forceOverwriteCatalog: boolean, keepLegacy: boolean }}
93
102
  */
94
103
  export function parseInstallFlags(args) {
95
104
  const flagSet = new Set(args.filter((a) => a.startsWith('--')))
@@ -105,6 +114,14 @@ export function parseInstallFlags(args) {
105
114
  // 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
106
115
  // so /apt:update can re-stamp runtime files on a no-drift upgrade.
107
116
  const forceRuntimes = flagSet.has('--force-runtimes')
117
+ // 0.7.0 (FRAMEWORK-BUG-023) — opt-in bypass of the foreign-row check
118
+ // in injectInstructionFile. Power-user flag only.
119
+ const forceOverwriteCatalog = flagSet.has('--force-overwrite-catalog')
120
+ // 0.7.0 (FRAMEWORK-BUG-021) — opt out of the codex legacy-tree cleanup
121
+ // that fires when `codex` is in the runtime set. Defaults to false so
122
+ // the cleanup runs by default; pass `--keep-legacy` to preserve the
123
+ // orphaned `.agents/skills/` tree on upgrades.
124
+ const keepLegacy = flagSet.has('--keep-legacy')
108
125
 
109
126
  const classFlags = parseClassFlags(args)
110
127
 
@@ -112,6 +129,8 @@ export function parseInstallFlags(args) {
112
129
  target,
113
130
  skipBootstrap,
114
131
  forceRuntimes,
132
+ forceOverwriteCatalog,
133
+ keepLegacy,
115
134
  visibility: classFlags.visibility,
116
135
  autonomy: classFlags.autonomy,
117
136
  accept_new_defaults: classFlags.accept_new_defaults,
@@ -1164,6 +1183,27 @@ export async function cmdInit(projectDir, extraArgs = []) {
1164
1183
  }
1165
1184
  }
1166
1185
 
1186
+ // FRAMEWORK-BUG-021 — sweep the legacy `.agents/skills/` codex tree
1187
+ // BEFORE the install pipeline runs so the install can re-stamp
1188
+ // `.codex/` against a clean slate. Only fires when codex is in the
1189
+ // requested runtime set and `--keep-legacy` was not passed; the
1190
+ // helper itself is conservative (no-op on managed installs, no-op
1191
+ // when no legacy framework files are present).
1192
+ let legacyCleanup = null
1193
+ if (effectiveAnswers.runtimes.includes('codex')) {
1194
+ if (parsedFlags.keepLegacy) {
1195
+ legacyCleanup = { runtime: 'codex', ran: false, reason: '--keep-legacy' }
1196
+ } else {
1197
+ const cleanup = runLegacyCleanup({ targetDir: installTarget })
1198
+ legacyCleanup = { runtime: 'codex', ...cleanup }
1199
+ if (cleanup.ran && cleanup.retained.length > 0) {
1200
+ process.stderr.write(
1201
+ `[apt-tools] legacy_cleanup retained ${cleanup.retained.length} user-added path(s) under .agents/skills/: ${cleanup.retained.join(', ')}\n`,
1202
+ )
1203
+ }
1204
+ }
1205
+ }
1206
+
1167
1207
  const results = await installRuntimes(
1168
1208
  effectiveAnswers.runtimes,
1169
1209
  canonicalRoot,
@@ -1186,7 +1226,14 @@ export async function cmdInit(projectDir, extraArgs = []) {
1186
1226
  effectiveAnswers,
1187
1227
  interactiveTty,
1188
1228
  kernelResult,
1229
+ parsedFlags.forceOverwriteCatalog === true,
1230
+ legacyCleanup,
1189
1231
  )
1232
+ // FRAMEWORK-BUG-023 — finishInit signals the foreign-row defensive
1233
+ // abort by returning an early-exit sentinel envelope.
1234
+ if (envelope?.__earlyExit) {
1235
+ return envelope.__earlyExit
1236
+ }
1190
1237
  if (interactiveTty) {
1191
1238
  await renderOutro(effectiveAnswers, results, version, kernelResult)
1192
1239
  // TTY mode: envelope was persisted to disk + suppressed from stdout
@@ -1365,6 +1412,19 @@ async function renderOutro(answers, results, version, kernelResult) {
1365
1412
  * deps/` — surfaced in the envelope so
1366
1413
  * downstream tooling can detect a
1367
1414
  * degraded install.
1415
+ * @param {boolean} [forceOverwriteCatalog=false]
1416
+ * FRAMEWORK-BUG-023 — bypass the
1417
+ * foreign-row check in
1418
+ * injectInstructionFile when the user
1419
+ * explicitly opted in via
1420
+ * --force-overwrite-catalog.
1421
+ * @param {object|null} [legacyCleanup=null]
1422
+ * FRAMEWORK-BUG-021 — outcome of the
1423
+ * codex `.agents/skills/` cleanup
1424
+ * step. `null` when codex was not in
1425
+ * the runtime set; otherwise the
1426
+ * shape `{ runtime, ran, … }` from
1427
+ * `runLegacyCleanup`.
1368
1428
  */
1369
1429
  function finishInit(
1370
1430
  targetDir,
@@ -1376,6 +1436,8 @@ function finishInit(
1376
1436
  answers,
1377
1437
  suppressStdout = false,
1378
1438
  kernelResult = null,
1439
+ forceOverwriteCatalog = false,
1440
+ legacyCleanup = null,
1379
1441
  ) {
1380
1442
  writeLastInstallSummary(targetDir, runtimeResults, version)
1381
1443
 
@@ -1506,7 +1568,40 @@ function finishInit(
1506
1568
  if (existsSync(claudeMdPath)) instructionFile = claudeMdPath
1507
1569
  else if (existsSync(agentsMdPath)) instructionFile = agentsMdPath
1508
1570
  else instructionFile = claudeMdPath
1509
- const injectionResult = injectInstructionFile(instructionFile)
1571
+ // FRAMEWORK-BUG-023 generate the section from discoverSkills before
1572
+ // handing it to the writer. The writer also runs a foreign-row defensive
1573
+ // check; if rows present in the file aren't in the generated set we
1574
+ // signal exit-2 via the __earlyExit sentinel so cmdInit can route the
1575
+ // envelope through exitWith() without writing a malformed install.
1576
+ const aptSection = buildAptSection({
1577
+ canonicalRoot,
1578
+ targetDir,
1579
+ cli: detectAgentRuntime(),
1580
+ })
1581
+ const injectionOutcome = injectInstructionFile(instructionFile, {
1582
+ aptSection,
1583
+ force: forceOverwriteCatalog,
1584
+ })
1585
+ if (injectionOutcome.status === 'aborted-foreign-rows') {
1586
+ return {
1587
+ __earlyExit: exitWith(
1588
+ {
1589
+ ok: false,
1590
+ status: 'error',
1591
+ command: 'init',
1592
+ code: 'instruction-file.foreign-rows',
1593
+ message:
1594
+ `${basename(instructionFile)} contains skill rows not present in the installed framework: ` +
1595
+ `${injectionOutcome.foreignRowIds.join(', ')}. ` +
1596
+ 'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
1597
+ foreignRowIds: injectionOutcome.foreignRowIds,
1598
+ instructionFile: basename(instructionFile),
1599
+ },
1600
+ 2,
1601
+ ),
1602
+ }
1603
+ }
1604
+ const injectionResult = injectionOutcome.status
1510
1605
 
1511
1606
  const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
1512
1607
  const precompactResult = installedClaude
@@ -1617,6 +1712,11 @@ function finishInit(
1617
1712
  ...(kernelResult.reason ? { reason: kernelResult.reason } : {}),
1618
1713
  }
1619
1714
  : null,
1715
+ // FRAMEWORK-BUG-021 — always-present envelope field. `null` when
1716
+ // codex was not in the runtime set; otherwise the cleanup outcome
1717
+ // (uses `{ ran: false, reason }` for every skip case so parsers
1718
+ // see a uniform shape).
1719
+ legacy_cleanup: legacyCleanup,
1620
1720
  }
1621
1721
 
1622
1722
  if (suppressStdout) {