@aperant/framework 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/apt-pr-review-fixer.md +9 -4
  3. package/dist/cli/commands/init.mjs +140 -22
  4. package/dist/cli/commands/init.mjs.map +1 -1
  5. package/dist/cli/commands/toolchain-detect.d.mts.map +1 -1
  6. package/dist/cli/commands/toolchain-detect.mjs +44 -0
  7. package/dist/cli/commands/toolchain-detect.mjs.map +1 -1
  8. package/dist/cli/commands/triage.d.mts.map +1 -1
  9. package/dist/cli/commands/triage.mjs +346 -13
  10. package/dist/cli/commands/triage.mjs.map +1 -1
  11. package/dist/cli/commands/uninstall.d.mts.map +1 -1
  12. package/dist/cli/commands/uninstall.mjs +11 -8
  13. package/dist/cli/commands/uninstall.mjs.map +1 -1
  14. package/dist/cli/install/manifest.d.mts +13 -0
  15. package/dist/cli/install/manifest.d.mts.map +1 -1
  16. package/dist/cli/install/manifest.mjs +5 -0
  17. package/dist/cli/install/manifest.mjs.map +1 -1
  18. package/dist/cli/install/pipeline.d.mts +15 -0
  19. package/dist/cli/install/pipeline.d.mts.map +1 -1
  20. package/dist/cli/install/pipeline.mjs +27 -0
  21. package/dist/cli/install/pipeline.mjs.map +1 -1
  22. package/dist/cli/install/runtime-detect.d.mts +26 -0
  23. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  24. package/dist/cli/install/runtime-detect.mjs +27 -0
  25. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  26. package/dist/cli/install/transforms/codex.d.mts +1 -1
  27. package/dist/cli/install/transforms/codex.d.mts.map +1 -1
  28. package/dist/cli/install/transforms/codex.mjs +43 -2
  29. package/dist/cli/install/transforms/codex.mjs.map +1 -1
  30. package/dist/cli/install/version-header.d.mts.map +1 -1
  31. package/dist/cli/install/version-header.mjs +7 -1
  32. package/dist/cli/install/version-header.mjs.map +1 -1
  33. package/dist/cli/util/aperant-section.d.mts +12 -0
  34. package/dist/cli/util/aperant-section.d.mts.map +1 -1
  35. package/dist/cli/util/aperant-section.mjs +12 -0
  36. package/dist/cli/util/aperant-section.mjs.map +1 -1
  37. package/dist/cli/util/copy.d.mts +27 -0
  38. package/dist/cli/util/copy.d.mts.map +1 -1
  39. package/dist/cli/util/copy.mjs +49 -5
  40. package/dist/cli/util/copy.mjs.map +1 -1
  41. package/dist/cli/verify-proof/idl/index.d.mts +12 -1
  42. package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
  43. package/dist/cli/verify-proof/idl/index.mjs +50 -1
  44. package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
  45. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  46. package/dist/cli/verify-proof/resolver.mjs +51 -1
  47. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  48. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  49. package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
  50. package/dist/plugin/skills/apt-pr-review/SKILL.md +52 -15
  51. package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
  52. package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
  53. package/dist/plugin/skills/apt-update/SKILL.md +1 -1
  54. package/package.json +1 -1
  55. package/skills/apt-pr-review/SKILL.md +52 -15
  56. package/skills/apt-setup/SKILL.md +7 -6
  57. package/skills/apt-triage/SKILL.md +8 -6
  58. package/skills/apt-update/SKILL.md +1 -1
  59. package/src/cli/commands/init.mjs +144 -19
  60. package/src/cli/commands/toolchain-detect.mjs +44 -0
  61. package/src/cli/commands/triage.mjs +352 -11
  62. package/src/cli/commands/uninstall.mjs +11 -8
  63. package/src/cli/install/manifest.mjs +5 -0
  64. package/src/cli/install/pipeline.mjs +28 -0
  65. package/src/cli/install/runtime-detect.mjs +27 -0
  66. package/src/cli/install/transforms/codex.mjs +51 -2
  67. package/src/cli/install/version-header.mjs +7 -1
  68. package/src/cli/util/aperant-section.mjs +14 -0
  69. package/src/cli/util/copy.mjs +53 -8
  70. package/src/cli/verify-proof/idl/index.mjs +49 -11
  71. package/src/cli/verify-proof/resolver.mjs +49 -2
@@ -127,16 +127,18 @@ needed.
127
127
 
128
128
  `.aperant/config.json.task_tracking.backend` selects the mirror:
129
129
 
130
- | Backend | v1 behavior |
130
+ | Backend | behavior |
131
131
  |---|---|
132
132
  | `local-only` (default) | State mirrors to `.aperant/tasks/{task-id}/triage.json` only. Zero network dependency. |
133
- | `github-issues` | Returns `{status: 'not-implemented', backend: 'github-issues', v2_issue: '#TBD'}`. Implementation deferred to v2. |
134
- | `app-inbox` | Same as github-issues stub returning not-implemented. |
133
+ | `github-issues` | Mirrors triage state to a real GitHub issue via the `gh` CLI. The first mirror CREATES an issue (`triage: <task-id>`, labelled `<category>` + `<vocab>:<state>`); subsequent transitions UPDATE the same issue (label swap), and `wontfix` CLOSES it as "not planned". The issue pointer (`github.{issue_number, issue_url, repo}`) persists on `triage.json` so transitions stay idempotent — no duplicates. Honors `task_tracking.tracker_label_vocabulary` (default → `triage:<state>`). Requires `gh` installed + authenticated + a resolvable git remote; otherwise returns a structured error (`reason: gh-unavailable | gh-unauthenticated | no-remote | gh-command-failed`) and exits non-zero — never a crash/stacktrace. Label ops are best-effort: an unknown label degrades to a `warnings[]` entry while the open/closed state change still lands. |
134
+ | `app-inbox` | Still a stub: returns `{status: 'not-implemented', backend: 'app-inbox', v2_issue: '#TBD'}`. Belongs to a future Convex-backed task (post-app-launch). |
135
135
 
