@aperant/framework 0.6.6 → 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 (150) hide show
  1. package/CHANGELOG.md +215 -0
  2. package/dist/cli/commands/check-version.d.mts.map +1 -1
  3. package/dist/cli/commands/check-version.mjs +76 -1
  4. package/dist/cli/commands/check-version.mjs.map +1 -1
  5. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  6. package/dist/cli/commands/ci-watch.mjs +73 -5
  7. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  8. package/dist/cli/commands/health-check.mjs +1 -1
  9. package/dist/cli/commands/health-check.mjs.map +1 -1
  10. package/dist/cli/commands/init.d.mts +17 -1
  11. package/dist/cli/commands/init.d.mts.map +1 -1
  12. package/dist/cli/commands/init.mjs +183 -11
  13. package/dist/cli/commands/init.mjs.map +1 -1
  14. package/dist/cli/commands/release-notes.d.mts +11 -0
  15. package/dist/cli/commands/release-notes.d.mts.map +1 -0
  16. package/dist/cli/commands/release-notes.mjs +173 -0
  17. package/dist/cli/commands/release-notes.mjs.map +1 -0
  18. package/dist/cli/commands/route.d.mts.map +1 -1
  19. package/dist/cli/commands/route.mjs +37 -2
  20. package/dist/cli/commands/route.mjs.map +1 -1
  21. package/dist/cli/commands/task.d.mts.map +1 -1
  22. package/dist/cli/commands/task.mjs +48 -0
  23. package/dist/cli/commands/task.mjs.map +1 -1
  24. package/dist/cli/config/load.d.mts +14 -0
  25. package/dist/cli/config/load.d.mts.map +1 -1
  26. package/dist/cli/config/load.mjs +40 -0
  27. package/dist/cli/config/load.mjs.map +1 -1
  28. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  29. package/dist/cli/config/upgrade-gitignore.mjs +6 -0
  30. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  31. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  32. package/dist/cli/consistency/parse-review.mjs +4 -1
  33. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  34. package/dist/cli/dispatch.d.mts.map +1 -1
  35. package/dist/cli/dispatch.mjs +2 -0
  36. package/dist/cli/dispatch.mjs.map +1 -1
  37. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  38. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  39. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  40. package/dist/cli/help.d.mts.map +1 -1
  41. package/dist/cli/help.mjs +1 -0
  42. package/dist/cli/help.mjs.map +1 -1
  43. package/dist/cli/host/detect.d.mts +122 -9
  44. package/dist/cli/host/detect.d.mts.map +1 -1
  45. package/dist/cli/host/detect.mjs +132 -0
  46. package/dist/cli/host/detect.mjs.map +1 -1
  47. package/dist/cli/install/legacy-paths.d.mts +38 -0
  48. package/dist/cli/install/legacy-paths.d.mts.map +1 -0
  49. package/dist/cli/install/legacy-paths.mjs +69 -0
  50. package/dist/cli/install/legacy-paths.mjs.map +1 -0
  51. package/dist/cli/install/runtime-migrate.d.mts +84 -0
  52. package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
  53. package/dist/cli/install/runtime-migrate.mjs +244 -0
  54. package/dist/cli/install/runtime-migrate.mjs.map +1 -0
  55. package/dist/cli/install/update-chips.d.mts +23 -0
  56. package/dist/cli/install/update-chips.d.mts.map +1 -1
  57. package/dist/cli/install/update-chips.mjs +60 -0
  58. package/dist/cli/install/update-chips.mjs.map +1 -1
  59. package/dist/cli/release-notes/compile.d.mts +38 -0
  60. package/dist/cli/release-notes/compile.d.mts.map +1 -0
  61. package/dist/cli/release-notes/compile.mjs +244 -0
  62. package/dist/cli/release-notes/compile.mjs.map +1 -0
  63. package/dist/cli/release-notes/draft.d.mts +73 -0
  64. package/dist/cli/release-notes/draft.d.mts.map +1 -0
  65. package/dist/cli/release-notes/draft.mjs +120 -0
  66. package/dist/cli/release-notes/draft.mjs.map +1 -0
  67. package/dist/cli/release-notes/output-dir.d.mts +20 -0
  68. package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
  69. package/dist/cli/release-notes/output-dir.mjs +42 -0
  70. package/dist/cli/release-notes/output-dir.mjs.map +1 -0
  71. package/dist/cli/release-notes/persona-filter.d.mts +51 -0
  72. package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
  73. package/dist/cli/release-notes/persona-filter.mjs +127 -0
  74. package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
  75. package/dist/cli/release-notes/publish.d.mts +23 -0
  76. package/dist/cli/release-notes/publish.d.mts.map +1 -0
  77. package/dist/cli/release-notes/publish.mjs +125 -0
  78. package/dist/cli/release-notes/publish.mjs.map +1 -0
  79. package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
  80. package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
  81. package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
  82. package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
  83. package/dist/cli/route/drift-detect.d.mts +20 -0
  84. package/dist/cli/route/drift-detect.d.mts.map +1 -0
  85. package/dist/cli/route/drift-detect.mjs +107 -0
  86. package/dist/cli/route/drift-detect.mjs.map +1 -0
  87. package/dist/cli/util/aperant-section.d.mts +34 -0
  88. package/dist/cli/util/aperant-section.d.mts.map +1 -0
  89. package/dist/cli/util/aperant-section.mjs +127 -0
  90. package/dist/cli/util/aperant-section.mjs.map +1 -0
  91. package/dist/cli/util/copy.d.mts +28 -1
  92. package/dist/cli/util/copy.d.mts.map +1 -1
  93. package/dist/cli/util/copy.mjs +43 -55
  94. package/dist/cli/util/copy.mjs.map +1 -1
  95. package/dist/cli/util/semver.d.mts +17 -0
  96. package/dist/cli/util/semver.d.mts.map +1 -0
  97. package/dist/cli/util/semver.mjs +29 -0
  98. package/dist/cli/util/semver.mjs.map +1 -0
  99. package/dist/cli/util/skill-installs.d.mts +65 -9
  100. package/dist/cli/util/skill-installs.d.mts.map +1 -1
  101. package/dist/cli/util/skill-installs.mjs +130 -21
  102. package/dist/cli/util/skill-installs.mjs.map +1 -1
  103. package/dist/cli/util/version-preflight.d.mts +44 -0
  104. package/dist/cli/util/version-preflight.d.mts.map +1 -0
  105. package/dist/cli/util/version-preflight.mjs +66 -0
  106. package/dist/cli/util/version-preflight.mjs.map +1 -0
  107. package/dist/types/config.d.ts +11 -7
  108. package/dist/types/config.d.ts.map +1 -1
  109. package/package.json +133 -133
  110. package/skills/apt-close-task/SKILL.md +30 -0
  111. package/skills/apt-diagram/SKILL.md +45 -9
  112. package/skills/apt-release-notes/SKILL.md +193 -0
  113. package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  114. package/skills/apt-setup/SKILL.md +146 -3
  115. package/skills/apt-ship/SKILL.md +63 -4
  116. package/skills/apt-spar/SKILL.md +36 -11
  117. package/skills/apt-update/SKILL.md +77 -10
  118. package/skills/apt-watch-ci/SKILL.md +6 -3
  119. package/src/cli/commands/check-version.mjs +73 -1
  120. package/src/cli/commands/ci-watch.mjs +74 -5
  121. package/src/cli/commands/health-check.mjs +1 -1
  122. package/src/cli/commands/init.mjs +202 -10
  123. package/src/cli/commands/release-notes.mjs +187 -0
  124. package/src/cli/commands/route.mjs +38 -2
  125. package/src/cli/commands/task.mjs +49 -0
  126. package/src/cli/config/load.mjs +37 -0
  127. package/src/cli/config/upgrade-gitignore.mjs +6 -0
  128. package/src/cli/consistency/parse-review.mjs +3 -1
  129. package/src/cli/dispatch.mjs +2 -0
  130. package/src/cli/gate/gates/review-clean.mjs +4 -2
  131. package/src/cli/help.mjs +1 -0
  132. package/src/cli/host/detect.mjs +135 -0
  133. package/src/cli/install/legacy-paths.mjs +69 -0
  134. package/src/cli/install/runtime-migrate.mjs +252 -0
  135. package/src/cli/install/update-chips.mjs +57 -0
  136. package/src/cli/release-notes/compile.mjs +261 -0
  137. package/src/cli/release-notes/draft.mjs +133 -0
  138. package/src/cli/release-notes/output-dir.mjs +52 -0
  139. package/src/cli/release-notes/persona-filter.mjs +126 -0
  140. package/src/cli/release-notes/publish.mjs +128 -0
  141. package/src/cli/release-notes/ship-autodraft.mjs +106 -0
  142. package/src/cli/route/drift-detect.mjs +107 -0
  143. package/src/cli/util/aperant-section.mjs +136 -0
  144. package/src/cli/util/copy.mjs +43 -56
  145. package/src/cli/util/semver.mjs +28 -0
  146. package/src/cli/util/skill-installs.mjs +134 -21
  147. package/src/cli/util/version-preflight.mjs +65 -0
  148. package/templates/aperant-claude-md-appendix.md +37 -0
  149. package/templates/config.json +28 -3
  150. package/workflows/docs.md +12 -0
