@aperant/framework 0.6.6 → 0.6.7

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 (88) hide show
  1. package/CHANGELOG.md +155 -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 +34 -4
  7. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  8. package/dist/cli/commands/init.d.mts +8 -1
  9. package/dist/cli/commands/init.d.mts.map +1 -1
  10. package/dist/cli/commands/init.mjs +91 -9
  11. package/dist/cli/commands/init.mjs.map +1 -1
  12. package/dist/cli/commands/release-notes.d.mts +11 -0
  13. package/dist/cli/commands/release-notes.d.mts.map +1 -0
  14. package/dist/cli/commands/release-notes.mjs +173 -0
  15. package/dist/cli/commands/release-notes.mjs.map +1 -0
  16. package/dist/cli/config/load.d.mts +14 -0
  17. package/dist/cli/config/load.d.mts.map +1 -1
  18. package/dist/cli/config/load.mjs +40 -0
  19. package/dist/cli/config/load.mjs.map +1 -1
  20. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  21. package/dist/cli/config/upgrade-gitignore.mjs +6 -0
  22. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  23. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  24. package/dist/cli/consistency/parse-review.mjs +4 -1
  25. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  26. package/dist/cli/dispatch.d.mts.map +1 -1
  27. package/dist/cli/dispatch.mjs +2 -0
  28. package/dist/cli/dispatch.mjs.map +1 -1
  29. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  30. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  31. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  32. package/dist/cli/install/update-chips.d.mts +23 -0
  33. package/dist/cli/install/update-chips.d.mts.map +1 -1
  34. package/dist/cli/install/update-chips.mjs +60 -0
  35. package/dist/cli/install/update-chips.mjs.map +1 -1
  36. package/dist/cli/release-notes/compile.d.mts +38 -0
  37. package/dist/cli/release-notes/compile.d.mts.map +1 -0
  38. package/dist/cli/release-notes/compile.mjs +244 -0
  39. package/dist/cli/release-notes/compile.mjs.map +1 -0
  40. package/dist/cli/release-notes/draft.d.mts +73 -0
  41. package/dist/cli/release-notes/draft.d.mts.map +1 -0
  42. package/dist/cli/release-notes/draft.mjs +120 -0
  43. package/dist/cli/release-notes/draft.mjs.map +1 -0
  44. package/dist/cli/release-notes/output-dir.d.mts +20 -0
  45. package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
  46. package/dist/cli/release-notes/output-dir.mjs +42 -0
  47. package/dist/cli/release-notes/output-dir.mjs.map +1 -0
  48. package/dist/cli/release-notes/persona-filter.d.mts +51 -0
  49. package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
  50. package/dist/cli/release-notes/persona-filter.mjs +127 -0
  51. package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
  52. package/dist/cli/release-notes/publish.d.mts +23 -0
  53. package/dist/cli/release-notes/publish.d.mts.map +1 -0
  54. package/dist/cli/release-notes/publish.mjs +125 -0
  55. package/dist/cli/release-notes/publish.mjs.map +1 -0
  56. package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
  57. package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
  58. package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
  59. package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
  60. package/dist/types/config.d.ts +11 -7
  61. package/dist/types/config.d.ts.map +1 -1
  62. package/package.json +133 -133
  63. package/skills/apt-close-task/SKILL.md +25 -0
  64. package/skills/apt-diagram/SKILL.md +45 -9
  65. package/skills/apt-release-notes/SKILL.md +193 -0
  66. package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  67. package/skills/apt-setup/SKILL.md +146 -3
  68. package/skills/apt-ship/SKILL.md +47 -0
  69. package/skills/apt-update/SKILL.md +51 -9
  70. package/skills/apt-watch-ci/SKILL.md +2 -2
  71. package/src/cli/commands/check-version.mjs +73 -1
  72. package/src/cli/commands/ci-watch.mjs +35 -4
  73. package/src/cli/commands/init.mjs +101 -9
  74. package/src/cli/commands/release-notes.mjs +187 -0
  75. package/src/cli/config/load.mjs +37 -0
  76. package/src/cli/config/upgrade-gitignore.mjs +6 -0
  77. package/src/cli/consistency/parse-review.mjs +3 -1
  78. package/src/cli/dispatch.mjs +2 -0
  79. package/src/cli/gate/gates/review-clean.mjs +4 -2
  80. package/src/cli/install/update-chips.mjs +57 -0
  81. package/src/cli/release-notes/compile.mjs +261 -0
  82. package/src/cli/release-notes/draft.mjs +133 -0
  83. package/src/cli/release-notes/output-dir.mjs +52 -0
  84. package/src/cli/release-notes/persona-filter.mjs +126 -0
  85. package/src/cli/release-notes/publish.mjs +128 -0
  86. package/src/cli/release-notes/ship-autodraft.mjs +106 -0
  87. package/templates/config.json +28 -3
  88. 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,6 +16,12 @@
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'
@@ -290,7 +296,8 @@ function doTick(root, flags) {
290
296
  })
