@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
@@ -0,0 +1,244 @@
1
+ /**
2
+ * install/runtime-migrate.mjs — at-install cleanup of legacy install
3
+ * trees the framework no longer owns (FRAMEWORK-BUG-021).
4
+ *
5
+ * Codex was originally installed into `.agents/skills/` (commit 1a8dd8a8)
6
+ * and later moved to `.codex/`. Projects that upgraded across that move
7
+ * end up with a stale `.agents/skills/` tree that:
8
+ *
9
+ * - keeps `.apt-version` on disk → the legacy fallback in
10
+ * `readSkillInstallState` reports a stale codex install.
11
+ * - shows up in `health-check` as `stale-skills`.
12
+ * - leaves dozens of orphaned SKILL.md files the runtime no longer
13
+ * reads.
14
+ *
15
+ * `runLegacyCleanup` runs once per `apt-tools init` when codex is in the
16
+ * requested runtime set. It is conservative by default:
17
+ *
18
+ * - Only files listed in `LEGACY_INSTALL_PATHS.codex.framework_files`
19
+ * are removed. User-added skill directories (`my-skill/`, …) are
20
+ * preserved. If anything beyond the known framework set remains,
21
+ * `retained[]` lists those paths.
22
+ * - A present-and-current `apt-file-manifest.json` is treated as the
23
+ * authoritative "this is a managed install, leave it alone" signal:
24
+ * the function no-ops with `reason: 'managed'`.
25
+ * - The caller can suppress cleanup entirely via `--keep-legacy` at the
26
+ * init.mjs site; the function itself is dumb about flags.
27
+ */
28
+ import { existsSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ import { LEGACY_INSTALL_PATHS } from './legacy-paths.mjs';
31
+ import { MANIFEST_FILENAME } from './manifest.mjs';
32
+ /**
33
+ * @typedef {Object} LegacyCodexDetection
34
+ * @property {boolean} present True when `.agents/skills/` exists.
35
+ * @property {string[]} frameworkFiles Subset of `framework_files` that
36
+ * actually exist on disk (relative
37
+ * to install_root).
38
+ * @property {string[]} userFiles Everything else under
39
+ * install_root (relative to
40
+ * install_root). May include both
41
+ * files and directories. Drives the
42
+ * `retained[]` warning.
43
+ * @property {boolean} hasAptVersion Whether the legacy `.apt-version`
44
+ * stamp is present.
45
+ * @property {boolean} hasManifest Whether an
46
+ * `apt-file-manifest.json` is
47
+ * present at install_root (signals
48
+ * a managed install — cleanup
49
+ * should no-op).
50
+ */
51
+ const LEGACY = LEGACY_INSTALL_PATHS.codex;
52
+ /**
53
+ * Recursively enumerate every file path under `dir`, returning paths
54
+ * relative to `dir`. Used to compute the "user files" set (anything not
55
+ * in the framework_files list).
56
+ *
57
+ * Returns [] when the dir doesn't exist. Swallows per-entry errors so a
58
+ * single unreadable symlink doesn't abort detection.
59
+ *
60
+ * @param {string} dir
61
+ * @returns {string[]}
62
+ */
63
+ function walkRelative(dir) {
64
+ /** @type {string[]} */
65
+ const out = [];
66
+ if (!existsSync(dir))
67
+ return out;
68
+ /** @param {string} current @param {string} rel */
69
+ const visit = (current, rel) => {
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(current, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ const childPath = join(current, entry.name);
79
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
80
+ if (entry.isDirectory()) {
81
+ visit(childPath, childRel);
82
+ }
83
+ else if (entry.isFile()) {
84
+ out.push(childRel);
85
+ }
86
+ // Symlinks / other entry types are intentionally ignored — the
87
+ // framework only ever writes regular files.
88
+ }
89
+ };
90
+ visit(dir, '');
91
+ return out;
92
+ }
93
+ /**
94
+ * Probe the codex legacy install tree at `<targetDir>/.agents/skills/`.
95
+ *
96
+ * @param {string} targetDir
97
+ * @returns {LegacyCodexDetection}
98
+ */
99
+ export function detectLegacyCodexTree(targetDir) {
100
+ const installRoot = join(targetDir, LEGACY.install_root);
101
+ if (!existsSync(installRoot)) {
102
+ return {
103
+ present: false,
104
+ frameworkFiles: [],
105
+ userFiles: [],
106
+ hasAptVersion: false,
107
+ hasManifest: false,
108
+ };
109
+ }
110
+ const onDisk = walkRelative(installRoot);
111
+ const frameworkSet = new Set(LEGACY.framework_files);
112
+ const frameworkFiles = onDisk.filter((p) => frameworkSet.has(p));
113
+ const userFiles = onDisk.filter((p) => !frameworkSet.has(p) && p !== MANIFEST_FILENAME);
114
+ return {
115
+ present: true,
116
+ frameworkFiles,
117
+ userFiles,
118
+ hasAptVersion: onDisk.includes('.apt-version'),
119
+ hasManifest: onDisk.includes(MANIFEST_FILENAME),
120
+ };
121
+ }
122
+ /**
123
+ * Try to remove a directory if it is empty. Swallows ENOTEMPTY / ENOENT
124
+ * so the caller can blindly sweep candidate dirs in any order.
125
+ *
126
+ * @param {string} dir
127
+ * @returns {boolean} true iff the dir was actually removed.
128
+ */
129
+ function rmEmptyDir(dir) {
130
+ if (!existsSync(dir))
131
+ return false;
132
+ try {
133
+ rmdirSync(dir);
134
+ return true;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ /**
141
+ * Clean up the legacy `.agents/skills/` tree if it looks orphaned.
142
+ *
143
+ * Branches:
144
+ * 1. `present: false` OR `frameworkFiles.length === 0` → no-op,
145
+ * `{ ran: false, reason: 'no-legacy' }`.
146
+ * 2. `hasManifest: true` → managed install, no-op,
147
+ * `{ ran: false, reason: 'managed' }`.
148
+ * 3. Otherwise: per-file `unlinkSync` each framework-owned file;
149
+ * `rmdirSync` any now-empty `apt-*` subdirectory; sweep
150
+ * `.agents/skills/` only if it is empty; sweep `.agents/` only if
151
+ * its `skills/` got removed and `.agents/` is empty.
152
+ *
153
+ * Returns `{ ran: true, removed, retained, rmdirs }` on the cleanup
154
+ * branch:
155
+ * - `removed` — relative paths under `install_root` that were unlinked.
156
+ * - `retained` — relative paths under `install_root` that were NOT
157
+ * removed (user-added skills / files not in the legacy set).
158
+ * - `rmdirs` — absolute paths of directories that were removed
159
+ * (apt-* subdirs, the install_root itself, .agents/), in removal order.
160
+ *
161
+ * Atomicity: per-file unlink is non-atomic. A partial-failure scenario
162
+ * (process killed mid-loop) is tolerable — the next `init` run will
163
+ * re-detect the remaining framework files and finish the sweep.
164
+ *
165
+ * @param {{ targetDir: string, dryRun?: boolean }} args
166
+ * @returns {
167
+ * | { ran: false, reason: 'no-legacy' | 'managed' }
168
+ * | { ran: true, removed: string[], retained: string[], rmdirs: string[] }
169
+ * }
170
+ */
171
+ export function runLegacyCleanup({ targetDir, dryRun = false }) {
172
+ const detection = detectLegacyCodexTree(targetDir);
173
+ if (!detection.present || detection.frameworkFiles.length === 0) {
174
+ return { ran: false, reason: 'no-legacy' };
175
+ }
176
+ if (detection.hasManifest) {
177
+ return { ran: false, reason: 'managed' };
178
+ }
179
+ const installRoot = join(targetDir, LEGACY.install_root);
180
+ const removed = [];
181
+ const rmdirs = [];
182
+ if (dryRun) {
183
+ // Dry-run: report what WOULD be removed without touching the filesystem.
184
+ // `retained` is the user-file set from the pre-walk (no sweep occurred).
185
+ // `rmdirs` is the set of apt-* subdirs that would become empty after removal.
186
+ const wouldRemove = detection.frameworkFiles;
187
+ const retained = detection.userFiles;
188
+ const candidateDirs = new Set();
189
+ for (const rel of wouldRemove) {
190
+ let parent = dirname(rel);
191
+ while (parent !== '.' && parent !== '') {
192
+ candidateDirs.add(parent);
193
+ parent = dirname(parent);
194
+ }
195
+ }
196
+ const wouldRmdirs = [...candidateDirs]
197
+ .sort((a, b) => b.length - a.length)
198
+ .map((rel) => join(installRoot, rel));
199
+ return { ran: true, dryRun: true, removed: wouldRemove, retained, rmdirs: wouldRmdirs };
200
+ }
201
+ for (const rel of detection.frameworkFiles) {
202
+ const abs = join(installRoot, rel);
203
+ try {
204
+ unlinkSync(abs);
205
+ removed.push(rel);
206
+ }
207
+ catch {
208
+ /* best-effort: leave a partial sweep; next run finishes it */
209
+ }
210
+ }
211
+ // Sweep any now-empty `apt-*/` subdirs the unlinks left behind.
212
+ // Walk parent dirs of every removed file in reverse-depth order so
213
+ // nested `apt-pr-review/prompts/` empties before `apt-pr-review/`.
214
+ const candidateDirs = new Set();
215
+ for (const rel of removed) {
216
+ let parent = dirname(rel);
217
+ while (parent !== '.' && parent !== '') {
218
+ candidateDirs.add(parent);
219
+ parent = dirname(parent);
220
+ }
221
+ }
222
+ const sortedDirs = [...candidateDirs].sort((a, b) => b.length - a.length);
223
+ for (const rel of sortedDirs) {
224
+ const abs = join(installRoot, rel);
225
+ if (rmEmptyDir(abs))
226
+ rmdirs.push(abs);
227
+ }
228
+ // Sweep `.agents/skills/` only if fully empty after the per-file
229
+ // pass — preserves any user-added skill dirs.
230
+ if (rmEmptyDir(installRoot)) {
231
+ rmdirs.push(installRoot);
232
+ // Sweep `.agents/` only if it became empty too (i.e. no other
233
+ // `.agents/<sibling>` content the user keeps there).
234
+ const agentsRoot = dirname(installRoot);
235
+ if (rmEmptyDir(agentsRoot))
236
+ rmdirs.push(agentsRoot);
237
+ }
238
+ // Use the pre-walk's userFiles as `retained` — the post-walk only existed
239
+ // to defensively re-scan, but pre-walk already captured user files accurately.
240
+ // (PRF-002: avoid redundant detectLegacyCodexTree call.)
241
+ const retained = detection.userFiles;
242
+ return { ran: true, removed, retained, rmdirs };
243
+ }
244
+ //# sourceMappingURL=runtime-migrate.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-migrate.mjs","sourceRoot":"","sources":["../../../src/cli/install/runtime-migrate.mjs"],"names":[],"mappings":"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"}
@@ -21,6 +21,29 @@ export function updateCachePath(): string;
21
21
  * @returns {UpdateCache|null}
22
22
  */
23
23
  export function readUpdateCache(): UpdateCache | null;
24
+ /**
25
+ * Read the worker's cache file with TTL and schema validation. Returns
26
+ * `{ fresh: true, cache }` when the cache exists, parses, declares
27
+ * `schema_version === 1`, and was written within `ttlMs` milliseconds.
28
+ * Returns `{ fresh: false }` when missing, stale, or schema mismatch.
29
+ *
30
+ * Security: `latest_version` and `installed_version` inside
31
+ * `cache.framework` are validated as semver strings. Invalid values are
32
+ * dropped (set to null) so they never propagate into shell-interpolated
33
+ * contexts.
34
+ *
35
+ * ENOENT and JSON parse errors stay silent (expected when the cache is
36
+ * missing or being rewritten). Unexpected errors are logged to stderr.
37
+ *
38
+ * @param {number} ttlMs Maximum age in milliseconds before cache is treated as stale.
39
+ * @returns {{ fresh: true, cache: Record<string, unknown> } | { fresh: false }}
40
+ */
41
+ export function readFreshUpdateCache(ttlMs: number): {
42
+ fresh: true;
43
+ cache: Record<string, unknown>;
44
+ } | {
45
+ fresh: false;
46
+ };
24
47
  /**
25
48
  * Build the chip string array for a given cache. Empty array when there
26
49
  * are no chips to show. Order: stale_files first (most urgent), then
@@ -1 +1 @@
1
- {"version":3,"file":"update-chips.d.mts","sourceRoot":"","sources":["../../../src/cli/install/update-chips.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,mCAFa,MAAM,CAIlB;AAED;;;;;GAKG;AACH,mCAFa,WAAW,GAAC,IAAI,CAY5B;AAED;;;;;;;;GAQG;AACH,yCAJW,WAAW,GAAC,IAAI,aAChB;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAChB,MAAM,EAAE,CA0BpB;;mBArEa,OAAO;sBACP,OAAO;uBACP,OAAO;uBACP,MAAM;gBACN,MAAM"}
1
+ {"version":3,"file":"update-chips.d.mts","sourceRoot":"","sources":["../../../src/cli/install/update-chips.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,mCAFa,MAAM,CAIlB;AAED;;;;;GAKG;AACH,mCAFa,WAAW,GAAC,IAAI,CAY5B;AAKD;;;;;;;;;;;;;;;;GAgBG;AACH,4CAHW,MAAM,GACJ;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAqC9E;AAED;;;;;;;;GAQG;AACH,yCAJW,WAAW,GAAC,IAAI,aAChB;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAChB,MAAM,EAAE,CA0BpB;;mBA9Ha,OAAO;sBACP,OAAO;uBACP,OAAO;uBACP,MAAM;gBACN,MAAM"}
@@ -57,6 +57,66 @@ export function readUpdateCache() {
57
57
  return null;
58
58
  }
59
59
  }
60
+ /** Semver-shape pattern: MAJOR.MINOR.PATCH with optional pre-release and build metadata. */
61
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
62
+ /**
63
+ * Read the worker's cache file with TTL and schema validation. Returns
64
+ * `{ fresh: true, cache }` when the cache exists, parses, declares
65
+ * `schema_version === 1`, and was written within `ttlMs` milliseconds.
66
+ * Returns `{ fresh: false }` when missing, stale, or schema mismatch.
67
+ *
68
+ * Security: `latest_version` and `installed_version` inside
69
+ * `cache.framework` are validated as semver strings. Invalid values are
70
+ * dropped (set to null) so they never propagate into shell-interpolated
71
+ * contexts.
72
+ *
73
+ * ENOENT and JSON parse errors stay silent (expected when the cache is
74
+ * missing or being rewritten). Unexpected errors are logged to stderr.
75
+ *
76
+ * @param {number} ttlMs Maximum age in milliseconds before cache is treated as stale.
77
+ * @returns {{ fresh: true, cache: Record<string, unknown> } | { fresh: false }}
78
+ */
79
+ export function readFreshUpdateCache(ttlMs) {
80
+ const path = updateCachePath();
81
+ if (!existsSync(path))
82
+ return { fresh: false };
83
+ let data;
84
+ try {
85
+ data = JSON.parse(readFileSync(path, 'utf-8'));
86
+ }
87
+ catch (err) {
88
+ // SyntaxError = malformed JSON (expected); anything else is unexpected.
89
+ if (!(err instanceof SyntaxError)) {
90
+ console.error('[update-cache] read failed:', err.message);
91
+ }
92
+ return { fresh: false };
93
+ }
94
+ try {
95
+ if (!data || typeof data !== 'object')
96
+ return { fresh: false };
97
+ if (data.schema_version !== 1)
98
+ return { fresh: false };
99
+ if (typeof data.checked_at !== 'string')
100
+ return { fresh: false };
101
+ const age = Date.now() - new Date(data.checked_at).getTime();
102
+ if (!Number.isFinite(age) || age < 0 || age >= ttlMs)
103
+ return { fresh: false };
104
+ // Validate semver fields in framework block — reject non-semver strings.
105
+ const fw = data.framework && typeof data.framework === 'object' ? data.framework : {};
106
+ const sanitized = { ...data, framework: { ...fw } };
107
+ for (const field of ['latest_version', 'installed_version']) {
108
+ if (typeof sanitized.framework[field] === 'string' &&
109
+ !SEMVER_RE.test(sanitized.framework[field])) {
110
+ sanitized.framework[field] = null;
111
+ }
112
+ }
113
+ return { fresh: true, cache: sanitized };
114
+ }
115
+ catch (err) {
116
+ console.error('[update-cache] read failed:', err.message);
117
+ return { fresh: false };
118
+ }
119
+ }
60
120
  /**
61
121
  * Build the chip string array for a given cache. Empty array when there
62
122
  * are no chips to show. Order: stale_files first (most urgent), then
@@ -1 +1 @@
1
- {"version":3,"file":"update-chips.mjs","sourceRoot":"","sources":["../../../src/cli/install/update-chips.mjs"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC,MAAM,IAAI,GAAG;IACZ,MAAM,EAAE,UAAU;IAClB,GAAG,EAAE,UAAU;IACf,GAAG,EAAE,SAAS;IACd,KAAK,EAAE,SAAS;CAChB,CAAA;AAED;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC9B,OAAO,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,uBAAuB,CAAC,CAAA;AACrE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC9B,MAAM,IAAI,GAAG,eAAe,EAAE,CAAA;IAC9B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAClC,IAAI,CAAC;QACJ,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAA;QACpD,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAA;QAClD,OAAO,IAAI,CAAA;IACZ,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;AACF,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE;IACjD,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAA;IACrB,MAAM,KAAK,GAAG,EAAE,CAAA;IAChB,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAE3E,IAAI,KAAK,CAAC,iBAAiB,IAAI,KAAK,CAAC,iBAAiB,GAAG,CAAC,EAAE,CAAC;QAC5D,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,kCAAkC,CAAC,CAAC,CAAA;IAC/D,CAAC;IACD,IAAI,KAAK,CAAC,gBAAgB,EAAE,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAA;IAC/C,CAAC;IACD,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;QAC7B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,kCAAkC,CAAC,CAAC,CAAA;IAClE,CAAC;IACD,IAAI,KAAK,CAAC,aAAa,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC,CAAA;IAC5C,CAAC;IACD,0EAA0E;IAC1E,0EAA0E;IAC1E,uDAAuD;IACvD,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACxB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,8BAA8B,CAAC,CAAC,CAAA;IAC3D,CAAC;IACD,OAAO,KAAK,CAAA;AACb,CAAC"}
1
+ {"version":3,"file":"update-chips.mjs","sourceRoot":"","sources":["../../../src/cli/install/update-chips.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @typedef {Object} CompileInput
3
+ * @property {string} projectDir
4
+ * @property {string} tag
5
+ * @property {string | null} outputDir
6
+ */
7
+ /**
8
+ * @typedef {Object} CompileResult
9
+ * @property {boolean} written
10
+ * @property {string} path
11
+ * @property {number} fragmentsTotal
12
+ * @property {number} fragmentsInBody
13
+ * @property {number} fragmentsInTriage
14
+ * @property {number} fragmentsFiltered
15
+ * @property {boolean} clearedUnreleased
16
+ */
17
+ /**
18
+ * Compile every `_unreleased/PR-*.md` fragment into `<output_dir>/<tag>.md`.
19
+ *
20
+ * @param {CompileInput} input
21
+ * @returns {CompileResult}
22
+ */
23
+ export function compileTag(input: CompileInput): CompileResult;
24
+ export type CompileInput = {
25
+ projectDir: string;
26
+ tag: string;
27
+ outputDir: string | null;
28
+ };
29
+ export type CompileResult = {
30
+ written: boolean;
31
+ path: string;
32
+ fragmentsTotal: number;
33
+ fragmentsInBody: number;
34
+ fragmentsInTriage: number;
35
+ fragmentsFiltered: number;
36
+ clearedUnreleased: boolean;
37
+ };
38
+ //# sourceMappingURL=compile.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile.d.mts","sourceRoot":"","sources":["../../../src/cli/release-notes/compile.mjs"],"names":[],"mappings":"AAgFA;;;;;GAKG;AAEH;;;;;;;;;GASG;AAEH;;;;;GAKG;AACH,kCAHW,YAAY,GACV,aAAa,CA4FzB;;gBAhHa,MAAM;SACN,MAAM;eACN,MAAM,GAAG,IAAI;;;aAKb,OAAO;UACP,MAAM;oBACN,MAAM;qBACN,MAAM;uBACN,MAAM;uBACN,MAAM;uBACN,OAAO"}
@@ -0,0 +1,244 @@
1
+ /**
2
+ * release-notes/compile.mjs — tag-time narrative compiler.
3
+ *
4
+ * Reads every `<output_dir>/_unreleased/PR-*.md` fragment, classifies each
5
+ * against the project's persona roster + filter mode, and writes
6
+ * `<output_dir>/<tag>.md`. Atomic-rename pattern: the fragments dir is
7
+ * cleared only AFTER the compiled file lands on disk, so a crash mid-write
8
+ * cannot orphan fragments without their compiled output.
9
+ *
10
+ * Non-negotiable invariant (ID-6): unmapped fragments (no persona tag, or
11
+ * a tag the roster doesn't recognize) MUST appear in a `## Needs Triage`
12
+ * section. Never silent-drop. The triage section is the human curator's
13
+ * safety net — its presence is asserted by TD-3.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { loadProjectConfig } from '../config/load.mjs';
18
+ import { readPersonas } from '../personas/sidecar.mjs';
19
+ import { atomicWriteText } from '../util/atomic-write.mjs';
20
+ import { resolveOutputDir, validateTag } from './output-dir.mjs';
21
+ import { buildPersonaTierMap, classifyFragment, isNoneSentinel } from './persona-filter.mjs';
22
+ const VALID_MODES = new Set(['primary', 'primary+secondary', 'all']);
23
+ /** Pre-compiled at module level to avoid recompilation in the sort comparator (PRF-001). */
24
+ const PR_FILENAME_RE = /^PR-(\d+)\.md$/;
25
+ /**
26
+ * Parse a single fragment file. Tolerant: missing keys default to empty
27
+ * strings; the body after `## Release note` is read verbatim.
28
+ *
29
+ * @param {string} text
30
+ * @returns {{ pr: string, date: string, persona: string, note: string, raw: string }}
31
+ */
32
+ function parseFragment(text) {
33
+ const lines = text.split('\n');
34
+ let pr = '';
35
+ let date = '';
36
+ let persona = '';
37
+ let note = '';
38
+ let noteStart = -1;
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ if (line.startsWith('pr: '))
42
+ pr = line.slice(4).trim();
43
+ else if (line.startsWith('date: '))
44
+ date = line.slice(6).trim();
45
+ else if (line.startsWith('persona: '))
46
+ persona = line.slice(9).trim();
47
+ else if (line.startsWith('## Release note')) {
48
+ noteStart = i;
49
+ break;
50
+ }
51
+ }
52
+ if (noteStart >= 0) {
53
+ note = lines
54
+ .slice(noteStart + 1)
55
+ .join('\n')
56
+ .trim();
57
+ }
58
+ return { pr, date, persona, note, raw: text };
59
+ }
60
+ /**
61
+ * Resolve the output dir and persona filter mode from config + flag override.
62
+ *
63
+ * @param {string} projectDir
64
+ * @param {string | null} flagOverride
65
+ * @returns {{ outputDir: string, personaFilter: 'primary' | 'primary+secondary' | 'all' }}
66
+ */
67
+ function resolveContext(projectDir, flagOverride) {
68
+ const config = loadProjectConfig(projectDir);
69
+ const outputDir = resolveOutputDir(projectDir, flagOverride);
70
+ const filterRaw = config?.changelog?.release_notes?.persona_filter;
71
+ const personaFilter = typeof filterRaw === 'string' && VALID_MODES.has(filterRaw)
72
+ ? /** @type {'primary' | 'primary+secondary' | 'all'} */ (filterRaw)
73
+ : 'primary+secondary';
74
+ return { outputDir, personaFilter };
75
+ }
76
+ /**
77
+ * @typedef {Object} CompileInput
78
+ * @property {string} projectDir
79
+ * @property {string} tag
80
+ * @property {string | null} outputDir
81
+ */
82
+ /**
83
+ * @typedef {Object} CompileResult
84
+ * @property {boolean} written
85
+ * @property {string} path
86
+ * @property {number} fragmentsTotal
87
+ * @property {number} fragmentsInBody
88
+ * @property {number} fragmentsInTriage
89
+ * @property {number} fragmentsFiltered
90
+ * @property {boolean} clearedUnreleased
91
+ */
92
+ /**
93
+ * Compile every `_unreleased/PR-*.md` fragment into `<output_dir>/<tag>.md`.
94
+ *
95
+ * @param {CompileInput} input
96
+ * @returns {CompileResult}
97
+ */
98
+ export function compileTag(input) {
99
+ validateTag(input.tag);
100
+ const { outputDir, personaFilter } = resolveContext(input.projectDir, input.outputDir);
101
+ const fragmentsDir = join(outputDir, '_unreleased');
102
+ const targetPath = join(outputDir, `${input.tag}.md`);
103
+ const rawFiles = existsSync(fragmentsDir)
104
+ ? readdirSync(fragmentsDir).filter((f) => PR_FILENAME_RE.test(f))
105
+ : [];
106
+ // Pre-compute PR numbers once to avoid re-running the regex in the comparator (PRF-001).
107
+ const withNumbers = rawFiles.map((f) => ({
108
+ f,
109
+ n: Number.parseInt(PR_FILENAME_RE.exec(f)?.[1] ?? '0', 10),
110
+ }));
111
+ withNumbers.sort((a, b) => a.n - b.n);
112
+ const fragments = withNumbers.map(({ f }) => ({
113
+ filename: f,
114
+ ...parseFragment(readFileSync(join(fragmentsDir, f), 'utf-8')),
115
+ }));
116
+ const personasEnvelope = readPersonas(input.projectDir);
117
+ const tierMap = buildPersonaTierMap(personasEnvelope);
118
+ /** @type {Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>} */
119
+ const bodyEntries = [];
120
+ /** @type {Array<{ pr: string, date: string, persona: string, note: string, reason: string }>} */
121
+ const triageEntries = [];
122
+ let filteredCount = 0;
123
+ for (const frag of fragments) {
124
+ const { bucket, resolvedTier } = classifyFragment(frag.persona, tierMap, personaFilter);
125
+ if (bucket === 'body' && resolvedTier !== null && resolvedTier !== 'unknown') {
126
+ bodyEntries.push({
127
+ pr: frag.pr,
128
+ date: frag.date,
129
+ persona: frag.persona,
130
+ note: frag.note,
131
+ tier: resolvedTier,
132
+ });
133
+ }
134
+ else if (bucket === 'triage' || resolvedTier === 'unknown') {
135
+ // Use the shared sentinel predicate so this label stays in lockstep
136
+ // with persona-filter.mjs's NONE_SENTINELS set. Without it, the
137
+ // classifier and labeller drift (e.g. literal "null" persona tags
138
+ // land in triage as 'unknown-persona' instead of 'no-persona-tag').
139
+ // 'unknown' tier (malformed tier value in personas.json) is also
140
+ // routed here — never silent-drop (ID-6 / LOG-001).
141
+ const reason = isNoneSentinel(frag.persona)
142
+ ? 'no-persona-tag'
143
+ : resolvedTier === 'unknown'
144
+ ? 'unknown-tier'
145
+ : 'unknown-persona';
146
+ triageEntries.push({
147
+ pr: frag.pr,
148
+ date: frag.date,
149
+ persona: frag.persona,
150
+ note: frag.note,
151
+ reason,
152
+ });
153
+ }
154
+ else {
155
+ filteredCount++;
156
+ }
157
+ }
158
+ const compiled = renderCompiled({
159
+ tag: input.tag,
160
+ body: bodyEntries,
161
+ triage: triageEntries,
162
+ filter: personaFilter,
163
+ });
164
+ atomicWriteText(targetPath, compiled);
165
+ // Clear _unreleased only AFTER the compiled file is durably written.
166
+ let clearedUnreleased = false;
167
+ if (existsSync(fragmentsDir) && fragments.length > 0) {
168
+ for (const frag of fragments) {
169
+ rmSync(join(fragmentsDir, frag.filename), { force: true });
170
+ }
171
+ clearedUnreleased = true;
172
+ }
173
+ return {
174
+ written: true,
175
+ path: targetPath,
176
+ fragmentsTotal: fragments.length,
177
+ fragmentsInBody: bodyEntries.length,
178
+ fragmentsInTriage: triageEntries.length,
179
+ fragmentsFiltered: filteredCount,
180
+ clearedUnreleased,
181
+ };
182
+ }
183
+ /**
184
+ * Render the per-tag compiled body.
185
+ *
186
+ * Layout:
187
+ * # Release <tag>
188
+ *
189
+ * (per-tier sections — primary first, then secondary, then tertiary
190
+ * when filter==='all'. Each section's entries sorted by PR number.)
191
+ *
192
+ * ## Needs Triage (only when triage[] non-empty)
193
+ *
194
+ * @param {{
195
+ * tag: string,
196
+ * body: Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>,
197
+ * triage: Array<{ pr: string, date: string, persona: string, note: string, reason: string }>,
198
+ * filter: 'primary' | 'primary+secondary' | 'all',
199
+ * }} input
200
+ * @returns {string}
201
+ */
202
+ function renderCompiled(input) {
203
+ const lines = [];
204
+ lines.push(`# Release ${input.tag}`);
205
+ lines.push('');
206
+ lines.push(`<!-- persona_filter: ${input.filter} -->`);
207
+ lines.push('');
208
+ const tiersInOrder = /** @type {const} */ (['primary', 'secondary', 'tertiary']);
209
+ for (const tier of tiersInOrder) {
210
+ const rows = input.body.filter((e) => e.tier === tier);
211
+ if (rows.length === 0)
212
+ continue;
213
+ lines.push(`## ${capitalize(tier)} personas`);
214
+ lines.push('');
215
+ for (const row of rows) {
216
+ lines.push(`- **PR #${row.pr}** (${row.persona}): ${row.note}`);
217
+ }
218
+ lines.push('');
219
+ }
220
+ if (input.triage.length > 0) {
221
+ lines.push('## Needs Triage');
222
+ lines.push('');
223
+ lines.push('_These fragments lack a recognized persona tag. Curate before publishing._');
224
+ lines.push('');
225
+ for (const row of input.triage) {
226
+ const personaLabel = row.persona && row.persona !== '(none)' ? row.persona : '(no tag)';
227
+ lines.push(`- **PR #${row.pr}** (${personaLabel}, ${row.reason}): ${row.note}`);
228
+ }
229
+ lines.push('');
230
+ }
231
+ if (input.body.length === 0 && input.triage.length === 0) {
232
+ lines.push('_No fragments to compile._');
233
+ lines.push('');
234
+ }
235
+ return lines.join('\n');
236
+ }
237
+ /**
238
+ * @param {string} s
239
+ * @returns {string}
240
+ */
241
+ function capitalize(s) {
242
+ return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
243
+ }
244
+ //# sourceMappingURL=compile.mjs.map