@@ -16,7 +16,26 @@
16
16
  * stale_files: [{ path, reason }],
17
17
  * update_available: false
18
18
  * }],
19
- * legacy_install: false // true when only the old .apt-version exists
19
+ * legacy_install: false, // true when only the old .apt-version exists
20
+ * // === 0.6.7 cache-merge fields (Bug 1 fix) ===
21
+ * // Merged from the SessionStart worker's cache at
22
+ * // ~/.cache/aperant/apt-update-check.json when fresh (< 1 h,
23
+ * // schema_version=1). When stale, missing, or malformed, the same
24
+ * // field names are present with null/false/0 sentinel values so
25
+ * // the skill's parser stays single-shape.
26
+ * //
27
+ * // Note: framework.installed_version is intentionally absent.
28
+ * // The live kernel version is always in the top-level
29
+ * // `framework_version` field (read from package.json, never from
30
+ * // the cache).
31
+ * framework: {
32
+ * latest_version: '0.6.7' | null
33
+ * },
34
+ * update_available: false,
35
+ * dev_install_ahead: false,
36
+ * not_published: false,
37
+ * stale_files_total: 0,
38
+ * lookup_error: null
20
39
  * }
21
40
  *
22
41
  * Per-file `reason` values:
@@ -31,6 +50,7 @@ import { dirname, join, resolve } from 'node:path'
31
50
  import { fileURLToPath } from 'node:url'
