@aperant/framework 0.8.7 → 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 (52) hide show
  1. package/CHANGELOG.md +74 -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/runtime-detect.d.mts +26 -0
  15. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  16. package/dist/cli/install/runtime-detect.mjs +27 -0
  17. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  18. package/dist/cli/util/aperant-section.d.mts +12 -0
  19. package/dist/cli/util/aperant-section.d.mts.map +1 -1
  20. package/dist/cli/util/aperant-section.mjs +12 -0
  21. package/dist/cli/util/aperant-section.mjs.map +1 -1
  22. package/dist/cli/util/copy.d.mts +27 -0
  23. package/dist/cli/util/copy.d.mts.map +1 -1
  24. package/dist/cli/util/copy.mjs +49 -5
  25. package/dist/cli/util/copy.mjs.map +1 -1
  26. package/dist/cli/verify-proof/idl/index.d.mts +12 -1
  27. package/dist/cli/verify-proof/idl/index.d.mts.map +1 -1
  28. package/dist/cli/verify-proof/idl/index.mjs +50 -1
  29. package/dist/cli/verify-proof/idl/index.mjs.map +1 -1
  30. package/dist/cli/verify-proof/resolver.d.mts.map +1 -1
  31. package/dist/cli/verify-proof/resolver.mjs +51 -1
  32. package/dist/cli/verify-proof/resolver.mjs.map +1 -1
  33. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  34. package/dist/plugin/agents/apt-pr-review-fixer.md +9 -4
  35. package/dist/plugin/skills/apt-pr-review/SKILL.md +52 -15
  36. package/dist/plugin/skills/apt-setup/SKILL.md +7 -6
  37. package/dist/plugin/skills/apt-triage/SKILL.md +8 -6
  38. package/dist/plugin/skills/apt-update/SKILL.md +1 -1
  39. package/package.json +1 -1
  40. package/skills/apt-pr-review/SKILL.md +52 -15
  41. package/skills/apt-setup/SKILL.md +7 -6
  42. package/skills/apt-triage/SKILL.md +8 -6
  43. package/skills/apt-update/SKILL.md +1 -1
  44. package/src/cli/commands/init.mjs +144 -19
  45. package/src/cli/commands/toolchain-detect.mjs +44 -0
  46. package/src/cli/commands/triage.mjs +352 -11
  47. package/src/cli/commands/uninstall.mjs +11 -8
  48. package/src/cli/install/runtime-detect.mjs +27 -0
  49. package/src/cli/util/aperant-section.mjs +14 -0
  50. package/src/cli/util/copy.mjs +53 -8
  51. package/src/cli/verify-proof/idl/index.mjs +49 -11
  52. package/src/cli/verify-proof/resolver.mjs +49 -2
@@ -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