136
136
  Skill body reads `task_tracking.backend` at the start of every
137
- invocation. If the backend's adapter is not implemented, the skill
138
- fails fast with the `not-implemented` envelope — it does NOT silently
139
- fall back to `local-only`.
137
+ invocation. For `app-inbox` (still unimplemented), the skill fails fast
138
+ with the `not-implemented` envelope — it does NOT silently fall back to
139
+ `local-only`. The `github-issues` backend is fully implemented (see
140
+ above); its preflight/`gh` failures surface as structured errors, again
141
+ without falling back to `local-only`.
140
142
 
141
143
  ## Fast Path Guarantee — QUICK never enters triage (AC9, ID-05)
142
144
 
@@ -287,6 +287,6 @@ Runtime files were still refreshed; `/apt` will still work from PATH if `apt-too
287
287
  - **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.
288
288
  - **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.
289
289
  - **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.
290
- - **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.
290
+ - **Instruction-file foreign rows (exit 2, `instruction-file.foreign-rows`)** — 0.7.0+ — init now refuses to silently clobber custom rows in the `<!-- APT:framework-* -->` block. Since the multi-file fix, init stamps the block into EVERY instruction file the selected runtimes read (claude → CLAUDE.md, codex/opencode → AGENTS.md, gemini → GEMINI.md) and runs an all-target foreign-row preflight: if ANY target has foreign rows, init writes NOTHING and exits 2 (no partial install). If `init` exits 2 with `code: instruction-file.foreign-rows`, the `foreignRowIds[]` field lists the rows the generator did NOT produce and `instructionFiles[]` lists the offending files. Two paths: (a) remove the foreign rows from the listed file(s) 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.
291
291
 
292
292
  </edge_cases>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperant/framework",
3
- "version": "0.8.6",
3
+ "version": "0.9.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",
@@ -65,11 +65,12 @@ PHASE 3 -- PARALLEL REVIEW (6 agents, independent, all spawned in ONE message):
65
65
  PHASE 4 -- VALIDATION (1 agent):
66
66
  validator -> Re-investigates every finding, confirms or dismisses
67
67
 
68
- PHASE 6 -- FIX LOOP (up to 3 agents per iteration, max 4 iterations):
69
- fixer-1, fixer-2, fixer-3
68
+ PHASE 6 -- FIX LOOP (up to 3 fixers per iteration, spawned ONE AT A TIME, max 4 iterations):
69
+ fixer-1, fixer-2, fixer-3 (groups still exist; they run SEQUENTIALLY, never concurrently)
70
70
  - Read validated finding files directly
71
- - Fix assigned issues, run verification, stage changes
71
+ - Fix assigned issues, run verification, commit atomically per finding
72
72
  - Report brief status to orchestrator
73
+ - NEVER run pr-review fixers in parallel in a shared worktree (FRAMEWORK-BUG-006)
73
74
 
74
75
  PHASE 7 -- SELF-REVIEW (1 fresh agent):
75
76
  self-reviewer
@@ -594,6 +595,16 @@ Construct a toolchain commands object:
594
595
 
595
596
  Save to `.aperant/pr-reviews/${REVIEW_ID}/toolchain.json`.
596
597
 
598
+ **Cache-bypass variant (FRAMEWORK-BUG-006 Part B).** `toolchain-detect` emits an additive
599
+ `no_cache_command` field on `typecheck` (and `test`) when the project's verify command is turbo-backed
600
+ (e.g. `typecheck: { tool, command, no_cache_command: "pnpm run typecheck --force" }`). A turbo-backed
601
+ verify can return a STALE CACHE HIT ("replaying logs") that reports PASS while a fixer-introduced broken
602
+ import sits on disk — sighted on PR #244, where only a `--force` (no-cache) typecheck exposed it.
603
+ **Whenever a `no_cache_command` exists for a check, every POST-FIX verification (auto-checks re-runs,
604
+ the Phase 6 fixer verification, and the Phase 7 self-reviewer) MUST use it instead of the plain
605
+ `command`** so a stale cache cannot mask a broken import. Non-turbo projects have no `no_cache_command`
606
+ (plain `tsc`/`vitest` have no cache to bust) and use the plain command unchanged.
607
+
597
608
  Update `metadata.json` with the toolchain summary and trigger count.
598
609
 
599
610
  ---
@@ -663,8 +674,8 @@ cd {WORKTREE_PATH}
663
674
  ```
664
675
 
665
676
  1. Lint: {LINT_COMMAND}
666
- 2. Typecheck: {TYPECHECK_COMMAND}
667
- 3. Tests: {TEST_COMMAND}
677
+ 2. Typecheck: {TYPECHECK_COMMAND} (use toolchain.json `typecheck.no_cache_command` if present — FRAMEWORK-BUG-006: a turbo-backed typecheck can replay a stale cached PASS and mask a broken import)
678
+ 3. Tests: {TEST_COMMAND} (use toolchain.json `test.no_cache_command` if present, same cache-bypass rationale)
668
679
 
669
680
  ## Output
670
681
 
@@ -1171,8 +1182,9 @@ This is the owned-mode path. External-mode invocations run Phase 6b instead of t
1171
1182
  for ITERATION in 1 2 3 4:
