@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,107 @@
1
+ /**
2
+ * route/drift-detect.mjs — FRAMEWORK-BUG-017 (rung 1 of 3).
3
+ *
4
+ * In-band detection of the "installed kernel drifts behind source
5
+ * workspace" failure mode. When an Aperant contributor self-hosts the
6
+ * framework out of a monorepo checkout, the kernel at
7
+ * `.aperant/deps/node_modules/@aperant/framework/` lags behind the
8
+ * source at `packages/framework/` after every same-PR framework fix.
9
+ * The SessionStart worker only knows about npm — it cannot tell that
10
+ * the workspace is ahead of the install.
11
+ *
12
+ * This module walks up from `targetDir`, looking for a sibling
13
+ * `packages/framework/package.json`. When found AND its `version`
14
+ * differs from the installed kernel's `version`, it returns a synthetic
15
+ * `update_check`-shaped object so `route.mjs` can surface the chip
16
+ * immediately — without waiting for the SessionStart hook to fire.
17
+ *
18
+ * Safe for the route hot path: synchronous, file-existence checks
19
+ * only, bounded walk (6 segments max), no network, no spawn.
20
+ *
21
+ * Security (Codex review edit 2): the workspace `package.json:version`
22
+ * is validated against the same semver-shape regex `update-chips.mjs`
23
+ * uses on the cache. A poisoned workspace `package.json` cannot
24
+ * propagate a non-semver `latest_version` into the synthesized
25
+ * `update_check` block.
26
+ */
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { compareVersions, SEMVER_RE } from '../util/semver.mjs'
31
+
32
+ /** Max directory frames to walk before giving up. */
33
+ const MAX_WALK_DEPTH = 6
34
+
35
+ /**
36
+ * Walk up from `start`, returning every directory frame including
37
+ * `start` itself. Stops at the filesystem root or at a depth of
38
+ * `MAX_WALK_DEPTH` (whichever comes first). The hard cap keeps this
39
+ * cheap on the route hot path and contains accidental scans on deeply
40
+ * nested input paths.
41
+ *
42
+ * @param {string} start
43
+ * @returns {string[]}
44
+ */
45
+ function walkUp(start) {
46
+ const frames = []
47
+ let cur = start
48
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
49
+ frames.push(cur)
50
+ const parent = dirname(cur)
51
+ if (parent === cur) break
52
+ cur = parent
53
+ }
54
+ return frames
55
+ }
56
+
57
+ /**
58
+ * Detect whether the workspace source for `@aperant/framework` is ahead
59
+ * of the installed kernel.
60
+ *
61
+ * @param {string} targetDir Absolute path the route was invoked against.
62
+ * @param {string|null} installedVersion The installed kernel's version (semver string), or null when unknown.
63
+ * @returns {null | {
64
+ * source_path: string,
65
+ * source_version: string,
66
+ * installed_version: string,
67
+ * drift: true,
68
+ * }}
69
+ */
70
+ export function detectSourceWorkspaceDrift(targetDir, installedVersion) {
71
+ if (!targetDir || typeof targetDir !== 'string') return null
72
+ if (!installedVersion || typeof installedVersion !== 'string') return null
73
+ if (!SEMVER_RE.test(installedVersion)) return null
74
+
75
+ for (const dir of walkUp(targetDir)) {
76
+ const candidate = join(dir, 'packages', 'framework', 'package.json')
77
+ if (!existsSync(candidate)) continue
78
+
79
+ let pkg
80
+ try {
81
+ pkg = JSON.parse(readFileSync(candidate, 'utf-8'))
82
+ } catch {
83
+ return null
84
+ }
85
+
86
+ // Validate the workspace package is actually @aperant/framework —
87
+ // guard against false matches on unrelated monorepos that happen
88
+ // to have a `packages/framework/` directory.
89
+ if (pkg?.name !== '@aperant/framework') continue
90
+
91
+ const sourceVersion = pkg?.version
92
+ if (typeof sourceVersion !== 'string' || !SEMVER_RE.test(sourceVersion)) return null
93
+ // Only report drift when workspace source is strictly ahead of the installed
94
+ // kernel. Equal (already up-to-date) and behind (installed is newer) both
95
+ // return null — only source-ahead is a drift worth surfacing.
96
+ if (compareVersions(sourceVersion, installedVersion) <= 0) return null
97
+
98
+ return {
99
+ source_path: candidate,
100
+ source_version: sourceVersion,
101
+ installed_version: installedVersion,
102
+ drift: true,
103
+ }
104
+ }
105
+
106
+ return null
107
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * util/aperant-section.mjs — generator for the CLAUDE.md / AGENTS.md
3
+ * "## Aperant Framework" section (FRAMEWORK-BUG-023).
4
+ *
5
+ * Replaces the previously-hardcoded `APT_SECTION` constant in copy.mjs.
6
+ * The skill TABLE is rebuilt from `discoverSkills` (filesystem source of
7
+ * truth — same helper the router uses at runtime) so install and runtime
8
+ * can never disagree about the catalog.
9
+ *
10
+ * Curated-doc risk: the appendix template
11
+ * (`templates/aperant-claude-md-appendix.md`) still contains hand-written
12
+ * sub-sections — "Fast Path Guarantee", "Discoverability —
13
+ * `apt-tools modes list`", "Feature Registry", "Config". These are small
14
+ * (~30 lines), rarely change, and rot would be visible (sections about
15
+ * non-existent features). Acceptable tradeoff per plan §Approach (c).
16
+ */
17
+
18
+ import { readFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import { getTrustedSkillSources } from '../config/load.mjs'
21
+ import { discoverSkills } from '../route/skill-discover.mjs'
22
+
23
+ const SECTION_START = '<!-- APT:framework-start -->'
24
+ const SECTION_END = '<!-- APT:framework-end -->'
25
+ const SECTION_HEADING = '## Aperant Framework'
26
+ const APPENDIX_REL_PATH = ['templates', 'aperant-claude-md-appendix.md']
27
+
28
+ /**
29
+ * Escape a SKILL `description` for safe inclusion as a markdown table cell.
30
+ * The only character we have to defend against is the pipe (`|`) — it
31
+ * terminates the table column. `apt:triage` ships with a literal `|` in
32
+ * its description, so this is a live case, not a theoretical one.
33
+ *
34
+ * @param {string} text
35
+ * @returns {string}
36
+ */
37
+ function escapePipes(text) {
38
+ return text.replace(/\|/g, '\\|')
39
+ }
40
+
41
+ /**
42
+ * Deterministic sort: the bare `apt` router skill first, then ASCII-sorted
43
+ * by `name`. The router goes first so the generated table mirrors the user's
44
+ * mental model — the entry point, then everything reachable from it.
45
+ *
46
+ * @param {{ name: string }} a
47
+ * @param {{ name: string }} b
48
+ * @returns {number}
49
+ */
50
+ function sortSkills(a, b) {
51
+ if (a.name === 'apt' && b.name !== 'apt') return -1
52
+ if (b.name === 'apt' && a.name !== 'apt') return 1
53
+ return a.name.localeCompare(b.name)
54
+ }
55
+
56
+ /**
57
+ * Build the full "## Aperant Framework" section, wrapped in the
58
+ * `<!-- APT:framework-* -->` markers.
59
+ *
60
+ * @param {Object} opts
61
+ * @param {string} opts.canonicalRoot Absolute path to the framework root
62
+ * (the directory that contains
63
+ * `templates/aperant-claude-md-appendix.md`).
64
+ * @param {string} opts.targetDir Project root (passed to discoverSkills).
65
+ * @param {string} [opts.cli] Host CLI label (forwarded to
66
+ * getTrustedSkillSources so the install
67
+ * root for the runtime is included).
68
+ * @returns {string} The full section, including markers
69
+ * and trailing newline before the
70
+ * end-marker. No leading newline.
71
+ */
72
+ export function buildAptSection({ canonicalRoot, targetDir, cli }) {
73
+ const trustedSkillSources = getTrustedSkillSources(targetDir, cli)
74
+ const discovered = discoverSkills({ targetDir, trustedSkillSources })
75
+ const skills = discovered.skills
76
+ .filter((s) => s.user_invocable === true && s.internal === false)
77
+ .slice()
78
+ .sort(sortSkills)
79
+
80
+ const rows = skills.map((s) => `| \`/${s.name}\` | ${escapePipes(s.description)} |`)
81
+
82
+ const tableBlock = [
83
+ '### Available Skills',
84
+ '| Skill | Description |',
85
+ '|-------|-------------|',
86
+ ...rows,
87
+ ].join('\n')
88
+
89
+ const appendixPath = join(canonicalRoot, ...APPENDIX_REL_PATH)
90
+ let appendix
91
+ try {
92
+ appendix = readFileSync(appendixPath, 'utf-8').replace(/\n+$/, '')
93
+ } catch (cause) {
94
+ const err = new Error(
95
+ `aperant-section: template not found at ${appendixPath} — re-install the framework package`,
96
+ { cause },
97
+ )
98
+ err.code = 'aperant-section.template-missing'
99
+ throw err
100
+ }
101
+
102
+ const body = [SECTION_HEADING, '', appendix, '', tableBlock].join('\n')
103
+
104
+ return `${SECTION_START}\n${body}\n${SECTION_END}`
105
+ }
106
+
107
+ /**
108
+ * Extract the `/apt:<slug>` (or bare `/apt`) row IDs from the
109
+ * `<!-- APT:framework-* -->` section of an existing instruction file.
110
+ * Returns `[]` when the file has no APT section.
111
+ *
112
+ * Used by `injectInstructionFile` to detect foreign rows that would be
113
+ * silently clobbered by a regeneration — see `--force-overwrite-catalog`.
114
+ *
115
+ * @param {string} text Full instruction-file contents.
116
+ * @returns {string[]} Row IDs in file order, e.g. `['/apt', '/apt:quick', …]`.
117
+ */
118
+ export function extractAptSectionRowIds(text) {
119
+ const match = text.match(/<!-- APT:framework-start -->([\s\S]*?)<!-- APT:framework-end -->/)
120
+ if (!match) return []
121
+ // Strip HTML comments (including multiline) before scanning for row IDs so
122
+ // that commented-out example rows don't trigger false-positive foreign-row exits.
123
+ const section = match[1].replace(/<!--[\s\S]*?-->/g, '')
124
+ const ids = []
125
+ // Match table rows whose first cell is `/apt` or `/apt:<slug>` wrapped in
126
+ // backticks. The leading `|` plus optional whitespace anchors us to a
127
+ // markdown table cell; the trailing backtick + ` |` anchors the column
128
+ // boundary so we don't match prose mentions like "the `/apt` router".
129
+ const rowRe = /^\|\s*`(\/apt(?::[a-z0-9-]+)?)`\s*\|/gm
130
+ let m = rowRe.exec(section)
131
+ while (m !== null) {
132
+ ids.push(m[1])
133
+ m = rowRe.exec(section)
134
+ }
135
+ return ids
136
+ }
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * copy.mjs — filesystem copy + instruction-file injection helpers (C29).
3
3
  *
4
- * `copyDirRecursive` is a pure leaf; `injectInstructionFile` owns the
5
- * CLAUDE.md / AGENTS.md "<!-- APT:framework-* -->" section template.
4
+ * `copyDirRecursive` is a pure leaf. `injectInstructionFile` owns the
5
+ * CLAUDE.md / AGENTS.md "<!-- APT:framework-* -->" section write path.
6
+ *
7
+ * The APT section TEXT is no longer assembled here — it is generated by
8
+ * `util/aperant-section.mjs#buildAptSection` and passed in (FRAMEWORK-BUG-023).
9
+ * Callers compute the section once (from the filesystem skill catalog) and
10
+ * hand it to this helper. The defensive foreign-row check stays in this
11
+ * file so the read/compare/write happens atomically against `filePath`.
6
12
  */
7
13
 
8
14
  import {
@@ -14,6 +20,7 @@ import {
14
20
  writeFileSync,
15
21
  } from 'node:fs'
16
22
  import { join } from 'node:path'
23
+ import { extractAptSectionRowIds } from './aperant-section.mjs'
17
24
 
18
25
  export function copyDirRecursive(src, dest) {
19
26
  mkdirSync(dest, { recursive: true })
@@ -28,68 +35,48 @@ export function copyDirRecursive(src, dest) {
28
35
  }
29
36
  }
30
37
 
31
- const APT_SECTION = `<!-- APT:framework-start -->
32
- ## Aperant Framework
33
-
34
- This project uses the [Aperant Framework](https://github.com/Mikalsen-AI/aperant-cloud-desktop/tree/main/packages/framework) for AI-assisted development.
35
-
36
- ### Available Skills
37
- | Skill | Description |
38
- |-------|-------------|
39
- | \`/apt\` | Smart router — auto-detects context, routes to the right skill |
40
- | \`/apt:quick\` | Fast task execution skip planning, go straight to implementation |
41
- | \`/apt:discuss\` | Interactive decision-making assumptions, gray areas, power mode |
42
- | \`/apt:debug\` | Systematic debugging with persistent state across context resets |
43
- | \`/apt:scan\` | Scan codebase to build/refresh the feature registry (recommended after setup) |
44
- | \`/apt:plan\` | Analyze a task, investigate codebase, produce spec + implementation plan |
45
- | \`/apt:execute\` | Implement subtasks from a plan with atomic commits |
46
- | \`/apt:verify\` | Score implementation on 4 dimensions (correctness, completeness, quality, coverage) |
47
- | \`/apt:verify-proof\` | Visual proof verification — screenshots, video, HTML report with feature checklists |
48
- | \`/apt:review\` | 6-pass inline code review (logic / security / quality / architecture / performance / docs-coherence) with auto-fix pipeline |
49
- | \`/apt:pr-review\` | Multi-agent PR review 6 parallel specialists, validator, fixer, self-reviewer (owned / external authorship) |
50
- | \`/apt:run\` | Full pipeline: plan → execute → verify → review |
51
- | \`/apt:roundtable\` | 10-agent structured debate for complex decisions |
52
- | \`/apt:stress-test\` | Scenario simulation to catch gaps in plans |
53
- | \`/apt:ship\` | Create PR with QA scores and decision traceability |
54
- | \`/apt:pause\` | Human-initiated session handoff — save state when YOU need to stop (not for agent-initiated bailouts) |
55
- | \`/apt:resume\` | Restore context from a paused session and route to next action |
56
- | \`/apt:docs\` | Generate/update documentation verified against the actual codebase |
57
- | \`/apt:setup\` | Change framework preferences (verification style, multi-model, branching) |
58
-
59
- ### Feature Registry
60
- The feature registry (\`.aperant/features/\`) is a living inventory of every interactive
61
- feature in the app. It's the source of truth for what to verify and review.
62
-
63
- - **Created by:** \`/apt:scan\` (initial scan or full refresh)
64
- - **Updated by:** \`/apt:execute\` post-step (\`apt-tools features-audit --apply-stubs\` auto-registers new UI/core/hook/IPC files per the docs-coherence policy)
65
- - **Consumed by:** \`/apt:verify-proof\` (generates test cases from registry)
66
- - **Checked by:** \`/apt:review\` Pass 6 (flags unregistered new components, applies stubs for auto-update policies)
67
-
68
- Run \`/apt:scan\` after setting up Aperant on a brownfield project, or after major changes.
69
-
70
- ### Config
71
- - Settings: \`.aperant/config.json\` (run \`/apt:setup\` to change)
72
- - Features: \`.aperant/features/\` (feature registry -- run \`/apt:scan\` to create)
73
- - State: \`.aperant/state.json\` (execution state)
74
- - Evidence: \`.aperant/proof/\` (proof reports and screenshots)
75
- - Prompts: \`.aperant/prompts/\` (agent prompt templates)
76
- <!-- APT:framework-end -->`
77
-
78
- export function injectInstructionFile(filePath) {
38
+ /**
39
+ * Write the (already-generated) APT section into `filePath`. Creates the
40
+ * file if missing; replaces the section in-place if the markers are
41
+ * present.
42
+ *
43
+ * FRAMEWORK-BUG-023 — before overwriting an existing section, extract the
44
+ * row IDs in the on-disk file and compare to the row IDs in the freshly
45
+ * generated section. If the file contains rows that are NOT in the
46
+ * generated set ("foreign rows"), the regen would silently clobber them.
47
+ * In that case we abort and let the caller surface a structured error.
48
+ * The `--force-overwrite-catalog` escape hatch flips `force: true`.
49
+ *
50
+ * @param {string} filePath
51
+ * @param {Object} opts
52
+ * @param {string} opts.aptSection Output of buildAptSection() wrapped
53
+ * in `<!-- APT:framework-* -->` markers.
54
+ * @param {boolean} [opts.force=false]
55
+ * Bypass the foreign-row check.
56
+ * @returns {{ status: 'created' | 'updated' | 'aborted-foreign-rows', written: boolean, foreignRowIds?: string[] }}
57
+ */
58
+ export function injectInstructionFile(filePath, { aptSection, force = false }) {
79
59
  const existing = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : ''
80
60
 
81
61
  if (existing.includes('<!-- APT:framework-start -->')) {
82
- // Update existing section in-place
62
+ if (!force) {
63
+ const existingIds = new Set(extractAptSectionRowIds(existing))
64
+ const generatedIds = new Set(extractAptSectionRowIds(aptSection))
65
+ const foreignRowIds = [...existingIds].filter((id) => !generatedIds.has(id))
66
+ if (foreignRowIds.length > 0) {
67
+ return { status: 'aborted-foreign-rows', written: false, foreignRowIds }
68
+ }
69
+ }
83
70
  const updated = existing.replace(
84
71
  /<!-- APT:framework-start -->[\s\S]*?<!-- APT:framework-end -->/,
85
- APT_SECTION,
72
+ aptSection,
86
73
  )
87
74
  writeFileSync(filePath, updated, 'utf-8')
88
- return 'updated'
75
+ return { status: 'updated', written: true }
89
76
  }
90
77
 
91
- // Append
78
+ // Append (greenfield create — no foreign-row concern).
92
79
  const separator = existing.length > 0 ? '\n\n' : ''
93
- writeFileSync(filePath, existing + separator + APT_SECTION + '\n', 'utf-8')
94
- return 'created'
80
+ writeFileSync(filePath, existing + separator + aptSection + '\n', 'utf-8')
81
+ return { status: 'created', written: true }
95
82
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * util/semver.mjs — shared semver helpers used by route/drift-detect.mjs
3
+ * and util/version-preflight.mjs. Extracted to avoid duplication (QUA-002).
4
+ */
5
+
6
+ /** Semver-shape pattern — mirrors install/update-chips.mjs:64. */
7
+ export const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
8
+
9
+ /**
10
+ * Compare two semver-shape strings. Returns -1 / 0 / 1. Ignores
11
+ * pre-release + build metadata (sufficient for "min version" checks
12
+ * where we only care about the MAJOR.MINOR.PATCH triple).
13
+ *
14
+ * @param {string} a
15
+ * @param {string} b
16
+ * @returns {-1 | 0 | 1}
17
+ */
18
+ export function compareVersions(a, b) {
19
+ const pa = a.split(/[-+]/, 1)[0].split('.').map(Number)
20
+ const pb = b.split(/[-+]/, 1)[0].split('.').map(Number)
21
+ for (let i = 0; i < 3; i++) {
22
+ const av = pa[i] ?? 0
23
+ const bv = pb[i] ?? 0
24
+ if (av < bv) return -1
25
+ if (av > bv) return 1
26
+ }
27
+ return 0
28
+ }
@@ -1,37 +1,150 @@
1
1
  /**
2
- * skill-installs.mjs — shared skill install surfaces per host.
2
+ * skill-installs.mjs — shared skill install surfaces per runtime (BUG-022).
3
+ *
4
+ * Source of truth for "what does the framework have installed on this
5
+ * project, per runtime, and is it current?" — the post-022 implementation
6
+ * is manifest-aware:
7
+ *
8
+ * 1. Per surface, prefer `apt-file-manifest.json` (the v0.6.7+ runtime
9
+ * manifest). When present and valid, `framework_version` is the
10
+ * authoritative answer.
11
+ * 2. Manifest present but malformed → reported as `installed: true`,
12
+ * `stale: true`, `source: 'manifest-malformed'`. Don't silently
13
+ * report healthy.
14
+ * 3. Else fall back to `legacy_version_file` (the pre-manifest
15
+ * `.apt-version` stamp; only defined for codex via
16
+ * `LEGACY_INSTALL_PATHS` today). `source: 'legacy_version_file'`.
17
+ * 4. Else → not installed for this surface.
18
+ *
19
+ * The surface array is derived from `RUNTIMES` (single source of truth in
20
+ * `runtime-detect.mjs`) so adding a new runtime there auto-extends every
21
+ * downstream diagnostic. The legacy fallback path is only populated for
22
+ * runtimes that declare a legacy topology in `LEGACY_INSTALL_PATHS`.
23
+ *
24
+ * Backward-compat: `host` field stays `'claude-code'` for the claude
25
+ * runtime (the statusline chip + several other consumers key on this
26
+ * exact string). The new `runtime` field exposes the descriptor id
27
+ * (`'claude'`, `'codex'`, …) for callers that want the canonical name.
3
28
  */
4
29
 
5
- import { existsSync, readFileSync } from 'node:fs'
30
+ import { existsSync, readFileSync, statSync } from 'node:fs'
6
31
  import { join, resolve } from 'node:path'
32
+ import { LEGACY_INSTALL_PATHS } from '../install/legacy-paths.mjs'
33
+ import { MANIFEST_FILENAME, readManifest } from '../install/manifest.mjs'
34
+ import { RUNTIMES } from '../install/runtime-detect.mjs'
7
35
 
36
+ /**
37
+ * Map a runtime descriptor id to the `host` value historical consumers
38
+ * expect. The Claude runtime is the only one with a non-identity mapping
39
+ * (`'claude' → 'claude-code'`) — every other id is its own host string.
40
+ *
41
+ * @param {string} runtimeId
42
+ * @returns {string}
43
+ */
44
+ function hostFor(runtimeId) {
45
+ return runtimeId === 'claude' ? 'claude-code' : runtimeId
46
+ }
47
+
48
+ /**
49
+ * @typedef {Object} SkillInstallSurface
50
+ * @property {string} host Backward-compat host id.
51
+ * @property {string} runtime Canonical runtime id (from RUNTIMES).
52
+ * @property {string} install_root Absolute path of the runtime install root.
53
+ * @property {string} manifest_path Absolute path of apt-file-manifest.json.
54
+ * @property {string|null} legacy_version_file Absolute path of the legacy .apt-version stamp, or null.
55
+ */
56
+
57
+ /**
58
+ * Return one install-surface descriptor per runtime in `RUNTIMES`.
59
+ *
60
+ * @param {string} projectDir
61
+ * @returns {SkillInstallSurface[]}
62
+ */
8
63
  export function getSkillInstallSurfaces(projectDir) {
9
64
  const root = resolve(projectDir)
10
- return [
11
- {
12
- host: 'claude-code',
13
- skills_dir: join(root, '.claude', 'skills'),
14
- },
15
- {
16
- host: 'codex',
17
- skills_dir: join(root, '.agents', 'skills'),
18
- },
19
- ].map((surface) => ({
20
- ...surface,
21
- version_file: join(surface.skills_dir, '.apt-version'),
22
- }))
65
+ return RUNTIMES.map((descriptor) => {
66
+ const install_root = join(root, descriptor.installRoot)
67
+ const legacyTopology = LEGACY_INSTALL_PATHS[descriptor.id]
68
+ const legacy_version_file = legacyTopology ? join(root, legacyTopology.version_file) : null
69
+ return {
70
+ host: hostFor(descriptor.id),
71
+ runtime: descriptor.id,
72
+ install_root,
73
+ manifest_path: join(install_root, MANIFEST_FILENAME),
74
+ legacy_version_file,
75
+ }
76
+ })
23
77
  }
24
78
 
79
+ /**
80
+ * @typedef {Object} SkillInstallState
81
+ * @property {string} host
82
+ * @property {string} runtime
83
+ * @property {string} install_root
84
+ * @property {string} manifest_path
85
+ * @property {string|null} legacy_version_file
86
+ * @property {boolean} installed
87
+ * @property {string|null} installed_version
88
+ * @property {boolean} stale
89
+ * @property {'manifest' | 'manifest-malformed' | 'legacy_version_file' | null} source
90
+ */
91
+
92
+ /**
93
+ * Read each surface's install state. See the file header for the
94
+ * precedence rules.
95
+ *
96
+ * @param {string} projectDir
97
+ * @param {string} frameworkVersion
98
+ * @returns {SkillInstallState[]}
99
+ */
25
100
  export function readSkillInstallState(projectDir, frameworkVersion) {
26
101
  return getSkillInstallSurfaces(projectDir).map((surface) => {
27
- const installed_version = existsSync(surface.version_file)
28
- ? readFileSync(surface.version_file, 'utf-8').trim()
29
- : null
102
+ const manifestExists = existsSync(surface.manifest_path)
103
+ if (manifestExists) {
104
+ const manifest = readManifest(surface.install_root)
105
+ if (manifest && typeof manifest.framework_version === 'string') {
106
+ return {
107
+ ...surface,
108
+ installed: true,
109
+ installed_version: manifest.framework_version,
110
+ stale: manifest.framework_version !== frameworkVersion,
111
+ source: /** @type {const} */ ('manifest'),
112
+ }
113
+ }
114
+ // Manifest file present but parser rejected it (malformed JSON,
115
+ // missing required fields, …). Don't silently report healthy.
116
+ return {
117
+ ...surface,
118
+ installed: true,
119
+ installed_version: null,
120
+ stale: true,
121
+ source: /** @type {const} */ ('manifest-malformed'),
122
+ }
123
+ }
124
+
125
+ if (surface.legacy_version_file && existsSync(surface.legacy_version_file)) {
126
+ try {
127
+ if (statSync(surface.legacy_version_file).isFile()) {
128
+ const version = readFileSync(surface.legacy_version_file, 'utf-8').trim()
129
+ return {
130
+ ...surface,
131
+ installed: true,
132
+ installed_version: version,
133
+ stale: version !== frameworkVersion,
134
+ source: /** @type {const} */ ('legacy_version_file'),
135
+ }
136
+ }
137
+ } catch {
138
+ /* fall through to "not installed" */
139
+ }
140
+ }
141
+
30
142
  return {
31
143
  ...surface,
32
- installed: installed_version !== null,
33
- installed_version,
34
- stale: installed_version !== null && installed_version !== frameworkVersion,
144
+ installed: false,
145
+ installed_version: null,
146
+ stale: false,
147
+ source: null,
35
148
  }
36
149
  })
37
150
  }
@@ -0,0 +1,65 @@
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
+
18
+ import { compareVersions, SEMVER_RE } from './semver.mjs'
19
+
20
+ /**
21
+ * Preflight check: is the installed kernel new enough to support the
22
+ * named feature?
23
+ *
24
+ * - Returns `null` when the install is at-or-above `introducedIn` (pass).
25
+ * - Returns `null` when `installedVersion` is malformed (fail-open — a
26
+ * corrupted `package.json` shouldn't lock users out of every cmd; the
27
+ * route-level drift check still catches the same install via the
28
+ * version mismatch path).
29
+ * - Returns a `version_drift` error envelope when the install predates
30
+ * `introducedIn`. Callers `exitWith(envelope, 1)`.
31
+ *
32
+ * @param {Object} args
33
+ * @param {string} args.feature Short name of the feature being gated.
34
+ * @param {string} args.introducedIn Minimum framework version that ships the feature.
35
+ * @param {string} args.installedVersion The installed kernel's version.
36
+ * @param {string} args.missingFlag The CLI flag that requires this version (e.g. '--payload-file').
37
+ * @returns {null | {
38
+ * status: 'error',
39
+ * command: 'version-preflight',
40
+ * error_code: 'version_drift',
41
+ * feature: string,
42
+ * introduced_in: string,
43
+ * installed_version: string,
44
+ * missing_flag: string,
45
+ * reason: string,
46
+ * }}
47
+ */
48
+ export function requireMinVersion({ feature, introducedIn, installedVersion, missingFlag }) {
49
+ if (!installedVersion || typeof installedVersion !== 'string') return null
50
+ if (!SEMVER_RE.test(installedVersion)) return null
51
+ if (!SEMVER_RE.test(introducedIn)) return null
52
+
53
+ if (compareVersions(installedVersion, introducedIn) >= 0) return null
54
+
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
+ }