32
51
  import { readManifest } from '../install/manifest.mjs'
33
52
  import { detectInstalledRuntimes } from '../install/runtime-detect.mjs'
53
+ import { readFreshUpdateCache } from '../install/update-chips.mjs'
34
54
  import { inspectManifestFiles } from '../install/version-header.mjs'
35
55
  import { err, ok } from '../util/result.mjs'
36
56
 
@@ -56,6 +76,10 @@ function inspectRuntime(targetDir, descriptor) {
56
76
  runtime: descriptor.id,
57
77
  installed_version: null,
58
78
  stale_files: [],
79
+ // 0.6.7 — per-runtime `update_available` is retained as `false` for
80
+ // backward shape compatibility with pre-0.6.7 callers. The real
81
+ // npm-bump signal now lives on the TOP-LEVEL `update_available`
82
+ // field (merged from the SessionStart worker's cache below).
59
83
  update_available: false,
60
84
  }
61
85
  }
@@ -67,10 +91,14 @@ function inspectRuntime(targetDir, descriptor) {
67
91
  runtime: descriptor.id,
68
92
  installed_version: manifest.framework_version,
69
93
  stale_files: stale,
94
+ // See note above — top-level `update_available` carries the real signal.
70
95
  update_available: false,
71
96
  }
72
97
  }
73
98
 
99
+ /** Cache freshness window — 1 hour, matches the worker's CACHE_TTL_MS. */
100
+ const CACHE_TTL_MS = 60 * 60 * 1_000
101
+
74
102
  /**
75
103
  * @param {string} projectDir
76
104
  */
@@ -106,6 +134,39 @@ export function cmdCheckVersion(projectDir) {
106
134
  messageParts.push(`Skills are up to date for ${runtimes.length} runtime(s).`)
107
135
  }
108
136
 
137
+ // 0.6.7 (Bug 1 fix) — merge the SessionStart worker's cache fields when
138
+ // the cache file is fresh (< 1 h, schema_version=1). On a stale / missing
139
+ // / malformed cache the same field names are emitted with null/false/0
140
+ // sentinel values so the skill's parser stays single-shape (ID-03).
141
+ //
142
+ // LOG-001: stale_files_total is always derived from the live per-runtime
143
+ // walk (totalStale). The cache value is intentionally NOT merged — it is
144
+ // up to 1 h old and would hide drift that occurred since the last worker
145
+ // run.
146
+ //
147
+ // LOG-002: installed_version always comes from the kernel's own
148
+ // package.json (frameworkVersion). The cache's installed_version is
149
+ // stale by definition and is NOT merged into the envelope. Consumers
150
+ // that need the live kernel version use the top-level `framework_version`
151
+ // field, which is identical.
152
+ const cacheResult = readFreshUpdateCache(CACHE_TTL_MS)
153
+ let latestVersion = null
154
+ let updateAvailable = false
155
+ let devInstallAhead = false
156
+ let notPublished = false
157
+ let lookupError = null
158
+ if (cacheResult.fresh) {
159
+ const c = cacheResult.cache
160
+ const fw = c.framework && typeof c.framework === 'object' ? c.framework : {}
161
+ // latest_version is semver-validated inside readFreshUpdateCache (SEC-001).
162
+ if (typeof fw.latest_version === 'string') latestVersion = fw.latest_version
163
+ if (typeof c.update_available === 'boolean') updateAvailable = c.update_available
164
+ if (typeof c.dev_install_ahead === 'boolean') devInstallAhead = c.dev_install_ahead
165
+ if (typeof c.not_published === 'boolean') notPublished = c.not_published
166
+ // stale_files_total intentionally NOT taken from cache (LOG-001).
167
+ if (typeof c.lookup_error === 'string') lookupError = c.lookup_error
168
+ }
169
+
109
170
  return ok({
110
171
  status,
111
172
  command: 'check-version',
@@ -113,5 +174,16 @@ export function cmdCheckVersion(projectDir) {
113
174
  runtimes,
114
175
  legacy_install: legacyInstall,
115
176
  message: messageParts.join(' '),
177
+ // Cache-merge fields (additive, nullable). Documented in the
178
+ // file-header schema comment.
179
+ framework: {
180
+ latest_version: latestVersion,
181
+ },
182
+ update_available: updateAvailable,
183
+ dev_install_ahead: devInstallAhead,
184
+ not_published: notPublished,
185
+ // stale_files_total: always the live sum from per-runtime walks (LOG-001).
186
+ stale_files_total: totalStale,
187
+ lookup_error: lookupError,
116
188
  })
117
189
  }