1172
1183
  1. Create iteration directory
1173
1184
  2. Group findings into fix assignments (max 3 groups)
1174
- 3. Spawn fix agents
1175
- 4. Wait for completion
1185
+ 3. Spawn fix agents ONE AT A TIME (never in parallel — FRAMEWORK-BUG-006):
1186
+ for each group: spawn fixer → wait → audit (Step 4b) → next group
1187
+ 4. (folded into step 3's per-fixer wait)
1176
1188
  5. Generate new diff
1177
1189
  6. If fixes were applied: spawn fresh review on new diff
1178
1190
  7. If ITERATION <= 2: accept new findings into the backlog
@@ -1197,6 +1209,14 @@ Take all confirmed, fixable findings and group them into up to 3 independent gro
1197
1209
 
1198
1210
  #### Step 3: Spawn Fix Agents
1199
1211
 
1212
+ > **HARD RULE (FRAMEWORK-BUG-006): NEVER run pr-review fixers in parallel in a shared worktree.**
1213
+ > All fixers share ONE worktree, ONE git index, and ONE branch. git's index is not a
1214
+ > concurrency-safe surface — concurrent `git add` / `git commit` / self-audit `git log`/`git diff`
1215
+ > from sibling fixers either SWEEP one fixer's staged files into another's commit (PR #114
1216
+ > entanglement) or CLOBBER another fixer's working-tree edits entirely (PR #244 work-loss). Spawn
1217
+ > fixers **one at a time**: spawn fixer-1, wait for it to return, run the Step 4b audit-fixer gate on
1218
+ > it, THEN spawn fixer-2, and so on. At most one fixer touches the shared worktree at any moment.
1219
+
1200
1220
  **Capture the iteration-start SHA BEFORE spawning fixers** (used by both the Step 4b audit gate AND the per-fixer Pre-Return Self-Audit added in BUG-016 — fixers now commit atomically per finding, and the audit window is scoped by this SHA):
1201
1221
 
1202
1222
  ```bash
@@ -1211,9 +1231,9 @@ For each fix group, spawn a fix agent. Read the agent definition from `.claude/a
1211
1231
  | `[FIX_ASSIGNMENT]` | List of finding IDs and files for this group |
1212
1232
  | `[N]` | Current iteration number |
1213
1233
  | `[FIXER_NAME]` | `fixer-1`, `fixer-2`, or `fixer-3` |
1214
- | `[TOOLCHAIN_COMMANDS]` | Commands from `toolchain.json` (the `all` command, or individual commands) |
1234
+ | `[TOOLCHAIN_COMMANDS]` | Commands from `toolchain.json` (the `all` command, or individual commands). **For the post-fix typecheck/test, pass the `no_cache_command` variant when `toolchain.json` provides one** (FRAMEWORK-BUG-006: a turbo-backed verify can replay a stale cached PASS and mask the fixer's own broken import). |
1215
1235
  | `[CRITICAL_APPROVAL]` | `true` or `false` for each critical finding in this group |
1216
- | `[PARALLEL_FIXERS]` | `true` if more than 1 fixer is spawned this iteration |
1236
+ | `[PARALLEL_FIXERS]` | Always `false` (FRAMEWORK-BUG-006). Fixers are spawned one at a time in the shared worktree — never concurrently. The placeholder is retained only so the fixer-agent Coordination Protocol can assert "you are the only fixer touching this worktree right now." |
1217
1237
  | `[WORKTREE_PATH]` | The worktree path — fix agents edit files HERE, not in the user's working directory |
1218
1238
  | `[ITERATION_START_SHA]` | The SHA captured BEFORE spawning fixers (see Step 4b's `ITERATION_START_SHA=$(...)` capture). Required for BUG-016 commit-discipline Pre-Return Self-Audit. |
1219
1239
 
@@ -1226,18 +1246,31 @@ Agent(
1226
1246
  )
1227
1247
  ```
1228
1248
 
1229
- If there are 2-3 groups, spawn all fix agents in ONE message for parallel execution.
1249
+ If there are 2-3 groups, spawn the fixers **sequentially one fixer at a time, never in parallel**
1250
+ (FRAMEWORK-BUG-006). Concretely, loop over the groups:
1251
+
1252
+ ```
1253
+ for each fix group (in order):
1254
+ 1. Spawn the fixer for THIS group only (a single Agent call in its own message)
1255
+ 2. Wait for it to return its brief status line (Step 4)
1256
+ 3. Run the Step 4b audit-fixer gate on it
1257
+ 4. Only THEN spawn the fixer for the next group
1258
+ ```
1259
+
1260
+ Do NOT batch multiple `Agent(...)` fixer calls into one message — that is the parallel-shared-worktree
1261
+ race this bug fixes. The atomic-per-finding commit discipline (BUG-016) and the Step 4b audit gate are
1262
+ UNCHANGED; they now simply run without a concurrent sibling mutating the index.
1230
1263
 
1231
1264
  #### Step 4: Wait for Completion
1232
1265
 
1233
- Wait for all fix agents to return their brief status:
1266
+ Wait for the **current** fix agent to return its brief status (you spawned only one this turn):
1234
1267
  ```
1235
1268
  FIXED: X | FAILED: Y | SKIPPED: Z | VERIFICATION: PASS/FAIL
1236
1269
  ```
1237
1270
 
1238
1271
  #### Step 4b: Audit Fixer Output (Hallucination Gate)
1239
1272
 
1240
- On 2026-04-20 a live fix agent returned `FIXED: 2 | FAILED: 0 | SKIPPED: 0 | VERIFICATION: PASS` and wrote a convincing 140-line `fixes-applied.md` — but the worktree had zero edits matching the claimed files. The orchestrator trusted the self-reported status line and nearly shipped two fabricated fixes. This step is the deterministic gate that closes that hole: for EACH fixer that returned in Step 4, run the audit CLI and cross-check the fixer's claims against the actual git state.
1273
+ On 2026-04-20 a live fix agent returned `FIXED: 2 | FAILED: 0 | SKIPPED: 0 | VERIFICATION: PASS` and wrote a convincing 140-line `fixes-applied.md` — but the worktree had zero edits matching the claimed files. The orchestrator trusted the self-reported status line and nearly shipped two fabricated fixes. This step is the deterministic gate that closes that hole: for the fixer that just returned in Step 4 (fixers run one at a time — FRAMEWORK-BUG-006), run the audit CLI and cross-check the fixer's claims against the actual git state BEFORE spawning the next group's fixer.
1241
1274
 
1242
1275
  **Capture the iteration-start SHA BEFORE spawning fixers** so the audit can cover committed fixes (bug-5, 2026-04-23 — fixers that commit their edits before returning would otherwise present an empty working tree and trip a false-positive hallucination verdict):
1243
1276
 
@@ -1271,7 +1304,7 @@ Rule of thumb: pass `--since-sha "${ITERATION_START_SHA}"` on every audit invoca
1271
1304
 
1272
1305
  The CLI returns a JSON envelope with `verdict` ∈ `verified | hallucinated | skipped`, writes `iterations/${ITERATION}/audit.json` (append-only), and bumps `metadata.json.fixer_hallucinations_total` on every `hallucinated` outcome. The envelope's `observed.since_sha` + `observed.since_sha_source` fields record which SHA was used and where it came from (`flag` | `merge-base` | `null`). The same envelope is used for the fixer and the self-reviewer (Phase 7) — `--fixer self-reviewer` reads from `self-review.md`.
1273
1306
 
1274
- **Canonical fix-report filename per fixer (BUG-030):** each fixer agent writes to `fixes-applied-${FIXER_NAME}.md` (e.g. `fixes-applied-fixer-1.md`). The audit CLI resolves candidates in strict order: `fixes-applied-group{N}.md` (legacy group-named, pre-BUG-030 reviews) → `fixes-applied-fixer-{N}.md` (NEW fixer-named) → `fixes-applied-${FIXER_NAME}.md` (exact match, preferred for non-numeric fixer names) → `fixes-applied.md` (legacy unscoped, single-fixer reviews only). Writing the canonical name eliminates the cross-fixer false-positive `hallucinated` verdict that fired when two parallel fixers shared `fixes-applied.md`.
1307
+ **Canonical fix-report filename per fixer (BUG-030):** each fixer agent writes to `fixes-applied-${FIXER_NAME}.md` (e.g. `fixes-applied-fixer-1.md`). The audit CLI resolves candidates in strict order: `fixes-applied-group{N}.md` (legacy group-named, pre-BUG-030 reviews) → `fixes-applied-fixer-{N}.md` (NEW fixer-named) → `fixes-applied-${FIXER_NAME}.md` (exact match, preferred for non-numeric fixer names) → `fixes-applied.md` (legacy unscoped, single-fixer reviews only). Writing the canonical name eliminates the cross-fixer false-positive `hallucinated` verdict that fired when fixers shared `fixes-applied.md` (a per-fixer report path is still correct now that fixers run sequentially — each fixer owns its own report file).
1275
1308
 
1276
1309
  | Verdict | Orchestrator Action |
1277
1310
  |---------|---------------------|
@@ -1430,7 +1463,7 @@ Read the agent definition from `.claude/agents/apt-pr-review-self-reviewer.md` a
1430
1463
  |-------------|-------|
1431
1464
  | `[FIX_DIFF]` | The combined fix diff content or path to the concatenated file |
1432
1465
  | `[REVIEW_DIR]` | `.aperant/pr-reviews/{REVIEW_ID}` |
1433
- | `[TOOLCHAIN_COMMANDS]` | Commands from `toolchain.json` |
1466
+ | `[TOOLCHAIN_COMMANDS]` | Commands from `toolchain.json`. **Use the `no_cache_command` variant for the typecheck/test when one is present** (FRAMEWORK-BUG-006: a turbo-backed verify can replay a stale cached PASS and mask a broken import the fixes introduced). |
1434
1467
  | `[WORKTREE_PATH]` | The worktree path — self-reviewer works HERE |
1435
1468
 
1436
1469
  ```
@@ -1683,11 +1716,15 @@ Fix agents self-report their outcome via a status line (`FIXED: X | FAILED: Y |
1683
1716
  **Wrong:** Reading the full finding text from agent responses and processing it in orchestrator context.
1684
1717
  **Right:** Agent responses are brief (3-5 lines). All detail goes to files. Orchestrator reads files via Read tool only when needed.
1685
1718
 
1686
- ### Mistake 2: Spawning Agents Sequentially
1719
+ ### Mistake 2: Spawning the Phase 3 REVIEW Agents Sequentially
1687
1720
 
1688
1721
  **Wrong:** Spawn security agent, wait, spawn quality agent, wait, etc.
1689
1722
  **Right:** Spawn all 6 review agents in ONE message using 6 parallel Agent tool calls. They run concurrently.
1690
1723
 
1724
+ > Scope note: this applies ONLY to the Phase 3 review specialists, which are read-only (Read/Grep/Glob,
1725
+ > no Edit/Bash, no git writes) and therefore cannot race the index. The Phase 6 FIXERS are the
1726
+ > opposite — they MUST be spawned one at a time (FRAMEWORK-BUG-006). Never parallelize fixers.
1727
+
1691
1728
  ### Mistake 3: Skipping the Validator
1692
1729
 
1693
1730
  **Wrong:** Sending raw review findings directly to fix agents.
@@ -966,8 +966,9 @@ ST-3 apt:execute --tdd).
966
966
 
967
967
  Per ID-01 (Framework full-featured locally; backends are pluggable), the
968
968
  default is `local-only` — zero GitHub auth required on first run. Users
969
- can switch to `github-issues` (v1 stub) or `app-inbox` (v1 stub) later;
970
- the framework abstraction is open for v2 adapter implementations.
969
+ can switch to `github-issues` (implemented creates + syncs real issues
970
+ via the `gh` CLI) or `app-inbox` (still a stub) later; the framework
971
+ abstraction is open for further adapter implementations.
971
972
 
972
973
  ### 3g.1 Present the task-tracking picker
973
974
 
@@ -979,10 +980,10 @@ AskUserQuestion([
979
980
  options: [
980
981
  { label: "Local files only (recommended for solo / 2-founder)",
981
982
  description: "Writes to .aperant/tasks/{id}/triage.json. Zero net dep." },
982
- { label: "GitHub Issues (v2 stub returns not-implemented in v1)",
983
- description: "v1 ships the adapter shape only; full implementation in v2." },
984
- { label: "App inbox (post-app-launch — stub in v1)",
985
- description: "Convex-backed; same state machine, app-rendered." }
983
+ { label: "GitHub Issues (creates + syncs real issues via the gh CLI)",
984
+ description: "First mirror creates an issue; transitions update the same issue (label swap), wontfix closes it as not-planned. Requires gh installed + authed + a git remote." },
985
+ { label: "App inbox (post-app-launch — stub)",
986
+ description: "Convex-backed; same state machine, app-rendered. Still a stub." }
986
987
  ]
987
988
  },
988
989
  {
@@ -127,16 +127,18 @@ needed.
127
127
 
128
128
  `.aperant/config.json.task_tracking.backend` selects the mirror:
129
129
 
130
- | Backend | v1 behavior |
130
+ | Backend | behavior |
131
131
  |---|---|
132
132
  | `local-only` (default) | State mirrors to `.aperant/tasks/{task-id}/triage.json` only. Zero network dependency. |
133
- | `github-issues` | Returns `{status: 'not-implemented', backend: 'github-issues', v2_issue: '#TBD'}`. Implementation deferred to v2. |
134
- | `app-inbox` | Same as github-issues stub returning not-implemented. |
133
+ | `github-issues` | Mirrors triage state to a real GitHub issue via the `gh` CLI. The first mirror CREATES an issue (`triage: <task-id>`, labelled `<category>` + `<vocab>:<state>`); subsequent transitions UPDATE the same issue (label swap), and `wontfix` CLOSES it as "not planned". The issue pointer (`github.{issue_number, issue_url, repo}`) persists on `triage.json` so transitions stay idempotent — no duplicates. Honors `task_tracking.tracker_label_vocabulary` (default → `triage:<state>`). Requires `gh` installed + authenticated + a resolvable git remote; otherwise returns a structured error (`reason: gh-unavailable | gh-unauthenticated | no-remote | gh-command-failed`) and exits non-zero — never a crash/stacktrace. Label ops are best-effort: an unknown label degrades to a `warnings[]` entry while the open/closed state change still lands. |
134
+ | `app-inbox` | Still a stub: returns `{status: 'not-implemented', backend: 'app-inbox', v2_issue: '#TBD'}`. Belongs to a future Convex-backed task (post-app-launch). |
135
135
 
136
136
  Skill body reads `task_tracking.backend` at the start of every
137
- invocation. If the backend's adapter is not implemented, the skill
138
- fails fast with the `not-implemented` envelope — it does NOT silently
139
- fall back to `local-only`.
137
+ invocation. For `app-inbox` (still unimplemented), the skill fails fast
138
+ with the `not-implemented` envelope — it does NOT silently fall back to
139
+ `local-only`. The `github-issues` backend is fully implemented (see
140
+ above); its preflight/`gh` failures surface as structured errors, again
141
+ without falling back to `local-only`.
140
142
 
141
143
  ## Fast Path Guarantee — QUICK never enters triage (AC9, ID-05)
142
144
 
@@ -287,6 +287,6 @@ Runtime files were still refreshed; `/apt` will still work from PATH if `apt-too
287
287
  - **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.
288
288
  - **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.
289
289
  - **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.
290
- - **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.
290
+ - **Instruction-file foreign rows (exit 2, `instruction-file.foreign-rows`)** — 0.7.0+ — init now refuses to silently clobber custom rows in the `<!-- APT:framework-* -->` block. Since the multi-file fix, init stamps the block into EVERY instruction file the selected runtimes read (claude → CLAUDE.md, codex/opencode → AGENTS.md, gemini → GEMINI.md) and runs an all-target foreign-row preflight: if ANY target has foreign rows, init writes NOTHING and exits 2 (no partial install). If `init` exits 2 with `code: instruction-file.foreign-rows`, the `foreignRowIds[]` field lists the rows the generator did NOT produce and `instructionFiles[]` lists the offending files. Two paths: (a) remove the foreign rows from the listed file(s) 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.
291
291
 
292
292
  </edge_cases>
@@ -59,7 +59,7 @@ import { detectAgentRuntime } from '../util/agent-runtime.mjs'
59
59
  import { buildAptSection } from '../util/aperant-section.mjs'
60
60
  import { atomicWriteJson } from '../util/atomic-write.mjs'
61
61
  import { aperantBanner } from '../util/banner.mjs'
62
- import { copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
62
+ import { checkInstructionFile, copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
63
63
  import { withFileLock } from '../util/fs-lock.mjs'
64
64
  import { emitInitConfigHint } from '../util/init-hints.mjs'
65
65
  import { err, exitWith, ok, rawText } from '../util/result.mjs'
@@ -1459,6 +1459,69 @@ async function renderOutro(answers, results, version, kernelResult) {
1459
1459
  clack.outro(`Installed ${fileCount} files · v${version} · ${xverifyCopy}`)
1460
1460
  }
1461
1461
 
1462
+ /**
1463
+ * Resolve the deduplicated set of root instruction-file paths the selected
1464
+ * runtimes read. Each chosen runtime contributes its `nativeInstructionFile`
1465
+ * (claude→CLAUDE.md, codex/opencode→AGENTS.md, gemini→GEMINI.md); runtimes
1466
+ * mapped to `null` install to their own surface and contribute nothing.
1467
+ * Filenames are deduplicated (codex + opencode both map to AGENTS.md → one
1468
+ * target).
1469
+ *
1470
+ * Resolution rules:
1471
+ * - Multiple distinct targets (e.g. `--claude --codex`): write all of them
1472
+ * verbatim — that is the whole point of the fix (AC1, AC2, AC5).
1473
+ * - Exactly one resolved target that does NOT exist AND it is CLAUDE.md, but
1474
+ * AGENTS.md already exists (the `--claude` with a pre-seeded AGENTS.md and
1475
+ * no CLAUDE.md case, Risk 4 / AC9): redirect to the existing AGENTS.md
1476
+ * rather than creating an orphan CLAUDE.md. This is safe ONLY for the
1477
+ * claude runtime because native Claude Code reads AGENTS.md as well as
1478
+ * CLAUDE.md. The redirect is NOT applied for gemini (reads only GEMINI.md)
1479
+ * or codex/opencode (read only AGENTS.md) — redirecting those to an
1480
+ * unrelated existing file would reintroduce the very visibility gap this
1481
+ * fix closes, so their absent target is simply created.
1482
+ * - Zero resolved targets (no selected runtime maps to an instruction file):
1483
+ * fall back to the legacy heuristic — CLAUDE.md if present, else AGENTS.md
1484
+ * if present, else a new CLAUDE.md.
1485
+ *
1486
+ * @param {string[]} chosenIds
1487
+ * @param {string} targetDir
1488
+ * @returns {string[]} absolute instruction-file paths, deduplicated
1489
+ */
1490
+ function resolveInstructionTargets(chosenIds, targetDir) {
1491
+ const filenames = []
1492
+ for (const id of chosenIds) {
1493
+ const runtime = getRuntime(id)
1494
+ const file = runtime?.nativeInstructionFile
1495
+ if (file && !filenames.includes(file)) filenames.push(file)
1496
+ }
1497
+ const claudeMdPath = join(targetDir, 'CLAUDE.md')
1498
+ const agentsMdPath = join(targetDir, 'AGENTS.md')
1499
+ const resolved = filenames.map((name) => join(targetDir, name))
1500
+
1501
+ if (resolved.length === 0) {
1502
+ // No selected runtime maps to an instruction file — legacy heuristic.
1503
+ if (existsSync(claudeMdPath)) return [claudeMdPath]
1504
+ if (existsSync(agentsMdPath)) return [agentsMdPath]
1505
+ return [claudeMdPath]
1506
+ }
1507
+
1508
+ if (resolved.length === 1 && !existsSync(resolved[0])) {
1509
+ // Single registry target that doesn't exist yet. The ONLY safe redirect
1510
+ // is claude→AGENTS.md (Risk 4 / AC9): native Claude Code reads AGENTS.md
1511
+ // too, so when CLAUDE.md is absent but AGENTS.md exists (e.g. a prior
1512
+ // codex install), writing AGENTS.md still serves claude and avoids an
1513
+ // orphan CLAUDE.md. gemini/codex/opencode read only their own native
1514
+ // file — redirecting them to an unrelated existing file would hide the
1515
+ // catalog from their CLI, so we just create their target.
1516
+ if (resolved[0] === claudeMdPath && existsSync(agentsMdPath)) return [agentsMdPath]
1517
+ return resolved
1518
+ }
1519
+
1520
+ // One or more registry targets, at least one exists OR multiple distinct
1521
+ // targets — write the registry-resolved set verbatim.
1522
+ return resolved
1523
+ }
1524
+
1462
1525
  /**
1463
1526
  * Synchronous tail of init — runs after the runtime-install promise
1464
1527
  * resolves. Performs all .aperant/ + Claude-hook + remote-warning work
@@ -1652,15 +1715,19 @@ function finishInit(
1652
1715
  copyDirRecursive(installedContextSrc, join(aperantDir, 'context', 'installed'))
1653
1716
  }
1654
1717
 
1655
- const claudeMdPath = join(targetDir, 'CLAUDE.md')
1656
- const agentsMdPath = join(targetDir, 'AGENTS.md')
1657
- let instructionFile
1658
- if (existsSync(claudeMdPath)) instructionFile = claudeMdPath
1659
- else if (existsSync(agentsMdPath)) instructionFile = agentsMdPath
1660
- else instructionFile = claudeMdPath
1718
+ // Resolve every distinct instruction file the selected runtimes read, then
1719
+ // preflight ALL of them before writing ANY — so a single foreign-row abort
1720
+ // leaves no file partially written (US-03 / AC3). Each chosen runtime maps
1721
+ // to its `nativeInstructionFile` (claude→CLAUDE.md, codex/opencode→AGENTS.md,
1722
+ // gemini→GEMINI.md); null-mapped runtimes install to their own surface and
1723
+ // contribute no target. When the selected runtimes resolve to zero targets,
1724
+ // or none of the resolved targets exist on disk, fall back to the legacy
1725
+ // "pick the existing file" heuristic so e.g. `--claude` with a pre-seeded
1726
+ // AGENTS.md (and no CLAUDE.md) still writes AGENTS.md (Risk 4 / AC9).
1727
+ const instructionTargets = resolveInstructionTargets(chosenIds, targetDir)
1661
1728
  // FRAMEWORK-BUG-023 — generate the section from discoverSkills before
1662
1729
  // handing it to the writer. The writer also runs a foreign-row defensive
1663
- // check; if rows present in the file aren't in the generated set we
1730
+ // check; if rows present in any file aren't in the generated set we
1664
1731
  // signal exit-2 via the __earlyExit sentinel so cmdInit can route the
1665
1732
  // envelope through exitWith() without writing a malformed install.
1666
1733
  const aptSection = buildAptSection({
@@ -1668,11 +1735,59 @@ function finishInit(
1668
1735
  targetDir,
1669
1736
  cli: detectAgentRuntime(),
1670
1737
  })
1671
- const injectionOutcome = injectInstructionFile(instructionFile, {
1672
- aptSection,
1673
- force: forceOverwriteCatalog,
1738
+ // Preflight pass check every target with the write-free helper; collect
1739
+ // all aborting targets so the error lists every offender, not just the first.
1740
+ const abortingTargets = []
1741
+ for (const filePath of instructionTargets) {
1742
+ const check = checkInstructionFile(filePath, { aptSection, force: forceOverwriteCatalog })
1743
+ if (check.status === 'aborted-foreign-rows') {
1744
+ abortingTargets.push({ path: basename(filePath), foreignRowIds: check.foreignRowIds })
1745
+ }
1746
+ }
1747
+ if (abortingTargets.length > 0) {
1748
+ const allForeignRowIds = [...new Set(abortingTargets.flatMap((t) => t.foreignRowIds))]
1749
+ const fileList = abortingTargets.map((t) => t.path).join(', ')
1750
+ return {
1751
+ __earlyExit: exitWith(
1752
+ {
1753
+ ok: false,
1754
+ status: 'error',
1755
+ command: 'init',
1756
+ code: 'instruction-file.foreign-rows',
1757
+ message:
1758
+ `${fileList} contains skill rows not present in the installed framework: ` +
1759
+ `${allForeignRowIds.join(', ')}. ` +
1760
+ 'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
1761
+ foreignRowIds: allForeignRowIds,
1762
+ instructionFile: { path: abortingTargets[0].path, status: 'aborted-foreign-rows' },
1763
+ instructionFiles: abortingTargets.map((t) => ({
1764
+ path: t.path,
1765
+ status: 'aborted-foreign-rows',
1766
+ })),
1767
+ },
1768
+ 2,
1769
+ ),
1770
+ }
1771
+ }
1772
+ // Write pass — no target aborted in the preflight, so write all.
1773
+ const instructionFiles = instructionTargets.map((filePath) => {
1774
+ const outcome = injectInstructionFile(filePath, {
1775
+ aptSection,
1776
+ force: forceOverwriteCatalog,
1777
+ })
1778
+ return {
1779
+ path: basename(filePath),
1780
+ status: outcome.status,
1781
+ foreignRowIds: outcome.foreignRowIds,
1782
+ }
1674
1783
  })
1675
- if (injectionOutcome.status === 'aborted-foreign-rows') {
1784
+ // TOCTOU guard: if a file gained foreign rows BETWEEN the preflight check and
1785
+ // the write (e.g. another process wrote to it), surface the same error envelope
1786
+ // the preflight uses rather than silently accepting partial writes.
1787
+ const writtenAborts = instructionFiles.filter((f) => f.status === 'aborted-foreign-rows')
1788
+ if (writtenAborts.length > 0) {
1789
+ const allForeignRowIds = [...new Set(writtenAborts.flatMap((f) => f.foreignRowIds ?? []))]
1790
+ const fileList = writtenAborts.map((f) => f.path).join(', ')
1676
1791
  return {
1677
1792
  __earlyExit: exitWith(
1678
1793
  {
@@ -1681,17 +1796,22 @@ function finishInit(
1681
1796
  command: 'init',
1682
1797
  code: 'instruction-file.foreign-rows',
1683
1798
  message:
1684
- `${basename(instructionFile)} contains skill rows not present in the installed framework: ` +
1685
- `${injectionOutcome.foreignRowIds.join(', ')}. ` +
1799
+ `${fileList} contains skill rows not present in the installed framework: ` +
1800
+ `${allForeignRowIds.join(', ')}. ` +
1686
1801
  'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
1687
- foreignRowIds: injectionOutcome.foreignRowIds,
1688
- instructionFile: basename(instructionFile),
1802
+ foreignRowIds: allForeignRowIds,
1803
+ instructionFile: { path: writtenAborts[0].path, status: 'aborted-foreign-rows' },
1804
+ instructionFiles: writtenAborts.map((f) => ({
1805
+ path: f.path,
1806
+ status: 'aborted-foreign-rows',
1807
+ })),
1689
1808
  },
1690
1809
  2,
1691
1810
  ),
1692
1811
  }
1693
1812
  }
1694
- const injectionResult = injectionOutcome.status
1813
+ // Primary entry drives the backwards-compat singular alias + structure key.
1814
+ const primaryInstructionFile = instructionFiles[0]
1695
1815
 
1696
1816
  const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
1697
1817
  const precompactResult = installedClaude
@@ -1730,9 +1850,11 @@ function finishInit(
1730
1850
  '.aperant/events/': 'event log (git-ignored)',
1731
1851
  '.aperant/prompts/': 'agent prompt templates',
1732
1852
  '.aperant/install/last-install.json': 'cross-runtime install summary',
1733
- [basename(instructionFile)]: `APT section ${injectionResult}`,
1734
1853
  '.claude/settings.local.json': `PreCompact hook ${precompactResult}; SessionStart hook ${sessionStartResult}`,
1735
1854
  }
1855
+ for (const f of instructionFiles) {
1856
+ structure[f.path] = `APT section ${f.status}`
1857
+ }
1736
1858
  if (kernelResult) {
1737
1859
  structure['.aperant/deps/'] =
1738
1860
  kernelResult.status === 'installed'
@@ -1772,7 +1894,10 @@ function finishInit(
1772
1894
  framework_version: version,
1773
1895
  configCreated,
1774
1896
  configMigrated,
1775
- instructionFile: { path: basename(instructionFile), status: injectionResult },
1897
+ // Plural contract (ID-03): one entry per instruction file written.
1898
+ // `instructionFile` (singular) is a backwards-compat alias = first entry.
1899
+ instructionFiles,
1900
+ instructionFile: primaryInstructionFile,
1776
1901
  stateCreated,
1777
1902
  seedsCreated,
1778
1903
  publicRemoteWarning,
@@ -274,6 +274,32 @@ export function cmdToolchainDetect(projectDir, extraArgs = []) {
274
274
  }
275
275
  const scripts = packageJson?.scripts ?? {}
276
276
 
277
+ // FRAMEWORK-BUG-006 (Part B) — turbo cache-bypass detection.
278
+ // A turbo-backed verify command can replay a STALE CACHE HIT and report
279
+ // PASS while a broken import sits on disk (sighted on PR #244). When the
280
+ // project is turbo-backed we additionally emit a `no_cache_command`
281
+ // variant that appends turbo's `--force` flag so pr-review (and any
282
+ // consumer) can run a cache-bypassing verification. This is ADDITIVE —
283
+ // the existing `command` strings are never altered (ID-03).
284
+ const hasTurboConfig = existsSync(join(scopePath, 'turbo.json'))
285
+ // A package script "resolves through turbo" when its script body invokes
286
+ // the turbo CLI (e.g. "turbo run typecheck"). package.json scripts are
287
+ // hand-authored and tiny; a substring match on the `turbo` token is
288
+ // sufficient and avoids parsing shell.
289
+ function scriptIsTurboBacked(scriptName) {
290
+ const body = scripts[scriptName]
291
+ return typeof body === 'string' && /\bturbo\b/.test(body)
292
+ }
293
+ // Append --force to a turbo-backed package script so it reaches the
294
+ // underlying `turbo run …`. Only used when the command is known to be
295
+ // turbo-backed, so --force is always meaningful here. npm does NOT
296
+ // forward trailing args to the script unless they follow a `--`
297
+ // separator (`npm run typecheck --force` swallows --force as an npm flag),
298
+ // so insert `--` for npm. pnpm/yarn/bun forward trailing args directly.
299
+ function withTurboForce(command) {
300
+ return pm === 'npm' ? `${command} -- --force` : `${command} --force`
301
+ }
302
+
277
303
  // Read pyproject.toml as string if present
278
304
  let pyproject = null
279
305
  const pyprojectPath = join(scopePath, 'pyproject.toml')
@@ -331,6 +357,19 @@ export function cmdToolchainDetect(projectDir, extraArgs = []) {
331
357
  } else if (pyproject && pyproject.includes('[tool.mypy]')) {
332
358
  typecheck = { tool: 'mypy', command: 'mypy .' }
333
359
  }
360
+ // Additive turbo cache-bypass variant (FRAMEWORK-BUG-006). When the
361
+ // project is turbo-backed AND has a turbo-backed typecheck script, the
362
+ // plain command (often `tsc --noEmit` when tsconfig.json wins above) can
363
+ // be masked by a replayed turbo cache PASS. The no_cache_command routes
364
+ // through the turbo-backed script with `--force` so pr-review's post-fix
365
+ // typecheck cannot be fooled by a stale cache.
366
+ if (typecheck && hasTurboConfig) {
367
+ if (scriptIsTurboBacked('typecheck')) {
368
+ typecheck.no_cache_command = withTurboForce(`${pmRun} typecheck`)
369
+ } else if (scriptIsTurboBacked('type-check')) {
370
+ typecheck.no_cache_command = withTurboForce(`${pmRun} type-check`)
371
+ }
372
+ }
334
373
 
335
374
  // --- Test ---
336
375
  let test = null
@@ -364,6 +403,11 @@ export function cmdToolchainDetect(projectDir, extraArgs = []) {
364
403
  if (!test && scripts.test) {
365
404
  test = { tool: 'package-script', command: `${pmRun} test` }
366
405
  }
406
+ // Additive turbo cache-bypass variant for tests (FRAMEWORK-BUG-006), same
407
+ // rationale as typecheck above — only when turbo backs the test script.
408
+ if (test && hasTurboConfig && scriptIsTurboBacked('test')) {
409
+ test.no_cache_command = withTurboForce(`${pmRun} test`)
410
+ }
367
411
 
368
412
  // --- Build ---
369
413
  let build = null