291
297
  }
292
298
 
293
- const payload = fetchGhPayload(pr)
299
+ const payloadFile = flags.get('payload-file')
300
+ const payload = payloadFile ? loadPayloadFile(resolve(root, payloadFile)) : fetchGhPayload(pr)
294
301
  if (payload.error) {
295
302
  // gh CLI not available / auth failed → infra-backoff. Don't count it,
296
303
  // double the next-tick interval (capped at MAX_TICK_INTERVAL_MS), and reschedule.
@@ -480,10 +487,34 @@ function fetchGhPayload(_pr) {
480
487
  // Real gh shell-out lives in the skill body. The cmd's tick treats
481
488
  // "no stub + no payload" as infra-backoff so unit tests can exercise the
482
489
  // 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).
490
+ // for pre-fetching and re-invoking with APT_TEST_GH_STUB set (tests) OR
491
+ // `--payload-file <path>` (production see loadPayloadFile below).
485
492
  return {
486
- error: 'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests',
493
+ error:
494
+ 'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests or pass --payload-file for production',
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Read a gh payload from disk for the production path (FRAMEWORK-BUG-010).
500
+ * Returns the same shape as fetchGhPayload's stub branch on success, or an
501
+ * { error } envelope on missing path / parse failure — the existing
502
+ * infra-backoff branch surfaces the error cleanly.
503
+ *
504
+ * @param {string} path absolute or already-resolved path
505
+ */
506
+ function loadPayloadFile(path) {
507
+ try {
508
+ if (!existsSync(path)) {
509
+ return { error: `payload-file path not found: ${path}` }
510
+ }
511
+ const data = JSON.parse(readFileSync(path, 'utf-8'))
512
+ return {
513
+ checks: data.checks || [],
514
+ prMetadata: data.prMetadata || { state: 'OPEN' },
515
+ }
516
+ } catch (e) {
517
+ return { error: `payload-file parse error: ${e.message}` }
487
518
  }
488
519
  }
489
520
 
@@ -82,8 +82,14 @@ const TRANSFORM_LOADERS = new Map(
82
82
  * a missing runtimes flag should block (no flag, no `--yes`) or not
83
83
  * (flag passed, or `--yes`).
84
84
  *
85
+ * 0.6.7 — `--force-runtimes` (Bug 2 fix) bypasses the `upgrade_noop`
86
+ * short-circuit so `/apt:update` can re-stamp runtime manifests even
87
+ * when `.aperant/config.json` is at the current schema. Strictly
88
+ * additive: callers without the flag still get the no-op short-circuit
89
+ * (preserves `init-upgrade-drift.test.ts` case (a)).
90
+ *
85
91
  * @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 }}
92
+ * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean }}
87
93
  */