@@ -16,16 +16,43 @@
16
16
  * reads the gh payload from that file instead of shelling out. The stub
17
17
  * shape is `{ checks: [...], prMetadata: {...} }` — matches the classifier
18
18
  * input contract in stop-matrix.mjs.
19
+ *
20
+ * Production seam: when `--payload-file <path>` is passed, the tick path
21
+ * reads the gh payload from that file. Same shape as the stub. The
22
+ * orchestrator skill (apt-watch-ci §3) captures `gh pr checks ...` +
23
+ * `gh pr view ...` into the payload file BEFORE invoking the cmd — the cmd
24
+ * itself never shells out to gh.
19
25
  */
20
26
 
21
27
  import { randomBytes } from 'node:crypto'
22
28
  import { existsSync, readFileSync } from 'node:fs'
23
- import { resolve } from 'node:path'
29
+ import { dirname, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
24
31
  import { acquireLock, releaseLock } from '../ci-watch/lock.mjs'
25
32
  import { deleteState, listWatches, readState, writeState } from '../ci-watch/state.mjs'
26
33
  import { classify, isFailing } from '../ci-watch/stop-matrix.mjs'
27
34
  import { parseFlags } from '../util/args.mjs'
28
35
  import { err, exitWith, ok } from '../util/result.mjs'
36
+ import { requireMinVersion } from '../util/version-preflight.mjs'
37
+
38
+ /**
39
+ * Installed kernel version — read once at module scope, same pattern as
40
+ * commands/check-version.mjs:108-109. Used by the version-preflight in
41
+ * `doTick` to fail loud (instead of infra-backoff-loop) when a future
42
+ * framework fix introduces a new tick flag and the user is on a stale
43
+ * install. See FRAMEWORK-BUG-017 rung 2 + the helper docstring for the
44
+ * "future-guard" framing.
45
+ */
46
+ const INSTALLED_KERNEL_VERSION = (() => {
47
+ try {
48
+ const here = dirname(fileURLToPath(import.meta.url))
49
+ const pkgPath = resolve(here, '..', '..', '..', 'package.json')
50
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
51
+ return typeof pkg?.version === 'string' ? pkg.version : null
52
+ } catch {
53
+ return null
54
+ }
55
+ })()
29
56
 
30
57
  const DEFAULT_TICK_INTERVAL_MS = 300_000 // 5 minutes
31
58
  const MAX_TICK_INTERVAL_MS = 30 * 60 * 1000 // 30 min
@@ -113,12 +140,14 @@ function doStart(root, flags) {
113
140
  command: 'ci-watch-start',
114
141
  pr: Number(pr),
115
142
  reason: 'already-armed',
143
+ scheduler_handle: existingState.scheduler_handle ?? null,
116
144
  existing: {
117
145
  task_id: existingState.task_id,
118
146
  host: existingState.host,
119
147
  attempts: existingState.attempts,
120
148
  started_at: existingState.started_at,
121
149
  deadline: existingState.deadline,
150
+ scheduler_handle: existingState.scheduler_handle ?? null,
122
151
  },
123
152
  },
124
153
  2,
@@ -255,6 +284,21 @@ function summarize(s) {
255
284
  // ---------------------------------------------------------------------------
256
285
 
257
286
  function doTick(root, flags) {
287
+ // FRAMEWORK-BUG-017 rung 2 (future-guard): emit a structured
288
+ // version_drift envelope when a future framework fix requires a
289
+ // new tick flag and the installed kernel predates it. The current
290
+ // pin to 0.6.7 (the version that introduced --payload-file) is a
291
+ // no-op on every 0.6.7+ install — but the scaffolding is in place
292
+ // for the NEXT new-flag fix to bump `introducedIn` without
293
+ // re-discovering the silent-infra-backoff failure mode.
294
+ const versionGate = requireMinVersion({
295
+ feature: 'payload-file',
296
+ introducedIn: '0.6.7',
297
+ installedVersion: INSTALLED_KERNEL_VERSION,
298
+ missingFlag: '--payload-file',
299
+ })
300
+ if (versionGate) return exitWith(versionGate, 1)
301
+
258
302
  const prCheck = validatePr(flags.get('pr'))
259
303
  if (typeof prCheck !== 'string') return prCheck
260
304
  const pr = prCheck
@@ -290,7 +334,8 @@ function doTick(root, flags) {
290
334
  })
291
335
  }
292
336
 
293
- const payload = fetchGhPayload(pr)
337
+ const payloadFile = flags.get('payload-file')
338
+ const payload = payloadFile ? loadPayloadFile(resolve(root, payloadFile)) : fetchGhPayload(pr)
294
339
  if (payload.error) {
295
340
  // gh CLI not available / auth failed → infra-backoff. Don't count it,
296
341
  // double the next-tick interval (capped at MAX_TICK_INTERVAL_MS), and reschedule.
@@ -480,10 +525,34 @@ function fetchGhPayload(_pr) {
480
525
  // Real gh shell-out lives in the skill body. The cmd's tick treats
481
526
  // "no stub + no payload" as infra-backoff so unit tests can exercise the
482
527
  // path without a live gh binary. The orchestrator skill is responsible
483
- // for pre-fetching and re-invoking with APT_TEST_GH_STUB set, OR for
484
- // passing real data via a future --payload-file flag (out of scope here).
528
+ // for pre-fetching and re-invoking with APT_TEST_GH_STUB set (tests) OR
529
+ // `--payload-file <path>` (production see loadPayloadFile below).
485
530
  return {
486
- error: 'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests',
531
+ error:
532
+ 'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests or pass --payload-file for production',
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Read a gh payload from disk for the production path (FRAMEWORK-BUG-010).
538
+ * Returns the same shape as fetchGhPayload's stub branch on success, or an
539
+ * { error } envelope on missing path / parse failure — the existing
540
+ * infra-backoff branch surfaces the error cleanly.
541
+ *
542
+ * @param {string} path absolute or already-resolved path
543
+ */
544
+ function loadPayloadFile(path) {
545
+ try {
546
+ if (!existsSync(path)) {
547
+ return { error: `payload-file path not found: ${path}` }
548
+ }
549
+ const data = JSON.parse(readFileSync(path, 'utf-8'))
550
+ return {
551
+ checks: data.checks || [],
552
+ prMetadata: data.prMetadata || { state: 'OPEN' },
553
+ }
554
+ } catch (e) {
555
+ return { error: `payload-file parse error: ${e.message}` }
487
556
  }
488
557
  }
489
558
 
@@ -140,7 +140,7 @@ export function cmdHealthCheck(projectDir) {
140
140
  issues.push({
141
141
  type: 'missing-skills',
142
142
  severity: 'warning',
143
- detail: 'No installed skill version stamps found under .claude/skills or .agents/skills',
143
+ detail: 'No apt install manifests or version stamps found for any supported runtime',
144
144
  install_surfaces: installs,
145
145
  })
146
146
  }
@@ -41,6 +41,7 @@ import {
41
41
  writeLastInstallSummary,
42
42
  } from '../install/pipeline.mjs'
43
43
  import { RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
44
+ import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
44
45
  import {
45
46
  CLASS_A_SETTINGS,
46
47
  CLASS_B_SETTINGS,
@@ -48,6 +49,7 @@ import {
48
49
  parseClassFlags,
49
50
  } from '../install/setting-class.mjs'
50
51
  import { detectAgentRuntime } from '../util/agent-runtime.mjs'
52
+ import { buildAptSection } from '../util/aperant-section.mjs'
51
53
  import { atomicWriteJson } from '../util/atomic-write.mjs'
52
54
  import { aperantBanner } from '../util/banner.mjs'
53
55
  import { copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
@@ -82,8 +84,21 @@ const TRANSFORM_LOADERS = new Map(
82
84
  * a missing runtimes flag should block (no flag, no `--yes`) or not
83
85
  * (flag passed, or `--yes`).
84
86
  *
87
+ * 0.6.7 — `--force-runtimes` (Bug 2 fix) bypasses the `upgrade_noop`
88
+ * short-circuit so `/apt:update` can re-stamp runtime manifests even
89
+ * when `.aperant/config.json` is at the current schema. Strictly
90
+ * additive: callers without the flag still get the no-op short-circuit
91
+ * (preserves `init-upgrade-drift.test.ts` case (a)).
92
+ *
93
+ * 0.7.0 — `--force-overwrite-catalog` (FRAMEWORK-BUG-023) bypasses the
94
+ * foreign-row defensive check in `injectInstructionFile`. Power-user
95
+ * flag only — no interview prompt. Without it, an instruction file
96
+ * carrying rows the generator didn't produce causes init to exit 2 with
97
+ * `code: 'instruction-file.foreign-rows'` so unattended upgrade flows
98
+ * surface the would-be data loss instead of silently clobbering it.
99
+ *
85
100
  * @param {string[]} args
86
- * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean }}
101
+ * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean, forceOverwriteCatalog: boolean, keepLegacy: boolean }}
87
102
  */
88
103
  export function parseInstallFlags(args) {
89
104
  const flagSet = new Set(args.filter((a) => a.startsWith('--')))
@@ -96,12 +111,26 @@ export function parseInstallFlags(args) {
96
111
  // C47 D-04 — opt out of the PROJECT.md auto-chain. Forwarded as
97
112
  // `answers.skipBootstrap` into both interview paths.
98
113
  const skipBootstrap = flagSet.has('--skip-bootstrap')
114
+ // 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
115
+ // so /apt:update can re-stamp runtime files on a no-drift upgrade.
116
+ const forceRuntimes = flagSet.has('--force-runtimes')
117
+ // 0.7.0 (FRAMEWORK-BUG-023) — opt-in bypass of the foreign-row check
118
+ // in injectInstructionFile. Power-user flag only.
119
+ const forceOverwriteCatalog = flagSet.has('--force-overwrite-catalog')
120
+ // 0.7.0 (FRAMEWORK-BUG-021) — opt out of the codex legacy-tree cleanup
121
+ // that fires when `codex` is in the runtime set. Defaults to false so
122
+ // the cleanup runs by default; pass `--keep-legacy` to preserve the
123
+ // orphaned `.agents/skills/` tree on upgrades.
124
+ const keepLegacy = flagSet.has('--keep-legacy')
99
125
 
100
126
  const classFlags = parseClassFlags(args)
101
127
 
102
128
  const base = {
103
129
  target,
104
130
  skipBootstrap,
131
+ forceRuntimes,
132
+ forceOverwriteCatalog,
133
+ keepLegacy,
105
134
  visibility: classFlags.visibility,
106
135
  autonomy: classFlags.autonomy,
107
136
  accept_new_defaults: classFlags.accept_new_defaults,
@@ -326,6 +355,33 @@ function applyCollapsedClassAToMigration(
326
355
  }
327
356
  }
328
357
 
358
+ /**
359
+ * Resolve the effective runtimes list from flags, with fallback to
360
+ * `suggestRuntimes` and a final fallback to `['claude']`.
361
+ *
362
+ * Shared by the `nonInteractiveAnswers` upgrade path and the
363
+ * `--force-runtimes` synthesis block in `cmdInit` to avoid duplication.
364
+ *
365
+ * Emits a one-line stderr warning when the final fallback fires (no
366
+ * explicit flags and `suggestRuntimes` returned empty) so the choice is
367
+ * visible without breaking automated callers.
368
+ *
369
+ * @param {string} targetDir
370
+ * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
371
+ * @returns {string[]}
372
+ */
373
+ function selectRuntimes(targetDir, parsedFlags) {
374
+ if (parsedFlags.runtimes_explicit) return parsedFlags.runtimes
375
+ const suggested = new Set(suggestRuntimes(targetDir))
376
+ if (suggested.size === 0) {
377
+ process.stderr.write(
378
+ '[apt-tools] No runtime specified and config has no suggestRuntimes; defaulting to claude.\n',
379
+ )
380
+ suggested.add('claude')
381
+ }
382
+ return [...suggested]
383
+ }
384
+
329
385
  /**
330
386
  * Non-TTY / scripted path — derive answers from flags + sensible defaults,
331
387
  * gated by the Class A/B classifier (spec R2 + R3).
@@ -427,9 +483,7 @@ function nonInteractiveAnswers(targetDir, canonicalRoot, archetype, parsedFlags)
427
483
  const visibility = migration.migrated?.share?.visibility === 'team' ? 'team' : 'solo'
428
484
  const autonomyDefault = migration.migrated?.autonomy?.default ?? 1
429
485
  const xverify = migration.migrated?.multi_model?.cross_verification?.tools ?? []
430
- const suggested = new Set(suggestRuntimes(targetDir))
431
- if (suggested.size === 0) suggested.add('claude')
432
- const runtimes = parsedFlags.runtimes_explicit ? parsedFlags.runtimes : [...suggested]
486
+ const runtimes = selectRuntimes(targetDir, parsedFlags)
433
487
  return {
434
488
  runtimes,
435
489
  autonomyDefault,
@@ -599,6 +653,12 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
599
653
  migration.applied_defaults.length === 0 &&
600
654
  migration.auto_added.length === 0
601
655
  if (noDrift) {
656
+ // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses the TTY
657
+ // "Nothing to do." outro so the runtime walker re-stamps every
658
+ // manifest; cmdInit's force-runtimes synthesis path takes over.
659
+ if (parsedFlags.forceRuntimes) {
660
+ return { upgrade_noop: true, skipBootstrap, migration }
661
+ }
602
662
  process.stdout.write(`\n${aperantBanner(version)}\n\n`)
603
663
  clack.intro('install')
604
664
  clack.note(`Already up to date — v${version}`, 'No drift')
@@ -907,6 +967,22 @@ function applyInterviewAnswers(config, answers) {
907
967
  }
908
968
  config.project_type =
909
969
  answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
970
+
971
+ // Onboarding marker (task 15-05-26_apt-setup-onboarding-marker-7q9c).
972
+ // `/apt:setup` dispatches on this marker to decide review-mode vs full
973
+ // Batch 1-8 flow — file existence alone cannot disambiguate "fresh seed
974
+ // from template" from "fully tuned by setup" because templates/config.json
975
+ // ships with every key populated. Only the fresh-seed branch reaches this
976
+ // function (finishInit:1331); the upgrade branch (finishInit:1338-1357)
977
+ // writes answers.migration.migrated directly and never calls
978
+ // applyInterviewAnswers, so legacy unmarked configs correctly stay
979
+ // unmarked on upgrade. --yes / --accept-new-defaults walk this same fresh
980
+ // path but do NOT walk Batch 1-8 questions, so completed stays false.
981
+ config.onboarding = {
982
+ completed: false,
983
+ seeded_from_template: true,
984
+ seeded_at: new Date().toISOString(),
985
+ }
910
986
  }
911
987
 
912
988
  /**
@@ -1061,7 +1137,13 @@ export async function cmdInit(projectDir, extraArgs = []) {
1061
1137
  // idempotent — preserves PROJECT.md, refreshes sidecar from it).
1062
1138
  // In TTY mode clack already rendered "Nothing to do." to stdout,
1063
1139
  // so suppress the JSON envelope (matches the cancelled path).
1064
- if (answers?.upgrade_noop) {
1140
+ //
1141
+ // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses this short-circuit
1142
+ // so `/apt:update` can re-stamp every runtime manifest even when
1143
+ // `.aperant/config.json` is at the current schema. We synthesize a
1144
+ // real InstallAnswers shape from the existing config so
1145
+ // installRuntimes + finishInit run their normal upgrade path.
1146
+ if (answers?.upgrade_noop && !parsedFlags.forceRuntimes) {
1065
1147
  if (interactiveTty) return rawText('', 0)
1066
1148
  const bootstrap = answers.skipBootstrap
1067
1149
  ? { status: 'skipped', reason: '--skip-bootstrap flag' }
@@ -1077,7 +1159,57 @@ export async function cmdInit(projectDir, extraArgs = []) {
1077
1159
  })
1078
1160
  }
1079
1161
 
1080
- const results = await installRuntimes(answers.runtimes, canonicalRoot, installTarget, version)
1162
+ // 0.6.7 (Bug 2 fix) when forceRuntimes is set on a no-drift run,
1163
+ // synthesize a real InstallAnswers shape from the existing config so
1164
+ // the runtime walker re-stamps every manifest. Visibility/autonomy/
1165
+ // cross-verification are read from answers.migration.migrated (already
1166
+ // in memory from nonInteractiveAnswers — avoids a second config read);
1167
+ // runtimes resolved via selectRuntimes helper shared with the upgrade path.
1168
+ let effectiveAnswers = answers
1169
+ if (answers?.upgrade_noop && parsedFlags.forceRuntimes) {
1170
+ const existing = answers.migration?.migrated || {}
1171
+ const runtimes = selectRuntimes(installTarget, parsedFlags)
1172
+ effectiveAnswers = {
1173
+ runtimes,
1174
+ autonomyDefault: existing?.autonomy?.default ?? 1,
1175
+ visibility: existing?.share?.visibility === 'team' ? 'team' : 'solo',
1176
+ crossVerificationTools: existing?.multi_model?.cross_verification?.tools ?? [],
1177
+ scanNow: false,
1178
+ skipBootstrap: answers.skipBootstrap,
1179
+ archetype: detectArchetype(installTarget),
1180
+ agent_runtime: detectAgentRuntime(),
1181
+ applied_defaults: [],
1182
+ explicit_inputs: collectExplicitInputs(parsedFlags),
1183
+ }
1184
+ }
1185
+
1186
+ // FRAMEWORK-BUG-021 — sweep the legacy `.agents/skills/` codex tree
1187
+ // BEFORE the install pipeline runs so the install can re-stamp
1188
+ // `.codex/` against a clean slate. Only fires when codex is in the
1189
+ // requested runtime set and `--keep-legacy` was not passed; the
1190
+ // helper itself is conservative (no-op on managed installs, no-op
1191
+ // when no legacy framework files are present).
1192
+ let legacyCleanup = null
1193
+ if (effectiveAnswers.runtimes.includes('codex')) {
1194
+ if (parsedFlags.keepLegacy) {
1195
+ legacyCleanup = { runtime: 'codex', ran: false, reason: '--keep-legacy' }
1196
+ } else {
1197
+ const cleanup = runLegacyCleanup({ targetDir: installTarget })
1198
+ legacyCleanup = { runtime: 'codex', ...cleanup }
1199
+ if (cleanup.ran && cleanup.retained.length > 0) {
1200
+ process.stderr.write(
1201
+ `[apt-tools] legacy_cleanup retained ${cleanup.retained.length} user-added path(s) under .agents/skills/: ${cleanup.retained.join(', ')}\n`,
1202
+ )
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ const results = await installRuntimes(
1208
+ effectiveAnswers.runtimes,
1209
+ canonicalRoot,
1210
+ installTarget,
1211
+ version,
1212
+ )
1081
1213
 
1082
1214
  // Provision the project-local kernel BEFORE finishInit runs its JSON
1083
1215
  // output — that way the "structure" summary can reflect the kernel
@@ -1089,14 +1221,21 @@ export async function cmdInit(projectDir, extraArgs = []) {
1089
1221
  installTarget,
1090
1222
  canonicalRoot,
1091
1223
  version,
1092
- answers.runtimes,
1224
+ effectiveAnswers.runtimes,
1093
1225
  results,
1094
- answers,
1226
+ effectiveAnswers,
1095
1227
  interactiveTty,
1096
1228
  kernelResult,
1229
+ parsedFlags.forceOverwriteCatalog === true,
1230
+ legacyCleanup,
1097
1231
  )
1232
+ // FRAMEWORK-BUG-023 — finishInit signals the foreign-row defensive
1233
+ // abort by returning an early-exit sentinel envelope.
1234
+ if (envelope?.__earlyExit) {
1235
+ return envelope.__earlyExit
1236
+ }
1098
1237
  if (interactiveTty) {
1099
- await renderOutro(answers, results, version, kernelResult)
1238
+ await renderOutro(effectiveAnswers, results, version, kernelResult)
1100
1239
  // TTY mode: envelope was persisted to disk + suppressed from stdout
1101
1240
  // because the clack outro already rendered the user-facing summary.
1102
1241
  // Return the rawText shape with empty payload so wrap() routes us
@@ -1273,6 +1412,19 @@ async function renderOutro(answers, results, version, kernelResult) {
1273
1412
  * deps/` — surfaced in the envelope so
1274
1413
  * downstream tooling can detect a
1275
1414
  * degraded install.
1415
+ * @param {boolean} [forceOverwriteCatalog=false]
1416
+ * FRAMEWORK-BUG-023 — bypass the
1417
+ * foreign-row check in
1418
+ * injectInstructionFile when the user
1419
+ * explicitly opted in via
1420
+ * --force-overwrite-catalog.
1421
+ * @param {object|null} [legacyCleanup=null]
1422
+ * FRAMEWORK-BUG-021 — outcome of the
1423
+ * codex `.agents/skills/` cleanup
1424
+ * step. `null` when codex was not in
1425
+ * the runtime set; otherwise the
1426
+ * shape `{ runtime, ran, … }` from
1427
+ * `runLegacyCleanup`.
1276
1428
  */
1277
1429
  function finishInit(
1278
1430
  targetDir,
@@ -1284,6 +1436,8 @@ function finishInit(
1284
1436
  answers,
1285
1437
  suppressStdout = false,
1286
1438
  kernelResult = null,
1439
+ forceOverwriteCatalog = false,
1440
+ legacyCleanup = null,
1287
1441
  ) {
1288
1442
  writeLastInstallSummary(targetDir, runtimeResults, version)
1289
1443
 
@@ -1414,7 +1568,40 @@ function finishInit(
1414
1568
  if (existsSync(claudeMdPath)) instructionFile = claudeMdPath
1415
1569
  else if (existsSync(agentsMdPath)) instructionFile = agentsMdPath
1416
1570
  else instructionFile = claudeMdPath
1417
- const injectionResult = injectInstructionFile(instructionFile)
1571
+ // FRAMEWORK-BUG-023 generate the section from discoverSkills before
1572
+ // handing it to the writer. The writer also runs a foreign-row defensive
1573
+ // check; if rows present in the file aren't in the generated set we
1574
+ // signal exit-2 via the __earlyExit sentinel so cmdInit can route the
1575
+ // envelope through exitWith() without writing a malformed install.
1576
+ const aptSection = buildAptSection({
1577
+ canonicalRoot,
1578
+ targetDir,
1579
+ cli: detectAgentRuntime(),
1580
+ })
1581
+ const injectionOutcome = injectInstructionFile(instructionFile, {
1582
+ aptSection,
1583
+ force: forceOverwriteCatalog,
1584
+ })
1585
+ if (injectionOutcome.status === 'aborted-foreign-rows') {
1586
+ return {
1587
+ __earlyExit: exitWith(
1588
+ {
1589
+ ok: false,
1590
+ status: 'error',
1591
+ command: 'init',
1592
+ code: 'instruction-file.foreign-rows',
1593
+ message:
1594
+ `${basename(instructionFile)} contains skill rows not present in the installed framework: ` +
1595
+ `${injectionOutcome.foreignRowIds.join(', ')}. ` +
1596
+ 'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
1597
+ foreignRowIds: injectionOutcome.foreignRowIds,
1598
+ instructionFile: basename(instructionFile),
1599
+ },
1600
+ 2,
1601
+ ),
1602
+ }
1603
+ }
1604
+ const injectionResult = injectionOutcome.status
1418
1605
 
1419
1606
  const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
1420
1607
  const precompactResult = installedClaude
@@ -1525,6 +1712,11 @@ function finishInit(
1525
1712
  ...(kernelResult.reason ? { reason: kernelResult.reason } : {}),
1526
1713
  }
1527
1714
  : null,
1715
+ // FRAMEWORK-BUG-021 — always-present envelope field. `null` when
1716
+ // codex was not in the runtime set; otherwise the cleanup outcome
1717
+ // (uses `{ ran: false, reason }` for every skip case so parsers
1718
+ // see a uniform shape).
1719
+ legacy_cleanup: legacyCleanup,
1528
1720
  }
1529
1721
 
1530
1722
  if (suppressStdout) {