akm-cli 0.9.8-beta.1 → 0.9.8-beta.3

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,105 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.9.8-beta.3] - 2026-09-02
8
+
9
+ ### Fixed
10
+
11
+ - **The incremental index no longer misses an edit whose timestamp did not move
12
+ forward.** The per-directory freshness check summarised a directory as its
13
+ file-name set plus the single newest mtime, which lost two kinds of change.
14
+ An edit to any file other than the newest one landed below that maximum and
15
+ was invisible even though its own mtime changed — so a restore, checkout, or
16
+ archive extraction that stamped a plausible older date left stale content in
17
+ the index. And because mtime is writable by ordinary tooling (`touch -r`,
18
+ `rsync --times`, `cp -p`), an edit with a restored timestamp was invisible
19
+ outright. The directory is now digested per file over
20
+ `(basename, size, mtime, ctime)` at nanosecond resolution. It is the same one
21
+ `stat` call per file, so the incremental fast path costs what it did before.
22
+ Both gaps predate 0.9.8 and applied to every earlier release.
23
+
24
+ Trade-off worth knowing: `ctime` also moves on metadata-only changes such as
25
+ `chmod`, and after copying a tree, so those now cost one extra rescan. That
26
+ direction is deliberate — extra work, never stale content. Existing indexes
27
+ rescan once as the digest changes shape, then return to the fast path.
28
+
29
+ - **`akm migrate apply` can now clear a legacy `extraParams` config.** A config
30
+ still carrying a liftable key such as `extraParams.temperature` fails config
31
+ load closed, and that error names `akm migrate apply` as the fix — but the
32
+ migrate command resolved the stash directory and ran the task migrator, both
33
+ of which load config, so it died on the very error it exists to clear. An
34
+ operator hitting this had no reachable way forward. The config lift now runs
35
+ before anything that loads config, and `akm migrate status` reports the
36
+ pending lift as its blocker instead of re-raising the same error. A genuine
37
+ conflict, where an `extraParams` key and its first-class field disagree, still
38
+ hard-rejects and names both values rather than guessing.
39
+
40
+ - **`akm health` no longer warns about disk usage on a fresh install.** The
41
+ `data-dir-usage` advisory added earlier in 0.9.8 counted SQLite's `-wal` and
42
+ `-shm` sidecars toward the data directory's total but not toward the live
43
+ databases they belong to. On an untouched install the write-ahead log is most
44
+ of the directory, so the very first `akm health` reported a ~126x ratio and
45
+ exited `warn` with no user data present. Sidecars now count as part of their
46
+ database, and the advisory stays quiet below 1 GB, where a ratio says nothing
47
+ useful about disk pressure.
48
+
49
+ ## [0.9.8-beta.2] - 2026-09-02
50
+
51
+ > **Adds state migration `026-proposals-strip-legacy-fragment-refs`.** The
52
+ > one-way caveat below applies to it as well: once this build opens
53
+ > `state.db`, 0.9.8-beta.1 and earlier refuse it with `unknown migration ID
54
+ > 026-proposals-strip-legacy-fragment-refs`.
55
+
56
+ ### Added
57
+
58
+ - **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
59
+ advisory sums the data directory with a stat-only walk and warns when it is
60
+ more than 3× the three live databases (state.db, index.db, logs.db) or when
61
+ one top-level subdirectory holds more than half of it, naming that
62
+ subdirectory with its size and share (for example `backups/ is 70G (94% of
63
+ data dir)`). The walk stops after 100,000 entries and says so. Silent when
64
+ nothing looks wrong.
65
+
66
+ ### Fixed
67
+
68
+ - **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
69
+ The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
70
+ process, so a `task sync --rebind` cycle spawns npm at most once instead of
71
+ twice, and an installation that loops it every minute stops accumulating an
72
+ npm debug log per spawn.
73
+ - **A blocked v2 task now says how to convert it** (#902, #899). The
74
+ `argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
75
+ and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
76
+ conversion is required and name the rewrite (`command:` argv array →
77
+ `run:` string plus `shell:`). The full v2 → v4 field mapping is documented in
78
+ `docs/migration/v0.9.1-to-v0.9.2.md`.
79
+ - **Legacy `#fragment` proposal rows are repaired instead of warned about
80
+ forever** (#898). State migration 026 strips the retired export-fragment
81
+ selector from `proposals.ref` in place so the rows parse again, and an
82
+ unparseable proposal row now warns once per process instead of once per
83
+ read (`akm health --report` read the table seven times).
84
+
85
+ - **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
86
+ Two causes: the per-directory freshness check ran two full scans of the
87
+ `entries` table for every directory (O(directories × entries)), and every
88
+ file was read, hashed, and parsed before the freshness check decided the
89
+ directory was unchanged. The directory lookup now uses the existing
90
+ `file_path` index, and a stat-based gate over each directory's walked file
91
+ set skips unchanged directories before any file is read. On a synthetic
92
+ 800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
93
+ of CPU with identical entries and search results. The persisted directory
94
+ fingerprint now covers every walked file and `index_dir_state` gains a
95
+ `row_count` column; an existing index.db drains each directory once more
96
+ after upgrading, then takes the fast path.
97
+
98
+ - **Task-migration snapshots are capped at the five most recent** (#897).
99
+ `akm migrate apply` writes one snapshot directory per run under
100
+ `backups/task-v3` and `backups/task-v4` and never pruned them; each apply
101
+ now keeps the five newest and removes the rest, the same policy config
102
+ backups already use. Nothing in the current code writes the legacy
103
+ `backups/migrations`, `manual`, `releases`, or `operations` directories,
104
+ so they are left alone; the new health advisory is what surfaces them.
105
+
7
106
  ## [0.9.8-beta.1] - 2026-09-01
8
107
 
9
108
  A cleanup and stabilization release: deletion of machinery that policed the
@@ -11,10 +110,45 @@ codebase's shape rather than its behaviour, and — because auditing for that
11
110
  machinery meant reading the code closely — a run of real defects it had been
12
111
  sitting on top of.
13
112
 
113
+ > **Upgrading is one-way for `state.db`.** This release adds migration
114
+ > `025-task-history-vocabulary-backfill`. Once any 0.9.8 command opens
115
+ > `state.db`, the ledger contains an ID that 0.9.7 does not know, and 0.9.7
116
+ > refuses to open it: `Refusing to open a database with a newer migration
117
+ > ledger: unknown migration ID 025-task-history-vocabulary-backfill`.
118
+ >
119
+ > The refusal is deliberate — an older binary must not write a database whose
120
+ > schema it cannot reason about — but the practical effect is that
121
+ > **downgrading to 0.9.7 requires restoring a `state.db` backup.** Commands
122
+ > that only read the derived index (`akm info`, `akm search`) keep working on
123
+ > 0.9.7; everything that touches `state.db` (`akm health`, `akm task`,
124
+ > `akm improve`, proposals) does not.
125
+ >
126
+ > Snapshot `state.db` before upgrading if you may need to go back:
127
+ >
128
+ > ```sh
129
+ > akm info --format json # confirm your data dir
130
+ > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO '''state.db.pre-0.9.8.bak'''"
131
+ > ```
132
+
14
133
  Two security holes, two search-correctness bugs, a locale-dependent hash, a
15
134
  deletion shield that failed open, and sixteen places that answered a failure
16
135
  with a confident wrong answer instead of an error.
17
136
 
137
+ ### Changed
138
+
139
+ - **`akm workflow plan` returns JSON by default**, like every other command
140
+ (#903). It was the one verb whose unmarked default was a human summary. That
141
+ exception cost a bespoke branch which could not reliably distinguish "no
142
+ format named" from "`--format json` named globally before the subcommand" —
143
+ citty parses each command level against its own argv, so the leaf read
144
+ `undefined` in both cases. Working around that meant reading the invocation
145
+ singleton, folding in a persisted `output.format`, and finally leaving a
146
+ resolved `"json"` on the text branch because it was indistinguishable from
147
+ "nothing configured" — which meant an explicit `--format json` silently did
148
+ nothing for anyone whose config already resolved to json. Deleted, along with
149
+ ~60 lines of comment justifying it. `--format text` still renders the same
150
+ summary through the same formatter; it is simply no longer the default.
151
+
18
152
  ### Fixed
19
153
 
20
154
  - **Historical state migrations are reachable where akm cannot reinstall
@@ -0,0 +1,182 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `data-dir-usage` advisory for `akm health` (#896).
6
+ *
7
+ * A real environment's `$XDG_DATA_HOME/akm` grew to 74 GB with none of the
8
+ * ~20 health checks saying a word about disk. 70 of the 74 GB turned out to
9
+ * be `backups/` (unpruned migration snapshots, see #897); the live working
10
+ * set (state.db + index.db + logs.db) was ~4.2 GB. Naming the largest
11
+ * top-level contributor and its share of the total is most of the value —
12
+ * `backups/ is 70G (94% of data dir)` is self-diagnosing where "akm health
13
+ * says nothing" is not.
14
+ *
15
+ * Best-effort and read-only: a plain recursive `fs` stat walk over the data
16
+ * dir, no `du` shell-out. Silent (returns `undefined`) whenever nothing
17
+ * looks wrong, matching the stash-exposure/type-directory-check house
18
+ * pattern — this is not a "always show a pass line" check.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ /**
23
+ * Warn when the data dir's total size is more than this many times the
24
+ * combined size of the three live databases (state.db + index.db +
25
+ * logs.db). Chosen so a healthy install (backups roughly comparable to the
26
+ * live working set) stays quiet, while an order-of-magnitude blowup like
27
+ * the 74 GB/4.2 GB (~17x) incident trips it.
28
+ */
29
+ const DATA_DIR_BLOAT_RATIO_THRESHOLD = 3;
30
+ /**
31
+ * Warn when a single top-level subdirectory accounts for more than this
32
+ * percentage of the data dir's total size — the "one thing ate the disk"
33
+ * signal (94% for `backups/` in the incident).
34
+ */
35
+ const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50;
36
+ /**
37
+ * Cap on the number of filesystem entries the recursive size walk will
38
+ * `stat`. A 70 GB tree of a few thousand backup copies is cheap to walk
39
+ * (stat-only), but a data dir polluted with hundreds of thousands of small
40
+ * files (task logs, npm logs) must not make `akm health` slow. Past this
41
+ * cap the walk stops descending further and the advisory says its size
42
+ * figures are a lower bound.
43
+ */
44
+ const MAX_WALK_ENTRIES = 100_000;
45
+ /**
46
+ * Below this the data dir is not worth an opinion. The advisory exists for
47
+ * disk blowups (74 GB in the incident); on a small directory a ratio is
48
+ * arithmetic noise, and akm's own housekeeping can dominate it outright.
49
+ */
50
+ const MIN_TOTAL_BYTES_TO_REPORT = 1_000_000_000;
51
+ const LIVE_DB_FILES = ["state.db", "index.db", "logs.db"];
52
+ /**
53
+ * SQLite writes `-wal` and `-shm` beside each database. They are part of the
54
+ * live working set, not overhead sitting next to it, so they must count as
55
+ * live: on a fresh install the WAL is most of the data dir, and leaving it
56
+ * out of the denominator made an empty install report a ~126x ratio.
57
+ */
58
+ function liveDbBytesFor(name, sizes) {
59
+ return ((sizes.get(name)?.bytes ?? 0) + (sizes.get(`${name}-wal`)?.bytes ?? 0) + (sizes.get(`${name}-shm`)?.bytes ?? 0));
60
+ }
61
+ /**
62
+ * Recursively sum file sizes under `root` (stat-only, symlinks not
63
+ * followed so a cyclic or huge-target symlink can't blow up the walk).
64
+ * `budget` is a shared mutable counter across the whole tree so the
65
+ * `MAX_WALK_ENTRIES` cap applies to the walk as a whole, not per-branch.
66
+ */
67
+ function sizeOfPath(root, budget) {
68
+ let stat;
69
+ try {
70
+ stat = fs.lstatSync(root);
71
+ }
72
+ catch {
73
+ return { bytes: 0, truncated: false };
74
+ }
75
+ if (stat.isSymbolicLink())
76
+ return { bytes: 0, truncated: false };
77
+ if (!stat.isDirectory())
78
+ return { bytes: stat.size, truncated: false };
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(root, { withFileTypes: true });
82
+ }
83
+ catch {
84
+ return { bytes: 0, truncated: false };
85
+ }
86
+ let bytes = 0;
87
+ let truncated = false;
88
+ for (const entry of entries) {
89
+ if (budget.remaining <= 0) {
90
+ truncated = true;
91
+ break;
92
+ }
93
+ budget.remaining--;
94
+ const sub = sizeOfPath(path.join(root, entry.name), budget);
95
+ bytes += sub.bytes;
96
+ if (sub.truncated)
97
+ truncated = true;
98
+ }
99
+ return { bytes, truncated };
100
+ }
101
+ /** `1610612736` -> `"1.5G"`. Values under 10 in a unit keep one decimal; 10+ round to an integer. */
102
+ function formatBytes(bytes) {
103
+ const units = ["B", "K", "M", "G", "T"];
104
+ let value = bytes;
105
+ let unit = 0;
106
+ while (value >= 1024 && unit < units.length - 1) {
107
+ value /= 1024;
108
+ unit++;
109
+ }
110
+ const rendered = unit === 0 ? String(Math.round(value)) : value < 10 ? value.toFixed(1) : String(Math.round(value));
111
+ return `${rendered}${units[unit]}`;
112
+ }
113
+ /**
114
+ * Build the `data-dir-usage` advisory, or `undefined` when the data dir is
115
+ * missing/empty/unreadable or its size looks unremarkable (neither
116
+ * threshold trips). `dataDir` is the caller-resolved `getDataDir()` path —
117
+ * this module never resolves paths or reads env itself.
118
+ */
119
+ export function collectDataDirUsageAdvisory(dataDir) {
120
+ let topEntries;
121
+ try {
122
+ topEntries = fs.readdirSync(dataDir, { withFileTypes: true });
123
+ }
124
+ catch {
125
+ return undefined; // no data dir yet — nothing to report.
126
+ }
127
+ const budget = { remaining: MAX_WALK_ENTRIES };
128
+ let totalBytes = 0;
129
+ let truncated = false;
130
+ const sizes = new Map();
131
+ for (const entry of topEntries) {
132
+ const size = sizeOfPath(path.join(dataDir, entry.name), budget);
133
+ totalBytes += size.bytes;
134
+ if (size.truncated)
135
+ truncated = true;
136
+ sizes.set(entry.name, { bytes: size.bytes, isDirectory: entry.isDirectory() });
137
+ }
138
+ if (totalBytes === 0)
139
+ return undefined;
140
+ const subdirs = [...sizes]
141
+ .filter(([, size]) => size.isDirectory)
142
+ .map(([name, size]) => ({ name, bytes: size.bytes, percent: (size.bytes / totalBytes) * 100 }))
143
+ .sort((a, b) => b.bytes - a.bytes);
144
+ const largest = subdirs[0];
145
+ const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, liveDbBytesFor(f, sizes)]));
146
+ const liveDbBytes = Object.values(liveDbBreakdown).reduce((a, b) => a + b, 0);
147
+ const ratio = liveDbBytes > 0 ? totalBytes / liveDbBytes : undefined;
148
+ if (totalBytes < MIN_TOTAL_BYTES_TO_REPORT)
149
+ return undefined;
150
+ const bloatWarn = ratio !== undefined && ratio > DATA_DIR_BLOAT_RATIO_THRESHOLD;
151
+ const dominantWarn = largest !== undefined && largest.percent > DOMINANT_SUBDIR_PERCENT_THRESHOLD;
152
+ if (!bloatWarn && !dominantWarn)
153
+ return undefined;
154
+ const parts = [`data dir is ${formatBytes(totalBytes)} at ${dataDir}`];
155
+ if (largest) {
156
+ parts.push(`${largest.name}/ is ${formatBytes(largest.bytes)} (${Math.round(largest.percent)}% of data dir)`);
157
+ }
158
+ if (ratio !== undefined) {
159
+ parts.push(`live databases (${LIVE_DB_FILES.join("+")}) total ${formatBytes(liveDbBytes)}, ~${ratio.toFixed(1)}x smaller`);
160
+ }
161
+ if (truncated) {
162
+ parts.push(`size figures are a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries`);
163
+ }
164
+ const message = `${parts.join("; ")}.`;
165
+ return {
166
+ name: "data-dir-usage",
167
+ kind: "deterministic",
168
+ status: "warn",
169
+ confidence: "medium",
170
+ message,
171
+ evidence: {
172
+ dataDir,
173
+ totalBytes,
174
+ liveDbBytes,
175
+ liveDbBreakdown,
176
+ largestSubdir: largest,
177
+ ratio,
178
+ walkBounded: truncated,
179
+ maxWalkEntries: MAX_WALK_ENTRIES,
180
+ },
181
+ };
182
+ }
@@ -9,7 +9,7 @@ import { ConfigError, UsageError } from "../core/errors.js";
9
9
  import { readEvents } from "../core/events.js";
10
10
  import { openLogsDatabase } from "../core/logs-db.js";
11
11
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
12
- import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
12
+ import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
13
13
  import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
14
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
15
15
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
@@ -18,6 +18,7 @@ import { queryTaskHistory } from "../storage/repositories/task-history-repositor
18
18
  import { pkgVersion } from "../version.js";
19
19
  import { collectImproveAdvisories } from "./health/advisories.js";
20
20
  import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
21
+ import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
21
22
  import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
22
23
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
23
24
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
@@ -197,9 +198,10 @@ function gatherImproveSummaryPhase(db, stateDbPath, since, now) {
197
198
  return { improveSummary, perRunSummaries };
198
199
  }
199
200
  /**
200
- * The four best-effort advisory groups beyond the health-check registry:
201
- * improve advisories, the `stash-git-exposure` probe, the 08 surfaces group
202
- * (binary-config-skew, egress-endpoints), and `plugin-version` (itlackey/akm#832).
201
+ * The best-effort advisory groups beyond the health-check registry: improve
202
+ * advisories, the `stash-git-exposure` probe, the 08 surfaces group
203
+ * (binary-config-skew, egress-endpoints), `type-directory-disagreement`
204
+ * (#831), `data-dir-usage` (#896), and `plugin-version` (itlackey/akm#832).
203
205
  * Order matches emission order in the returned array. A probe/filesystem
204
206
  * failure in any try/catch must not abort the health report — each group
205
207
  * degrades to "no advisory" independently.
@@ -251,6 +253,19 @@ function gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, optio
251
253
  catch {
252
254
  // Non-fatal.
253
255
  }
256
+ // #896: report the data dir's total size and its largest top-level
257
+ // subdirectory, so a disk-usage blowup (e.g. unpruned migration snapshot
258
+ // backups, #897) is self-diagnosing instead of requiring `du` archaeology.
259
+ // Best-effort — an unreadable/missing data dir must not abort the health
260
+ // report.
261
+ try {
262
+ const dataDirUsage = collectDataDirUsageAdvisory(getDataDir());
263
+ if (dataDirUsage)
264
+ advisories.push(dataDirUsage);
265
+ }
266
+ catch {
267
+ // Non-fatal.
268
+ }
254
269
  // itlackey/akm#832: report installed Claude Code harness plugin version(s)
255
270
  // and warn when stale or when the plugin's own akm-cli version range no
256
271
  // longer admits this CLI. Best-effort — no plugin installed, an unreadable
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
5
5
  import { resolveStashDir } from "../core/common.js";
6
+ import { resetConfigCache } from "../core/config/config.js";
6
7
  import { ConfigError } from "../core/errors.js";
7
8
  import { getConfigPath } from "../core/paths.js";
8
9
  import { applyConfigExtraParamsLift, findConfigExtraParamsLift } from "./migrate/config-extra-params.js";
@@ -94,6 +95,29 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
94
95
  // No configured bundle means there is no stash to scan — an empty domain,
95
96
  // not an error — so migrate still works before `akm bundle create`. Any
96
97
  // OTHER ConfigError propagates.
98
+ const configPath = getConfigPath();
99
+ const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
100
+ // The config lift runs BEFORE anything that loads config. A config still
101
+ // carrying legacy extraParams keys fails `loadConfig` closed, and the error
102
+ // it fails with names `akm migrate apply` as the remedy -- but both
103
+ // `resolveStashDir` below and the task migrator itself load config, so that
104
+ // remedy could never reach the lift that fixes it. Applying it first is what
105
+ // makes the advice true. `resetConfigCache` so every load below sees the
106
+ // rewritten file rather than the rejected one.
107
+ const configExtraParams = applyResidue
108
+ ? applyConfigExtraParamsLift(configPath)
109
+ : { pending: findConfigExtraParamsLift(configPath) };
110
+ if (applyResidue && configExtraParams.applied)
111
+ resetConfigCache();
112
+ // status and --dry-run cannot rewrite the file, so a pending lift still
113
+ // blocks every config load below. Report it as the blocker rather than
114
+ // letting the operator hit the same circular error again.
115
+ const pendingLift = applyResidue ? undefined : configExtraParams.pending;
116
+ if (pendingLift && pendingLift.lifted.length > 0) {
117
+ output(command, { status: "blocked", blockers: pendingLift.lifted, configExtraParams });
118
+ process.exitCode = EXIT_CODES.GENERAL;
119
+ return;
120
+ }
97
121
  let stashDir;
98
122
  try {
99
123
  stashDir = resolveStashDir();
@@ -102,8 +126,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
102
126
  if (!(error instanceof ConfigError) || error.code !== "STASH_DIR_NOT_FOUND")
103
127
  throw error;
104
128
  }
105
- const configPath = getConfigPath();
106
- const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
107
129
  const first = await callMigrateTool(genOneArgs, runTool);
108
130
  if (first.status !== EXIT_CODES.SUCCESS && first.status !== EXIT_CODES.GENERAL) {
109
131
  process.exitCode = first.status;
@@ -133,9 +155,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
133
155
  ? { recovered: await recoverStaleTxns(stashDir) }
134
156
  : { pending: findStaleTxnEntries(stashDir) },
135
157
  };
136
- const configExtraParams = applyResidue
137
- ? applyConfigExtraParamsLift(configPath)
138
- : { pending: findConfigExtraParamsLift(configPath) };
139
158
  output(command, { ...combined, ...stashSections, configExtraParams });
140
159
  if (combined.status === "blocked")
141
160
  process.exitCode = EXIT_CODES.GENERAL;
@@ -7,18 +7,12 @@
7
7
  * `create --print` emits Markdown; execution accepts peer `.md` and
8
8
  * GitHub-shaped `.yml` workflow sources. Validate with `akm lint --type workflows`.
9
9
  */
10
- import { getParsedInvocation } from "../cli/invocation.js";
11
10
  import { getStringArg } from "../cli/parse-args.js";
12
11
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
13
12
  import { armAbortDeadline } from "../core/abort-deadline.js";
14
13
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
15
14
  import { NotFoundError, UsageError } from "../core/errors.js";
16
15
  import { akmIndex } from "../indexer/indexer.js";
17
- import { getOutputMode } from "../output/context.js";
18
- import { renderGenericText } from "../output/generic-render.js";
19
- import { deliverRendered } from "../output/html-render.js";
20
- import { shapeForCommand } from "../output/shapes.js";
21
- import { formatPlain } from "../output/text.js";
22
16
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
23
17
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
24
18
  import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
@@ -319,61 +313,17 @@ const workflowPlanCommand = defineJsonCommand({
319
313
  },
320
314
  async run({ args }) {
321
315
  const result = await akmWorkflowPlan(args.ref);
322
- // B-46/B-57: `akm workflow plan` is read-only introspection whose UNMARKED
323
- // default is a human summary `--format json` (B-N9) is the opt-in for
324
- // the full structure, the mirror image of every other verb's json-by-
325
- // default (DEFAULT_CONFIG.output.format).
326
- //
327
- // Detecting "the caller named no format at all" MUST NOT read
328
- // `args.format` (code-review round 4, finding 3 / Review log R3):
329
- // citty's one-parse rule (GLOBAL_OUTPUT_ARGS's own doc comment, "no
330
- // command body may read these args") isn't just style here — reading it
331
- // is actively wrong. citty parses each command level against only that
332
- // level's own remaining argv, so a GLOBAL, pre-subcommand `--format json`
333
- // (e.g. `akm --format json workflow plan <ref>`) is consumed by the ROOT
334
- // command's own declared `format` arg before the `workflow`/`plan`
335
- // subcommand tokens are even resolved — this LEAF's `args.format` reads
336
- // `undefined` in exactly that case too, indistinguishable from "no
337
- // format was named anywhere". Reproduced live: that invocation printed
338
- // the human TEXT summary at exit 0 even though `getOutputMode().format`
339
- // was already `"json"` (the control, `akm --format json workflow list`,
340
- // correctly emitted JSON — only this leaf's own arg-read was wrong).
341
- // Detect it instead off the process-wide invocation singleton
342
- // (`getParsedInvocation`, src/cli/invocation.ts) — the same canonical,
343
- // position-independent argv parse `src/cli.ts` mints ONCE at startup
344
- // (`setParsedInvocation`, immediately before `initOutputMode` builds the
345
- // `getOutputMode()` singleton from that identical argv), so this agrees
346
- // with `getOutputMode()` regardless of where `--format` appeared. A bare
347
- // `process.argv` read is reserved for `src/cli.ts`/`cli/invocation.ts`
348
- // themselves (`lint-process-argv.ts`); every other module reads through
349
- // this singleton instead. When explicit, this defers to the normal
350
- // `output()` path (json/yaml/text/md/html/jsonl, `--output <path>`)
351
- // unchanged; when absent, it reproduces `output()`'s OWN "text" branch
352
- // verbatim (same shape/detail projection, same registered-formatter-or-
353
- // generic-fallback, same `--output <path>` handling) without touching
354
- // the shared dispatcher other commands rely on.
355
- //
356
- // "No format named anywhere" also has to check the RESOLVED mode, not
357
- // just argv: `getOutputMode().format` already folds a persisted
358
- // `output.format` config default in ahead of the hardcoded "json"
359
- // fallback (`resolveOutputMode`, src/output/context.ts — argv ?? config
360
- // default ?? "json"). A user who has configured e.g. `output.format:
361
- // "yaml"` gets yaml from every other command with no `--format` on the
362
- // line; `workflow plan` must honor that too instead of forcing its
363
- // human-text branch over a real persisted default. A resolved format of
364
- // exactly "json" is deliberately left on the text branch below: it's
365
- // indistinguishable from "nothing configured" (DEFAULT_CONFIG.output.format
366
- // is also "json" — OutputConfigSchema's `format` carries no independent
367
- // zod default, so the merge is the only source), and collapsing that case
368
- // onto the JSON envelope would erase the documented unmarked-default text
369
- // summary for the overwhelmingly common "user configured nothing" case.
370
- const mode = getOutputMode();
371
- if (getParsedInvocation().getFlagValue("--format") === undefined && mode.format === "json") {
372
- const shaped = shapeForCommand("workflow-plan", result, mode.detail, mode.shape);
373
- const plain = formatPlain("workflow-plan", shaped, mode.detail);
374
- deliverRendered(plain ?? renderGenericText("workflow-plan", shaped), mode.outputPath);
375
- return;
376
- }
316
+ // json-by-default, like every other verb (#903). This used to default to
317
+ // the human summary, which cost ~60 lines of branch: `args.format` cannot
318
+ // detect "no format named" (citty parses per level, so a global
319
+ // pre-subcommand `--format json` is eaten by the ROOT command and the leaf
320
+ // reads undefined), so it had to route through `getParsedInvocation()`,
321
+ // then fold in a persisted `output.format`, and then still leave a
322
+ // resolved "json" on the text branch because that is indistinguishable
323
+ // from "nothing configured". The last compromise meant an explicit
324
+ // `--format json` silently did nothing for anyone whose config already
325
+ // resolved to json. `--format text` still renders the summary through the
326
+ // registered formatter; it is just no longer the unmarked default.
377
327
  output("workflow-plan", result);
378
328
  },
379
329
  });
@@ -115,17 +115,30 @@ export function backupExistingConfig(configPath, now = new Date()) {
115
115
  return { timestamped, latest };
116
116
  }
117
117
  function pruneOldBackups(backupDir) {
118
+ pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
119
+ }
120
+ function isTimestampedConfigBackup(name) {
121
+ return name.startsWith("config-") && name.endsWith(".json") && name !== "config.latest.json";
122
+ }
123
+ /**
124
+ * Keep the `keep` most-recently-modified entries of `dir` that `select`
125
+ * admits and remove the rest. Best-effort: an unreadable dir is a no-op, an
126
+ * unreadable entry sorts oldest (pruned first), and a failed removal is
127
+ * retried by the next call. Shared by the config backups above and the task
128
+ * migration snapshots (#897).
129
+ */
130
+ export function pruneToNewest(dir, keep, select) {
118
131
  let entries;
119
132
  try {
120
- entries = fs.readdirSync(backupDir);
133
+ entries = fs.readdirSync(dir, { withFileTypes: true });
121
134
  }
122
135
  catch {
123
136
  return;
124
137
  }
125
- const timestamped = entries
126
- .filter((n) => n.startsWith("config-") && n.endsWith(".json") && n !== "config.latest.json")
127
- .map((name) => {
128
- const full = path.join(backupDir, name);
138
+ const candidates = entries
139
+ .filter(select)
140
+ .map((entry) => {
141
+ const full = path.join(dir, entry.name);
129
142
  let mtime = 0;
130
143
  try {
131
144
  mtime = fs.statSync(full).mtimeMs;
@@ -136,12 +149,12 @@ function pruneOldBackups(backupDir) {
136
149
  return { path: full, mtime };
137
150
  })
138
151
  .sort((a, b) => b.mtime - a.mtime);
139
- for (const stale of timestamped.slice(MAX_CONFIG_BACKUPS)) {
152
+ for (const stale of candidates.slice(keep)) {
140
153
  try {
141
- fs.unlinkSync(stale.path);
154
+ fs.rmSync(stale.path, { recursive: true, force: true });
142
155
  }
143
156
  catch {
144
- // Best-effort prune; next save will retry.
157
+ // Best-effort prune; the next call will retry.
145
158
  }
146
159
  }
147
160
  }
@@ -36,6 +36,7 @@ export const STATE_MIGRATION_SAFETY_BY_ID = Object.freeze({
36
36
  "023-child-workflow-runs": "additive",
37
37
  "024-workflow-run-outputs": "additive",
38
38
  "025-task-history-vocabulary-backfill": "data-preserving-rebuild",
39
+ "026-proposals-strip-legacy-fragment-refs": "data-preserving-rebuild",
39
40
  });
40
41
  export const STATE_MIGRATIONS = [
41
42
  // ── Migration 001 — initial schema ──────────────────────────────────────────
@@ -1127,6 +1128,27 @@ export const STATE_MIGRATIONS = [
1127
1128
  AND json_extract(metadata_json, '$.targetVocab') IS NULL;
1128
1129
  `,
1129
1130
  },
1131
+ // ── Migration 026 — strip the retired proposal-ref export fragment (#898) ──
1132
+ //
1133
+ // Older releases could write a proposal `ref` carrying the export-fragment
1134
+ // selector (`[bundle//]conceptId#fragment`, see src/core/asset/asset-ref.ts).
1135
+ // `currentProposalRef` (proposals-repository.ts) now rejects any fragment, so
1136
+ // those rows failed to parse on every read and could not be listed, repaired,
1137
+ // or deleted through the CLI. Every surface that still reads an archived row
1138
+ // is concept-scoped, so the fragment carries nothing they use.
1139
+ //
1140
+ // `#` is legal in a ref only as the fragment separator, so truncating at the
1141
+ // first `#` is exactly the split `parseBundleRef` performs. `ref` has no
1142
+ // UNIQUE constraint, and no writer ever produced an empty concept id, so
1143
+ // every legacy row normalizes without a drop path.
1144
+ {
1145
+ id: "026-proposals-strip-legacy-fragment-refs",
1146
+ up: `
1147
+ UPDATE proposals
1148
+ SET ref = substr(ref, 1, instr(ref, '#') - 1)
1149
+ WHERE ref LIKE '%#%';
1150
+ `,
1151
+ },
1130
1152
  ];
1131
1153
  assertMigrationRegistry(STATE_MIGRATIONS);
1132
1154
  function assertStateMigrationSafetyRegistry() {
package/dist/core/warn.js CHANGED
@@ -122,6 +122,21 @@ export function warn(...args) {
122
122
  console.warn(...args);
123
123
  }
124
124
  }
125
+ const warnedOnceKeys = new Set();
126
+ /**
127
+ * `warn()` at most once per process for a given `key`. For diagnostics that
128
+ * would otherwise repeat on every read of the same bad row or value.
129
+ */
130
+ export function warnOnce(key, ...args) {
131
+ if (warnedOnceKeys.has(key))
132
+ return;
133
+ warnedOnceKeys.add(key);
134
+ warn(...args);
135
+ }
136
+ /** TEST-ONLY. Forget every `warnOnce` key so a test can re-trigger a warning. */
137
+ export function _resetWarnOnceForTests() {
138
+ warnedOnceKeys.clear();
139
+ }
125
140
  /**
126
141
  * Emit an error to stderr unless --quiet is active.
127
142
  * Always written to the log file if one is active.