88
94
  export function parseInstallFlags(args) {
89
95
  const flagSet = new Set(args.filter((a) => a.startsWith('--')))
@@ -96,12 +102,16 @@ export function parseInstallFlags(args) {
96
102
  // C47 D-04 — opt out of the PROJECT.md auto-chain. Forwarded as
97
103
  // `answers.skipBootstrap` into both interview paths.
98
104
  const skipBootstrap = flagSet.has('--skip-bootstrap')
105
+ // 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
106
+ // so /apt:update can re-stamp runtime files on a no-drift upgrade.
107
+ const forceRuntimes = flagSet.has('--force-runtimes')
99
108
 
100
109
  const classFlags = parseClassFlags(args)
101
110
 
102
111
  const base = {
103
112
  target,
104
113
  skipBootstrap,
114
+ forceRuntimes,
105
115
  visibility: classFlags.visibility,
106
116
  autonomy: classFlags.autonomy,
107
117
  accept_new_defaults: classFlags.accept_new_defaults,
@@ -326,6 +336,33 @@ function applyCollapsedClassAToMigration(
326
336
  }
327
337
  }
328
338
 
339
+ /**
340
+ * Resolve the effective runtimes list from flags, with fallback to
341
+ * `suggestRuntimes` and a final fallback to `['claude']`.
342
+ *
343
+ * Shared by the `nonInteractiveAnswers` upgrade path and the
344
+ * `--force-runtimes` synthesis block in `cmdInit` to avoid duplication.
345
+ *
346
+ * Emits a one-line stderr warning when the final fallback fires (no
347
+ * explicit flags and `suggestRuntimes` returned empty) so the choice is
348
+ * visible without breaking automated callers.
349
+ *
350
+ * @param {string} targetDir
351
+ * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
352
+ * @returns {string[]}
353
+ */
354
+ function selectRuntimes(targetDir, parsedFlags) {
355
+ if (parsedFlags.runtimes_explicit) return parsedFlags.runtimes
356
+ const suggested = new Set(suggestRuntimes(targetDir))
357
+ if (suggested.size === 0) {
358
+ process.stderr.write(
359
+ '[apt-tools] No runtime specified and config has no suggestRuntimes; defaulting to claude.\n',
360
+ )
361
+ suggested.add('claude')
362
+ }
363
+ return [...suggested]
364
+ }
365
+
329
366
  /**
330
367
  * Non-TTY / scripted path — derive answers from flags + sensible defaults,
331
368
  * gated by the Class A/B classifier (spec R2 + R3).
@@ -427,9 +464,7 @@ function nonInteractiveAnswers(targetDir, canonicalRoot, archetype, parsedFlags)
427
464
  const visibility = migration.migrated?.share?.visibility === 'team' ? 'team' : 'solo'
428
465
  const autonomyDefault = migration.migrated?.autonomy?.default ?? 1
429
466
  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]
467
+ const runtimes = selectRuntimes(targetDir, parsedFlags)
433
468
  return {
434
469
  runtimes,
435
470
  autonomyDefault,
@@ -599,6 +634,12 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
599
634
  migration.applied_defaults.length === 0 &&
600
635
  migration.auto_added.length === 0
601
636
  if (noDrift) {
637
+ // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses the TTY
638
+ // "Nothing to do." outro so the runtime walker re-stamps every
639
+ // manifest; cmdInit's force-runtimes synthesis path takes over.
640
+ if (parsedFlags.forceRuntimes) {
641
+ return { upgrade_noop: true, skipBootstrap, migration }
642
+ }
602
643
  process.stdout.write(`\n${aperantBanner(version)}\n\n`)
603
644
  clack.intro('install')
604
645
  clack.note(`Already up to date — v${version}`, 'No drift')
@@ -907,6 +948,22 @@ function applyInterviewAnswers(config, answers) {
907
948
  }
908
949
  config.project_type =
909
950
  answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
951
+
952
+ // Onboarding marker (task 15-05-26_apt-setup-onboarding-marker-7q9c).
953
+ // `/apt:setup` dispatches on this marker to decide review-mode vs full
954
+ // Batch 1-8 flow — file existence alone cannot disambiguate "fresh seed
955
+ // from template" from "fully tuned by setup" because templates/config.json
956
+ // ships with every key populated. Only the fresh-seed branch reaches this
957
+ // function (finishInit:1331); the upgrade branch (finishInit:1338-1357)
958
+ // writes answers.migration.migrated directly and never calls
959
+ // applyInterviewAnswers, so legacy unmarked configs correctly stay
960
+ // unmarked on upgrade. --yes / --accept-new-defaults walk this same fresh
961
+ // path but do NOT walk Batch 1-8 questions, so completed stays false.
962
+ config.onboarding = {
963
+ completed: false,
964
+ seeded_from_template: true,
965
+ seeded_at: new Date().toISOString(),
966
+ }
910
967
  }
911
968
 
912
969
  /**
@@ -1061,7 +1118,13 @@ export async function cmdInit(projectDir, extraArgs = []) {
1061
1118
  // idempotent — preserves PROJECT.md, refreshes sidecar from it).
1062
1119
  // In TTY mode clack already rendered "Nothing to do." to stdout,
1063
1120
  // so suppress the JSON envelope (matches the cancelled path).
1064
- if (answers?.upgrade_noop) {
1121
+ //
1122
+ // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses this short-circuit
1123
+ // so `/apt:update` can re-stamp every runtime manifest even when
1124
+ // `.aperant/config.json` is at the current schema. We synthesize a
1125
+ // real InstallAnswers shape from the existing config so
1126
+ // installRuntimes + finishInit run their normal upgrade path.
1127
+ if (answers?.upgrade_noop && !parsedFlags.forceRuntimes) {
1065
1128
  if (interactiveTty) return rawText('', 0)
1066
1129
  const bootstrap = answers.skipBootstrap
1067
1130
  ? { status: 'skipped', reason: '--skip-bootstrap flag' }
@@ -1077,7 +1140,36 @@ export async function cmdInit(projectDir, extraArgs = []) {
1077
1140
  })
1078
1141
  }
1079
1142
 
1080
- const results = await installRuntimes(answers.runtimes, canonicalRoot, installTarget, version)
1143
+ // 0.6.7 (Bug 2 fix) when forceRuntimes is set on a no-drift run,
1144
+ // synthesize a real InstallAnswers shape from the existing config so
1145
+ // the runtime walker re-stamps every manifest. Visibility/autonomy/
1146
+ // cross-verification are read from answers.migration.migrated (already
1147
+ // in memory from nonInteractiveAnswers — avoids a second config read);
1148
+ // runtimes resolved via selectRuntimes helper shared with the upgrade path.
1149
+ let effectiveAnswers = answers
1150
+ if (answers?.upgrade_noop && parsedFlags.forceRuntimes) {
1151
+ const existing = answers.migration?.migrated || {}
1152
+ const runtimes = selectRuntimes(installTarget, parsedFlags)
1153
+ effectiveAnswers = {
1154
+ runtimes,
1155
+ autonomyDefault: existing?.autonomy?.default ?? 1,
1156
+ visibility: existing?.share?.visibility === 'team' ? 'team' : 'solo',
1157
+ crossVerificationTools: existing?.multi_model?.cross_verification?.tools ?? [],
1158
+ scanNow: false,
1159
+ skipBootstrap: answers.skipBootstrap,
1160
+ archetype: detectArchetype(installTarget),
1161
+ agent_runtime: detectAgentRuntime(),
1162
+ applied_defaults: [],
1163
+ explicit_inputs: collectExplicitInputs(parsedFlags),
1164
+ }
1165
+ }
1166
+
1167
+ const results = await installRuntimes(
1168
+ effectiveAnswers.runtimes,
1169
+ canonicalRoot,
1170
+ installTarget,
1171
+ version,
1172
+ )
1081
1173
 
1082
1174
  // Provision the project-local kernel BEFORE finishInit runs its JSON
1083
1175
  // output — that way the "structure" summary can reflect the kernel
@@ -1089,14 +1181,14 @@ export async function cmdInit(projectDir, extraArgs = []) {
1089
1181
  installTarget,
1090
1182
  canonicalRoot,
1091
1183
  version,
1092
- answers.runtimes,
1184
+ effectiveAnswers.runtimes,
1093
1185
  results,
1094
- answers,
1186
+ effectiveAnswers,
1095
1187
  interactiveTty,
1096
1188
  kernelResult,
1097
1189
  )
1098
1190
  if (interactiveTty) {
1099
- await renderOutro(answers, results, version, kernelResult)
1191
+ await renderOutro(effectiveAnswers, results, version, kernelResult)
1100
1192
  // TTY mode: envelope was persisted to disk + suppressed from stdout
1101
1193
  // because the clack outro already rendered the user-facing summary.
1102
1194
  // Return the rawText shape with empty payload so wrap() routes us
@@ -0,0 +1,187 @@
1
+ /**
2
+ * commands/release-notes.mjs — `apt-tools release-notes <sub>` (subtask 2-3
3
+ * of persona-aware-changelog-and-release-note_16-05-26).
4
+ *
5
+ * Deterministic I/O surface for the apt:release-notes skill. NEVER calls
6
+ * an LLM. The SKILL.md body invokes the host LLM to author the note text
7
+ * and hands the structured payload to this CLI for persistence + compile.
8
+ *
9
+ * Sub-verb menu:
10
+ *
11
+ * apt-tools release-notes draft <project-dir> --pr <n> --note "<text>"
12
+ * [--persona <id>] [--output-dir <path>] [--commits <sha,sha,...>]
13
+ * [--date <YYYY-MM-DD>]
14
+ * → write `<output_dir>/_unreleased/PR-<n>.md`. Idempotent.
15
+ *
16
+ * apt-tools release-notes compile <project-dir> --tag <tag>
17
+ * [--output-dir <path>]
18
+ * → read every `_unreleased/PR-*.md`, filter by persona_filter, write
19
+ * `<output_dir>/<tag>.md`, clear `_unreleased/` only on success.
20
+ *
21
+ * apt-tools release-notes publish <project-dir> --tag <tag>
22
+ * [--output-dir <path>] [--dry-run]
23
+ * → read `<output_dir>/<tag>.md` + shell `gh release create/edit`.
24
+ *
25
+ * Return shape: always `{ status, command, ... }`. The wrap() helper in
26
+ * dispatch.mjs translates `{ status: 'error' }` envelopes into exit-1.
27
+ */
28
+
29
+ import { resolve } from 'node:path'
30
+
31
+ import { compileTag } from '../release-notes/compile.mjs'
32
+ import { writeDraft } from '../release-notes/draft.mjs'
33
+ import { publishTag } from '../release-notes/publish.mjs'
34
+ import { getFlagValue } from '../util/common-flags.mjs'
35
+ import { err, ok } from '../util/result.mjs'
36
+
37
+ /**
38
+ * Dispatch shape (mirrors `audit`): `release-notes: (args) =>
39
+ * wrap(cmdReleaseNotes)(args[1], args[2], args.slice(3))` — args[1] is the
40
+ * sub-verb, args[2] is the project dir.
41
+ *
42
+ * @param {string | undefined} subcommand
43
+ * @param {string | undefined} projectDirArg
44
+ * @param {string[] | undefined} extraArgs
45
+ */
46
+ export async function cmdReleaseNotes(subcommand, projectDirArg, extraArgs) {
47
+ const args = extraArgs ?? []
48
+ if (!subcommand) {
49
+ return err(
50
+ 'Usage: apt-tools release-notes <draft|compile|publish> <project-dir> [flags]\n' +
51
+ ' draft write docs/releases/_unreleased/PR-<n>.md\n' +
52
+ ' compile read fragments → docs/releases/<tag>.md, clear _unreleased/\n' +
53
+ ' publish shell gh release create/edit',
54
+ )
55
+ }
56
+ const projectDir = resolve(projectDirArg && projectDirArg.length > 0 ? projectDirArg : '.')
57
+
58
+ switch (subcommand) {
59
+ case 'draft':
60
+ return runDraft(projectDir, args)
61
+ case 'compile':
62
+ return runCompile(projectDir, args)
63
+ case 'publish':
64
+ return runPublish(projectDir, args)
65
+ default:
66
+ return err(
67
+ `apt-tools release-notes: unknown subcommand "${subcommand}". Known: draft, compile, publish.`,
68
+ )
69
+ }
70
+ }
71
+
72
+ /**
73
+ * draft — write a single PR fragment.
74
+ *
75
+ * @param {string} projectDir
76
+ * @param {string[]} args
77
+ */
78
+ function runDraft(projectDir, args) {
79
+ const prRaw = getFlagValue(args, '--pr')
80
+ const note = getFlagValue(args, '--note')
81
+ const persona = getFlagValue(args, '--persona')
82
+ const outputDirFlag = getFlagValue(args, '--output-dir')
83
+ const commitsFlag = getFlagValue(args, '--commits')
84
+ const dateFlag = getFlagValue(args, '--date')
85
+
86
+ if (!prRaw) {
87
+ return err('release-notes draft: --pr <number> is required')
88
+ }
89
+ const pr = Number.parseInt(prRaw, 10)
90
+ if (!Number.isFinite(pr) || pr <= 0) {
91
+ return err(`release-notes draft: --pr must be a positive integer (got "${prRaw}")`)
92
+ }
93
+ if (!note || note.trim().length === 0) {
94
+ return err('release-notes draft: --note "<text>" is required (and must be non-empty)')
95
+ }
96
+
97
+ const commits = commitsFlag
98
+ ? commitsFlag
99
+ .split(',')
100
+ .map((s) => s.trim())
101
+ .filter((s) => s.length > 0)
102
+ : null
103
+
104
+ const result = writeDraft({
105
+ projectDir,
106
+ pr,
107
+ note,
108
+ persona,
109
+ outputDir: outputDirFlag,
110
+ commits,
111
+ date: dateFlag,
112
+ })
113
+
114
+ return ok({
115
+ status: 'ok',
116
+ command: 'release-notes-draft',
117
+ projectDir,
118
+ pr,
119
+ written: result.written,
120
+ reason: result.reason,
121
+ path: result.path,
122
+ })
123
+ }
124
+
125
+ /**
126
+ * compile — produce the per-tag narrative file from accumulated fragments.
127
+ *
128
+ * @param {string} projectDir
129
+ * @param {string[]} args
130
+ */
131
+ function runCompile(projectDir, args) {
132
+ const tag = getFlagValue(args, '--tag')
133
+ if (!tag) {
134
+ return err('release-notes compile: --tag <tag> is required')
135
+ }
136
+ const outputDirFlag = getFlagValue(args, '--output-dir')
137
+ const result = compileTag({ projectDir, tag, outputDir: outputDirFlag })
138
+ return ok({
139
+ status: 'ok',
140
+ command: 'release-notes-compile',
141
+ projectDir,
142
+ tag,
143
+ written: result.written,
144
+ path: result.path,
145
+ fragments_total: result.fragmentsTotal,
146
+ fragments_in_body: result.fragmentsInBody,
147
+ fragments_in_triage: result.fragmentsInTriage,
148
+ fragments_filtered: result.fragmentsFiltered,
149
+ cleared_unreleased: result.clearedUnreleased,
150
+ })
151
+ }
152
+
153
+ /**
154
+ * publish — shell `gh release create/edit`. Returns a graceful
155
+ * `{status: 'skipped', reason: 'gh-not-found'}` when gh is unavailable.
156
+ *
157
+ * @param {string} projectDir
158
+ * @param {string[]} args
159
+ */
160
+ function runPublish(projectDir, args) {
161
+ const tag = getFlagValue(args, '--tag')
162
+ if (!tag) {
163
+ return err('release-notes publish: --tag <tag> is required')
164
+ }
165
+ const outputDirFlag = getFlagValue(args, '--output-dir')
166
+ const dryRun = args.includes('--dry-run')
167
+ const result = publishTag({ projectDir, tag, outputDir: outputDirFlag, dryRun })
168
+ if (result.status === 'skipped') {
169
+ return ok({
170
+ status: 'skipped',
171
+ command: 'release-notes-publish',
172
+ projectDir,
173
+ tag,
174
+ reason: result.reason,
175
+ notes_path: result.notesPath ?? null,
176
+ })
177
+ }
178
+ return ok({
179
+ status: 'ok',
180
+ command: 'release-notes-publish',
181
+ projectDir,
182
+ tag,
183
+ mode: result.mode,
184
+ dry_run: dryRun,
185
+ notes_path: result.notesPath,
186
+ })
187
+ }
@@ -20,6 +20,43 @@ export function loadProjectConfig(targetDir) {
20
20
  }
21
21
  }
22
22
 
23
+ /**
24
+ * Load `.aperant/config.json` deep-merged with `.aperant/config.local.json`.
25
+ * Local wins on key collision. Nested objects merge key-by-key; arrays and
26
+ * primitives replace wholesale.
27
+ *
28
+ * Returns null if config.json is missing/malformed (same as loadProjectConfig).
29
+ * Throws Error("config.local.json parse error: <msg>") if config.local.json
30
+ * exists but is malformed — this is a user-visible failure surface; silent
31
+ * fallback would mask the user's broken override. See FRAMEWORK-AUDIT-001.
32
+ *
33
+ * @param {string} targetDir
34
+ * @returns {object | null}
35
+ */
36
+ export function loadMergedProjectConfig(targetDir) {
37
+ const shared = loadProjectConfig(targetDir)
38
+ if (shared === null) return null
39
+ const localPath = join(targetDir, '.aperant', 'config.local.json')
40
+ if (!existsSync(localPath)) return shared
41
+ let local
42
+ try {
43
+ local = JSON.parse(readFileSync(localPath, 'utf-8'))
44
+ } catch (e) {
45
+ throw new Error(`config.local.json parse error: ${e.message}`)
46
+ }
47
+ return deepMerge(shared, local)
48
+ }
49
+
50
+ function deepMerge(a, b) {
51
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return b
52
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
53
+ const out = { ...a }
54
+ for (const k of Object.keys(b)) {
55
+ out[k] = k in a ? deepMerge(a[k], b[k]) : b[k]
56
+ }
57
+ return out
58
+ }
59
+
23
60
  /**
24
61
  * Return a normalized task_isolation config object with safe fallbacks.
25
62
  * Always returns a concrete object even when the project has no config at all,
@@ -183,6 +183,12 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
183
183
  lines.push('events/')
184
184
  lines.push('locks/')
185
185
  lines.push('team-status/')
186
+ // config.local.json — per-user override file (FRAMEWORK-AUDIT-001). The
187
+ // deny-by-default `*` rule above already ignores it because no allow line
188
+ // exists for it; this explicit deny is defense in depth so a future
189
+ // maintainer cannot accidentally un-ignore it with a broad `!config*.json`
190
+ // allow rule.
191
+ lines.push('config.local.json')
186
192
  lines.push('')
187
193
  lines.push('# ── Derived markdown indexes (regenerated from JSON) ──────────────')
188
194
  lines.push('tasks/INDEX.md')
@@ -10,7 +10,8 @@
10
10
  * `warning` -> `major`. `suggestion|nitpick|note|info` are dropped.
11
11
  *
12
12
  * Status normalizes to `fixed | deferred | open` (default `open`).
13
- * `resolved: true` is treated as `status: 'fixed'`.
13
+ * Both `fixed: true` and `resolved: true` are treated as `status: 'fixed'`
14
+ * (the reviewer skill writes `fixed: true`; `resolved: true` is legacy).
14
15
  *
15
16
  * Output: {findings: [{id, severity, status, title, file}]}
16
17
  */
@@ -89,6 +90,7 @@ function normalizeSeverity(sev) {
89
90
  }
90
91
 
91
92
  function normalizeStatus(f) {
93
+ if (f.fixed === true) return 'fixed'
92
94
  if (f.resolved === true) return 'fixed'
93
95
  if (typeof f.status === 'string') {
94
96
  const s = f.status.toLowerCase().trim()
@@ -71,6 +71,7 @@ import { cmdPrReview } from './commands/pr-review.mjs'
71
71
  import { cmdPrecompactHook } from './commands/precompact-hook.mjs'
72
72
  import { cmdProductContext } from './commands/product-context.mjs'
73
73
  import { cmdProfile } from './commands/profile.mjs'
74
+ import { cmdReleaseNotes } from './commands/release-notes.mjs'
74
75
  import { cmdRiskCheck } from './commands/risk-check.mjs'
75
76
  import { cmdRoadmap } from './commands/roadmap.mjs'
76
77
  import { cmdRoute } from './commands/route.mjs'
@@ -253,6 +254,7 @@ export const DISPATCH = {
253
254
  'variant-pick': (args) => wrap(cmdVariantPick)(args[1], args[2], args.slice(3)),
254
255
  worktree: (args) => wrap(cmdWorktree)(args[1], args[2], args.slice(3)),
255
256
  audit: (args) => wrap(cmdAudit)(args[1], args[2], args.slice(3)),
257
+ 'release-notes': (args) => wrap(cmdReleaseNotes)(args[1], args[2], args.slice(3)),
256
258
  team: (args) => wrap(cmdTeam)(args[1], args[2], args.slice(3)),
257
259
  active: (args) => wrap(cmdActive)(args[1], args[2], args.slice(3)),
258
260
  roadmap: (args) => wrap(cmdRoadmap)(args[1], args[2], args.slice(3)),
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Skipped when {task_dir}/review.json is absent (review is optional for some
6
6
  * tracks). When it is present, fails if any finding has
7
- * severity ∈ {"critical", "warning"} AND resolved !== true.
7
+ * severity ∈ {"critical", "warning"} AND neither `resolved === true` nor
8
+ * `fixed === true`. The reviewer skill writes resolution as `fixed: true`
9
+ * paired with `fix_commit`; `resolved: true` is a legacy alias.
8
10
  *
9
11
  * The findings array may be under `findings` or `issues` depending on the
10
12
  * reviewer format — we accept either.
@@ -47,7 +49,7 @@ const gate = {
47
49
  ? doc.issues
48
50
  : []
49
51
  const unresolved = findings.filter(
50
- (f) => BLOCKING_SEVERITIES.has(f?.severity) && f?.resolved !== true,
52
+ (f) => BLOCKING_SEVERITIES.has(f?.severity) && f?.resolved !== true && f?.fixed !== true,
51
53
  )
52
54
  if (unresolved.length > 0) {
53
55
  return {