akm-cli 0.10.261005-beta → 0.10.261007-beta

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 (55) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/STABILITY.md +9 -0
  3. package/dist/assets/ui/app.js +91 -0
  4. package/dist/assets/ui/asset.js +67 -0
  5. package/dist/assets/ui/bundles.js +80 -0
  6. package/dist/assets/ui/changes.js +32 -0
  7. package/dist/assets/ui/charts.js +93 -0
  8. package/dist/assets/ui/config.js +69 -0
  9. package/dist/assets/ui/diff-view.js +202 -0
  10. package/dist/assets/ui/diff.js +261 -0
  11. package/dist/assets/ui/index.html +30 -0
  12. package/dist/assets/ui/lib.js +238 -0
  13. package/dist/assets/ui/markdown.js +103 -0
  14. package/dist/assets/ui/overview.js +145 -0
  15. package/dist/assets/ui/proposal-actions.js +273 -0
  16. package/dist/assets/ui/proposals.js +219 -0
  17. package/dist/assets/ui/search.js +62 -0
  18. package/dist/assets/ui/styles.css +1415 -0
  19. package/dist/cli.js +3 -0
  20. package/dist/commands/health/checks.js +5 -3
  21. package/dist/commands/health/egress.js +5 -1
  22. package/dist/commands/health/engine-usage.js +2 -2
  23. package/dist/commands/health/llm-usage.js +2 -2
  24. package/dist/commands/health/windows.js +5 -5
  25. package/dist/commands/health.js +40 -8
  26. package/dist/commands/improve/reflect-noise.js +4 -0
  27. package/dist/commands/improve/reflect.js +14 -5
  28. package/dist/commands/metrics/metrics-cli.js +3 -3
  29. package/dist/commands/proposal/propose-cli.js +5 -0
  30. package/dist/commands/proposal/propose.js +6 -2
  31. package/dist/commands/proposal/repository.js +34 -29
  32. package/dist/commands/read/search.js +2 -1
  33. package/dist/commands/read/show.js +18 -12
  34. package/dist/commands/sources/source-clone.js +123 -11
  35. package/dist/commands/ui/api.js +556 -0
  36. package/dist/commands/ui/git-read.js +101 -0
  37. package/dist/commands/ui/open-browser.js +30 -0
  38. package/dist/commands/ui/proposal-actions.js +179 -0
  39. package/dist/commands/ui/redact.js +88 -0
  40. package/dist/commands/ui/server.js +352 -0
  41. package/dist/commands/ui/ui-cli.js +62 -0
  42. package/dist/core/asset/resolve-ref.js +7 -3
  43. package/dist/indexer/indexer.js +14 -2
  44. package/dist/indexer/search/db-search.js +7 -3
  45. package/dist/output/format-exempt.js +3 -0
  46. package/dist/storage/repositories/improve-runs-repository.js +6 -0
  47. package/dist/storage/repositories/index-db.js +10 -2
  48. package/dist/storage/repositories/metrics-repository.js +17 -0
  49. package/docs/architecture/0.10-process-rules.md +3 -4
  50. package/docs/architecture/improvement.md +2 -1
  51. package/docs/architecture/internals/improve-workflow.md +2 -2
  52. package/docs/reference/README.md +1 -0
  53. package/docs/reference/cli.md +68 -0
  54. package/docs/reference/ui-api.md +576 -0
  55. package/package.json +1 -1
@@ -232,13 +232,17 @@ function opaqueRefParts(conceptId, allowRoot = false) {
232
232
  * that `resolveSourcesForOrigin` matches on `registryId`. The SHORT form (no
233
233
  * bundle) leaves `origin` undefined = search-all-sources.
234
234
  * - `#fragment` → rejected. No input boundary consumes an export fragment.
235
+ *
236
+ * `originGiven` — the caller already split a (non-slug) origin off the ref, so a
237
+ * single-segment conceptId (a flat skill repo's `domain-name-brainstormer`) is
238
+ * qualified and parses as opaque, exactly as it does behind a slug `bundle//`.
235
239
  */
236
- export function parseRefInput(raw) {
240
+ export function parseRefInput(raw, originGiven = false) {
237
241
  const ref = parseBundleRef(raw);
238
242
  if (ref.fragment !== undefined) {
239
243
  throw new UsageError(`Export fragment "#${ref.fragment}" is not accepted here — drop it from "${raw.trim()}".`, "INVALID_FLAG_VALUE");
240
244
  }
241
- const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, ref.bundle !== undefined);
245
+ const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, originGiven || ref.bundle !== undefined);
242
246
  if (parts === undefined) {
243
247
  throw new NotFoundError(`Unrecognized asset ref "${raw.trim()}": conceptId "${ref.conceptId}" has no known asset-type prefix.`, "ASSET_NOT_FOUND");
244
248
  }
@@ -265,7 +269,7 @@ export function parseQualifiedRefInput(raw) {
265
269
  if (boundary > 0) {
266
270
  const origin = trimmed.slice(0, boundary);
267
271
  if (!isBundleSlug(origin)) {
268
- return { ...parseRefInput(trimmed.slice(boundary + 2)), origin };
272
+ return { ...parseRefInput(trimmed.slice(boundary + 2), true), origin };
269
273
  }
270
274
  }
271
275
  return parseRefInput(trimmed);
@@ -1196,6 +1196,18 @@ borrowed = false) {
1196
1196
  export async function lookupBundleRefWithResolution(ref) {
1197
1197
  return lookupBundleRefWithResolutionUsing(ref, openExistingDatabase);
1198
1198
  }
1199
+ /**
1200
+ * {@link lookupBundleRefWithResolution} on a plain read-only connection (`akm ui`): a read-write one can checkpoint
1201
+ * the WAL into the main file when it closes, changing the file although nothing was written.
1202
+ */
1203
+ export async function lookupBundleRefWithResolutionReadonly(ref) {
1204
+ return lookupBundleRefWithResolutionUsing(ref, (dbPath) => {
1205
+ const db = openReadonlyExistingDatabase(dbPath);
1206
+ if (!db)
1207
+ throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
1208
+ return db;
1209
+ });
1210
+ }
1199
1211
  /** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
1200
1212
  export async function lookupBundleRef(ref) {
1201
1213
  const resolution = await lookupBundleRefWithResolution(ref);
@@ -1223,12 +1235,12 @@ export async function lookupBundleRefReadonly(ref) {
1223
1235
  * index, which is copied once rather than once per ref. A ref that does not
1224
1236
  * resolve, or an index that cannot be read, gives `null` for that ref.
1225
1237
  */
1226
- export async function lookupBundleRefsReadonly(refs) {
1238
+ export async function lookupBundleRefsReadonly(refs, options = {}) {
1227
1239
  if (refs.length === 0)
1228
1240
  return [];
1229
1241
  let db;
1230
1242
  try {
1231
- db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: true });
1243
+ db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: options.isolatedSnapshot ?? true });
1232
1244
  }
1233
1245
  catch {
1234
1246
  // Unreadable index: every ref stays unresolved.
@@ -21,7 +21,7 @@ import { presentationFor } from "../../core/type-presentation.js";
21
21
  import { embed } from "../../llm/embedder.js";
22
22
  import { applyEmbeddingTemplate, resolveEmbeddingProfile } from "../../llm/embedders/profile.js";
23
23
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
24
- import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
24
+ import { assertIndexPathReadable, closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../storage/repositories/index-connection.js";
25
25
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getEntryRefsAndTypes, } from "../../storage/repositories/index-entries-repository.js";
26
26
  import { searchFts } from "../../storage/repositories/index-fts-repository.js";
27
27
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -112,7 +112,8 @@ export async function searchLocal(input) {
112
112
  // Bootstrap-only: builds the index inline when it cannot serve this stash.
113
113
  // Content freshness is the writers' job (indexWrittenAssets + full runs);
114
114
  // reads serve the existing index as-is.
115
- await ensureIndex(stashDir);
115
+ if (!input.readOnly)
116
+ await ensureIndex(stashDir);
116
117
  const dbPath = getDbPath();
117
118
  // An index we cannot READ is not an index that does not exist (#791). Saying
118
119
  // "No search index available" for a populated index the caller merely lacks
@@ -127,7 +128,10 @@ export async function searchLocal(input) {
127
128
  mode: "keyword",
128
129
  };
129
130
  }
130
- const db = openExistingDatabase(dbPath);
131
+ // A read-only search (`akm ui`) must not open the index for write: closing a
132
+ // read-write connection can checkpoint the WAL into the main file, which
133
+ // changes it even though nothing was written.
134
+ const db = input.readOnly ? openReadonlyExistingDatabase(dbPath) : openExistingDatabase(dbPath);
131
135
  try {
132
136
  const entryCount = getEntryCount(db);
133
137
  if (entryCount === 0) {
@@ -41,6 +41,9 @@ const EXEMPT_COMMANDS = new Set([
41
41
  "help",
42
42
  // Embedded agent guide document (`src/output/cli-hints.ts`).
43
43
  "hints",
44
+ // Prints the URL it is serving and then runs until interrupted; the data is
45
+ // the web UI's own JSON API, not a result envelope.
46
+ "ui",
44
47
  ]);
45
48
  /**
46
49
  * `<command> <subcommand>` pairs that are exempt while the rest of the group
@@ -121,6 +121,12 @@ export function getLatestImproveRun(db) {
121
121
  .prepare(`SELECT ${IMPROVE_RUN_ROW_COLUMNS} FROM improve_runs WHERE dry_run = 0 ORDER BY started_at DESC LIMIT 1`)
122
122
  .get();
123
123
  }
124
+ /** The `limit` most recent real (non-dry-run) improve runs, newest first (`akm ui`'s run list). */
125
+ export function listRecentImproveRuns(db, limit) {
126
+ return db
127
+ .prepare("SELECT id, started_at, completed_at, ok, scope_mode, scope_value, strategy, result_json FROM improve_runs WHERE dry_run = 0 ORDER BY started_at DESC LIMIT ?")
128
+ .all(limit);
129
+ }
124
130
  /**
125
131
  * #950: count real (non-dry-run) improve_runs rows whose `started_at` falls
126
132
  * in `[since, now)` — same filter as {@link queryImproveRuns}, but a `SELECT
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { resolveStorageLocations } from "../locations.js";
5
5
  import { withManagedDb } from "../managed-db.js";
6
- import { openExistingDatabase } from "./index-connection.js";
6
+ import { openExistingDatabase, openReadonlyExistingDatabase } from "./index-connection.js";
7
7
  /**
8
8
  * Busy-timeout (ms) for read-path telemetry writers. Small on purpose: a
9
9
  * usage-event insert contending with a background reindex should be dropped,
@@ -41,7 +41,15 @@ export const TELEMETRY_BUSY_TIMEOUT_MS = 250;
41
41
  * @returns Whatever `fn` returns.
42
42
  */
43
43
  export function withIndexDb(fn, opts) {
44
- return withManagedDb(() => openExistingDatabase(resolveStorageLocations().indexDb), (db) => {
44
+ return withManagedDb(() => {
45
+ const dbPath = resolveStorageLocations().indexDb;
46
+ if (!opts?.readOnly)
47
+ return openExistingDatabase(dbPath);
48
+ const db = openReadonlyExistingDatabase(dbPath);
49
+ if (!db)
50
+ throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
51
+ return db;
52
+ }, (db) => {
45
53
  if (opts?.busyTimeoutMs !== undefined) {
46
54
  db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts.busyTimeoutMs))}`);
47
55
  }
@@ -17,6 +17,23 @@ export function listUsageEventRows(db, filter) {
17
17
  ORDER BY datetime(created_at), id`)
18
18
  .all(filter.sinceIso, untilBound, ...sourceParams);
19
19
  }
20
+ /** One asset's feedback rows (`entry_ref` is the durable `bundle//conceptId`), newest first (`akm ui`'s asset view). */
21
+ export function listFeedbackEventRows(db, entryRef, limit) {
22
+ return db
23
+ .prepare(`SELECT id, event_type, query, entry_id, entry_ref, signal, metadata, source, created_at
24
+ FROM usage_events
25
+ WHERE event_type = 'feedback' AND entry_ref = ?
26
+ ORDER BY datetime(created_at) DESC, id DESC LIMIT ?`)
27
+ .all(entryRef, limit);
28
+ }
29
+ /** Feedback counts by signal for one asset, all sources (`akm ui`'s asset view). */
30
+ export function countFeedbackBySignal(db, entryRef) {
31
+ const rows = db
32
+ .prepare(`SELECT COALESCE(signal, 'none') AS signal, COUNT(*) AS n FROM usage_events
33
+ WHERE event_type = 'feedback' AND entry_ref = ? GROUP BY signal`)
34
+ .all(entryRef);
35
+ return Object.fromEntries(rows.map((row) => [row.signal, row.n]));
36
+ }
20
37
  /**
21
38
  * `select` events (a show within 60 s of a search that returned the ref) in the
22
39
  * window. The events stream stores the ref as the user typed it (often
@@ -11,7 +11,7 @@ This version adds the policy batch: Policy decisions 1, 3, 4, 5, 7 and 9 are imp
11
11
  ## TLDR
12
12
 
13
13
  - **The improve run** plans from fresh feedback plus a capped proactive lane, works only on the write-target bundle, and only touches assets someone retrieved in the last 90 days or new material. It takes a PID lock, syncs a git-backed bundle like `akm sync` (commit pending managed-path edits, fetch, merge, push) before it reads anything, has a 2-hour budget, commits and pushes only the files it wrote, and records every run in `improve_runs`.
14
- - **Reflect** runs only on fresh negative feedback (or the proactive lane) and may change only `description`, `when_to_use` and a title, never the body text (a title is added as a `# heading` when the body has none). The judge must score every criterion 4 or higher, and nothing is checked after it. A non-pass with a mean of 3.5 or more and a judge outage go to a person as a pending proposal; a lower non-pass or a defect is refused with no proposal and a hard 14-day window.
14
+ - **Reflect** runs only on fresh negative feedback (or the proactive lane) and may change only `description`, `when_to_use` and a title, and the title edits the body only by adding a missing `# heading`; the body is otherwise byte for byte unchanged. The judge must score every criterion 4 or higher, and nothing is checked after it. A non-pass with a mean of 3.5 or more and a judge outage go to a person as a pending proposal; a lower non-pass or a defect is refused with no proposal and a hard 14-day window.
15
15
  - **Distill** needs feedback in the last 30 days, and skips a memory whose only feedback is a positive with no reason or note (10 of 11 lessons from such memories were rejected). Lessons that pass the judge still go to a person, never the drain. The proactive lane never feeds distill.
16
16
  - **Consolidate** only promotes memories to knowledge; the pair pass retires a duplicate only when the claim lists show nothing would be lost. A retirement is staged for the drain only if the claims are clean, there is no continuity risk and the second look agrees. There is no confidence cutoff. A `captureMode: hot` memory is retired, promoted and archived like any other. The pair pass, the coverage gate and the continuity check all need embeddings, which are off by default.
17
17
  - **Triage** accepts only what a deterministic gate passed (a proposal whose judge stamp still matches its content hash, or a retirement the pair pass staged), up to 200 per run. The judgment step is off by default and may only accept consolidate promotions. Proactive-lane proposals, distill lessons and uncertain judge verdicts wait for a person.
@@ -211,7 +211,7 @@ These 14 bugs are fixed on this branch; the rules below describe the fixed behav
211
211
  - **Ranking inside the lane** — Priority is the salience rank score computed from type-stub encoding, retrieval frequency, last use and file size. It has no outcome term. Ties break by staleness (never-touched first), then ref ascending. There is no randomisation, so at 15 a day and 30 days between visits at most about 450 distinct assets ever cycle, and among never-retrieved assets of one type priority differs only by file size, so the same smallest files recur. The lane has no memory of futility: a `no_change` reflect records `unchanged` and resets the 30-day clock, and the no-op damper applies only to the merged loop ranking, not to the lane's own priority (`rankScore` is used raw), so an asset reflect already found sound returns at full priority every 30 days. (`proactive-maintenance.ts:94-123`, `preparation.ts:963-966`)
212
212
  - **Lane attribution** — Each planned ref is tagged `proactive` < `signal-delta` < `scope`; the strongest lane wins. The tag goes on the dry-run plan, the events, and the proposal as `eligibilitySource`. *Why:* so outcomes can be sliced by lane. (`preparation.ts:948-954`, `src/commands/proposal/proposal-types.ts:15-27`)
213
213
  - **Events and plan** — A live run appends a `proactive_selected` event (count, dueTotal, neverReflected). The plan reports `configured`, `effective` (`dueDays`, `maxPerRun`), `candidatePool` and `selectedRefs`. (`preparation.ts:1119-1146`)
214
- - **What it may propose** — Reflect proposals in practice: reflect revises only `description`, `when_to_use` and the title, never the body. A proactively picked memory is never distilled, because it has no feedback signal (`memory_distill_requires_feedback`; non-memory picks fail distill's type filter); the only link to distill is that a distill attempt resets the lane's due clock. *Why:* on 396 labelled edits, frontmatter-only fixes were good 24/26 times, and edits that also rewrote the body were bad 175 of 224. (`docs/architecture/improvement.md` "Quality judge", `loop-stages.ts:302-310`)
214
+ - **What it may propose** — Reflect proposals in practice: reflect edits frontmatter (`description`, `when_to_use`, `title`), plus at most a missing level-1 heading; the body is otherwise byte for byte unchanged. A proactively picked memory is never distilled, because it has no feedback signal (`memory_distill_requires_feedback`; non-memory picks fail distill's type filter); the only link to distill is that a distill attempt resets the lane's due clock. *Why:* on 396 labelled edits, frontmatter-only fixes were good 24/26 times, and edits that also rewrote the body were bad 175 of 224. (`docs/architecture/improvement.md` "Quality judge", `loop-stages.ts:302-310`)
215
215
  - **Its proposals wait for a person** — `proposal drain` never accepts a proposal with `eligibilitySource:"proactive"`, whether a deterministic gate, an earlier staged accept or the judgment tier would take it. It is deferred with reason `proactive-needs-review` and is not sent to the judgment tier. Empty diffs are still rejected. Unattended, then, the lane cannot fix anything: the proposal expires after 90 days while the asset is due again after 30, so it is re-reflected at about day 30 and day 60 before the first proposal expires, and up to three identical pending proposals can exist for one asset (nothing blocks a second pending reflect proposal for a ref; see Reflect: Nothing prevents a second pending reflect proposal). *Why:* with the lane on and the drain promoting, reflect edits on assets with no feedback were accepted unattended. The nightly eval showed `when_to_use`, `type` and provenance stamps and a title heading added to assets no one asked about (#1147). (`src/commands/proposal/drain.ts:12-17,593-596`, CHANGELOG 0.10.26101001 "A proposal from the proactive lane waits for a person", #1147)
216
216
  - **Unreviewed proactive proposals expire** — A pending proposal older than `archiveRetentionDays` (default 90; 0 disables) is archived as rejected (`expired`) in post-loop hygiene. Counting starts from the last reopen. Retire proposals expire the same way (fixed in this PR, #10). (`src/commands/proposal/repository.ts:872-927`)
217
217
 
@@ -296,7 +296,7 @@ All paths are relative to the `rules-010` worktree. Citation form is `path:line`
296
296
 
297
297
  ### What reflect may change
298
298
 
299
- - **Only `description`, `when_to_use` and a title. The body text is the source's own, except that a title adds a `# title` heading.** The engine returns `confidence` plus `frontmatterPatch {description, when_to_use, title}`, each a non-empty single-line string or `null`. akm applies the patch itself. When the patch carries a title and the body has no level-1 heading, akm prepends `# <title>` and strips every leading blank line of the body, so the body is not byte for byte unchanged; #1147 lists exactly this edit ("a title heading added to assets no one asked about") as harm. After the judge, the proposal mint also splices `type:` and `updated:` lines into the frontmatter when they are missing (next rules and Proposal outcome). *Why:* on 396 labelled reflect edits, frontmatter-only fixes were good 24 of 26 times, and edits that also rewrote the body were bad 175 of 224. (`reflect.ts:5-10,229-235,237-259,313-322,428-446`; CHANGELOG 0.9.25-alpha.3)
299
+ - **Only `description`, `when_to_use` and a title. The body text is the source's own, byte for byte, except that a title adds a missing `# title` heading.** The engine returns `confidence` plus `frontmatterPatch {description, when_to_use, title}`, each a non-empty single-line string or `null`. akm applies the patch itself. When the patch carries a title and the body has no level-1 heading, akm inserts `# <title>` and one blank line at the body's first non-blank line, in the body's own line ending, and changes nothing else: leading blank lines stay, so the only body difference is the inserted heading. A missing H1 is a valid reflect edit and the only body change reflect may make, so a gate or reviewer that treats a proposal as frontmatter-only must accept it (#1183). Of 21 pending proactive-lane proposals on 2026-10-14, 17 differed from their source only by the inserted heading and the stripped blank lines, and all 17 were rejected as body edits; the title is kept because with the problem list in the prompt the model fixed 189 of 193 missing titles. After the judge, the proposal mint also splices `type:` and `updated:` lines into the frontmatter when they are missing (next rules and Proposal outcome). *Why:* on 396 labelled reflect edits, frontmatter-only fixes were good 24 of 26 times, and edits that also rewrote the body were bad 175 of 224. (`reflect.ts:5-10,229-235,237-259,313-322,428-446`; CHANGELOG 0.9.25-alpha.3)
300
300
  - **A title becomes a `# title` heading only if the body has no level-1 heading.** Otherwise the title is ignored. The test is a multi-line regex that also matches a `# comment` line inside a fenced shell block, which suppresses the title. (`reflect.ts:257-259`)
301
301
  - **Only the changed keys' frontmatter lines are rewritten.** Every other line is kept, and an unchanged-equal field is dropped from the patch. The mint then adds `type:` and `updated:` lines (see Mint below). *Why:* a source whose YAML the parser reads only partly (a broken description beside a list) must lose nothing. (`reflect.ts:243-245,274,289-304`)
302
302
  - **A source whose frontmatter fence is opened but never closed gets no proposal.** It is reported as `no_change` ("empty diff"), recorded `unchanged` and counted by the plasticity counter; the person the comment says should fix it is never told. *Why:* it would leave the old block in the body under a new one, so a person fixes it. (`reflect.ts:253-255,1042-1059`)
@@ -413,7 +413,6 @@ All paths are relative to the `rules-010` worktree. Citation form is `path:line`
413
413
  - `salience.ts:129-134` documents the no-op dampener as "Consolidation selection only", but `preparation.ts:956-969` applies it when ranking the reflect/distill loop.
414
414
  - The `content_policy_reject` / `reflect-guard-rejected` classification still exists, but the reflect size rail it was named for was removed in 0.9.25-alpha.3.
415
415
  - Reflect's judge labels a mean in [2.5, 4) as "review" (`runQualityJudge`), but reflect sends only a mean of 3.5 or more to a person and refuses 2.5 to 3.5; the cut is `REFLECT_REVIEW_MIN_SCORE`, not the judge's label (changed in this PR, decision 4). (`stage.ts:643`, `reflect.ts:1018,1104-1116`)
416
- - The code comment at `reflect.ts:239-241` and the judge intro (`stage.ts:464`) say the body is kept byte for byte / unchanged apart from an added title heading; neither mentions that every leading blank line of the body is stripped when the heading is prepended (`reflect.ts:258-259`).
417
416
 
418
417
  ## Distill
419
418
 
@@ -241,7 +241,8 @@ it for a person too. On 2026-10-05 the gate had
241
241
  staged 12 lessons and 10 were bad (they restated the memory, claimed what it
242
242
  does not say, or filed a dated status as a lesson), and no score separated them
243
243
  from the two good ones. Reflect revises only an asset's
244
- `description`, `when_to_use` and title, never its body. On 396 labelled edits,
244
+ `description`, `when_to_use` and title, and edits the body only to add a missing level-1
245
+ heading; the rest of the body stays byte for byte. On 396 labelled edits,
245
246
  those that fixed a frontmatter defect and left the body alone were good 24
246
247
  times in 26, and those that also rewrote the body were bad 175 times in 224.
247
248
 
@@ -187,7 +187,7 @@ flowchart TD
187
187
 
188
188
  `akmReflect` is the agent-invocation subprocess. It always emits a `reflect_invoked` event at entry, regardless of success or failure.
189
189
 
190
- Reflect changes only an asset's `description`, `when_to_use` and title. The engine replies with those fields (each `null` when it needs no fix), and akm applies them to the asset it read: the body is kept byte for byte, apart from a `# <title>` heading akm adds when the body has none. A reply that changes nothing creates no proposal.
190
+ Reflect changes only an asset's `description`, `when_to_use` and title. The engine replies with those fields (each `null` when it needs no fix), and akm applies them to the asset it read: the body is kept byte for byte, apart from a `# <title>` heading and one blank line akm inserts when the body has no level-1 heading (the only body change reflect makes). A reply that changes nothing creates no proposal.
191
191
 
192
192
  **Internal steps:**
193
193
 
@@ -208,7 +208,7 @@ Reflect changes only an asset's `description`, `when_to_use` and title. The engi
208
208
  LLM does. An LLM endpoint that rejects JSON Schema gets the framed-markdown
209
209
  contract instead.
210
210
  6. Validate the reply with `parseSchemaReflectOutput` (`parseFramedReflectOutput` for the framed contract), which strips `<think>` blocks and code fences, then requires exactly the contract's fields: `confidence` and a `frontmatterPatch` of `description`, `when_to_use` and `title` (each a non-empty single-line string, or `null` for no change), plus `ref` when no target was given. A reply that fails gets one repair turn, shared across self-refine passes: the conversation so far, and a request to reformat the reply in the contract (`reflect-output-repair.md`). A reply still invalid fails with `parse_error` and queues nothing. A failed agent or SDK dispatch is reported as it ran, with its exit code and stderr.
211
- 7. Apply the patch (`applyReflectPatch`) to the source asset: its frontmatter with the non-null `description` and `when_to_use` set, and its body unchanged. A non-null `title` becomes a `# <title>` heading and one blank line at the top of the body, only when the body has no level-1 heading; otherwise it is ignored. A source that requires a `description` and has none gets one derived from its own text when the patch gave none (#636). A patch that changes nothing (every field `null`, or equal to the source's, and no `description` to derive) is the `no_change` outcome (`reflect_skipped_noop`), as is a source with no content to patch; a result that differs only cosmetically is `reflect_skipped_cosmetic`. Neither creates a proposal.
211
+ 7. Apply the patch (`applyReflectPatch`) to the source asset: its frontmatter with the non-null `description` and `when_to_use` set, and its body unchanged. A non-null `title` becomes a `# <title>` heading and one blank line inserted at the body's first non-blank line, in the body's line ending, only when the body has no level-1 heading; otherwise it is ignored. Leading blank lines and every other body byte are kept. A source that requires a `description` and has none gets one derived from its own text when the patch gave none (#636). A patch that changes nothing (every field `null`, or equal to the source's, and no `description` to derive) is the `no_change` outcome (`reflect_skipped_noop`), as is a source with no content to patch; a result that differs only cosmetically is `reflect_skipped_cosmetic`. Neither creates a proposal.
212
212
  8. Quality gate (`processes.reflect.qualityGate`, on unless disabled): one judge call scores the revision against the source on **need**, **preservation** and **quality** ([Quality judge](../improvement.md#quality-judge)), and passes it only when every criterion scores 4 or more. A score that does not pass is refused (`quality_rejected`, no proposal) unless its mean is 3.5 or more: that is uncertain, not bad, and the proposal is deferred for a person (`judge-uncertain`, gate `quality-gate`, with the criterion `scores` and the judge's reason) and records no rejection window. A judge that times out, errors or replies unparseably gives no verdict, and the proposal is deferred for review too (`judge-error`, gate `quality-gate`). Before the judge, with the gate on or off, reflect refuses (`quality_rejected`, no proposal, no judge call; the rule is the event's `reflectDefect`) a revision that adds placeholder text (`placeholder_added`: "please confirm", "to be confirmed"; not `TODO`, `TBD` or `FIXME`), talks about its own edit (`meta_commentary_added`: "the feedback says", "this revision") or copies frontmatter into its body (`frontmatter_copied_into_body`: `sources:` or `updated:` lines outside code, a `sources` value). Each rule counts only what the revision adds to its source (`findReflectDefect`), and its wording is a list that `processes.reflect.defectFilter` replaces or, when empty, turns off ([Configuration](../../reference/configuration.md#strategies)).
213
213
  9. Write the proposal (`mintProposal`). A pass the judge passed is stamped `staged`, and the triage drain accepts it; with the gate off the proposal is minted unstamped for the drain to decide. One made with no judge configured (`no-judge-configured`) is deferred for review (gate `reflect`), and so is one whose judge gave no verdict (`judge-error`) and one in the judge's uncertain band (`judge-uncertain`). `akm proposal list` shows the reason; the drain leaves every deferral for a person.
214
214
 
@@ -5,6 +5,7 @@ Authoritative reference documentation for the akm CLI and its data.
5
5
  - [Programmatic API](api.md) -- `akm-cli/api`: in-process `curate()` for the akm plugins
6
6
  - [Bundle Types](bundle-types.md) -- Supported bundle formats and compatibility
7
7
  - [CLI](cli.md) -- All `akm` commands and flags
8
+ - [UI API](ui-api.md) -- The read-only JSON API behind `akm ui`
8
9
  - [Configuration](configuration.md) -- Engines, strategies, bundles, and settings
9
10
  - [Supported Formats](supported-formats.md) -- Formats akm can index, from its own bundle layout to other tools' existing asset directories
10
11
  - [Tasks](tasks.md) -- Task source v4 grammar, executable targets, scheduling, migration, and scheduler operations
@@ -490,6 +490,55 @@ Notes you may see:
490
490
  - Selects are read from the events stream, which records no source, so they are
491
491
  counted whatever the usage source is.
492
492
 
493
+ ### ui
494
+
495
+ **Experimental** (see [STABILITY.md](../../STABILITY.md)): the command, the page and the API it reads may change in any
496
+ release.
497
+
498
+ Serve a local web UI over your whole akm environment and open it in your browser. It is read-only except that you can accept and reject proposals in it: health and metrics, bundles
499
+ (with git status and asset counts), the effective config with where each value comes from, the proposal queue with
500
+ diffs, recent changes to the write-target stash, and search with an asset view. Every page reads through the functions
501
+ the matching CLI commands use, so the UI and the CLI report the same numbers.
502
+
503
+ | Page | Shows |
504
+ | --- | --- |
505
+ | Overview | Overall health, failing and warning check counts and the akm version; the health checks grouped by status with expandable evidence; metrics tiles; the last 10 improve runs (result, duration, model calls and failures, sync, triage) with bars; and a 14-day stacked usage chart (search, curate, show, feedback). |
506
+ | Bundles | One card per bundle: kind, path or source, writable, git branch, ahead/behind, last commit time and asset counts by type; plus the index state. |
507
+ | Config | The effective config as a collapsible tree with a `default` / `file` / `extends:<ref>` source badge on every key, the engines table and the `AKM_*` environment. |
508
+ | Proposals | The pending queue grouped by source (filter by source) with age and gate decision, a Recent tab of accepted, rejected and reverted proposals with reasons, and a detail page with the unified diff and, for retirements, the pair judgment. The detail page has Accept (after an inline confirmation) and Reject (with a required reason); the pending list has checkboxes and an Accept selected bar that accepts several in one commit. |
509
+ | Changes | Recent stash commits as a timeline by day, each tagged by origin (`improve-sync`, `accept`, `remember`, `sync`, `other`) with its changed paths. |
510
+ | Search | A query box with type and bundle filters. A result opens the asset view: frontmatter, the body as safe markdown, links, and feedback counts and history. |
511
+
512
+ The pages are plain HTML, CSS and JavaScript modules in `src/assets/ui/`: no framework, no build step, and no request
513
+ beyond the local server. All text from the API is escaped before it reaches the page.
514
+
515
+ ```sh
516
+ akm ui
517
+ akm ui --port 8123 --no-open
518
+ ```
519
+
520
+ | Flag | Description |
521
+ | --- | --- |
522
+ | `--port <n>` | Port to listen on. Default: a random free port. |
523
+ | `--host <addr>` | Address to bind. Default `127.0.0.1`. Anything else is reachable from other machines and prints a warning. |
524
+ | `--no-open` | Print the URL without opening a browser. A failed open also falls back to printing it. |
525
+
526
+ The command prints `akm ui: http://127.0.0.1:<port>/?token=<token>` and keeps serving until Ctrl-C (SIGINT or SIGTERM).
527
+ Each launch generates a new random token. Every `/api/` request must send it as the `X-Akm-Ui-Token` header or a `token`
528
+ query parameter, otherwise it gets a 401; the page keeps it in `sessionStorage`. The `Host` header must be the address
529
+ the server is bound to, which stops a web page from reaching it through DNS rebinding.
530
+
531
+ Browsing is read-only: `state.db` and `index.db` are opened read-only and never created or migrated, searching and
532
+ showing in the UI record no usage events (so browsing is never counted as demand) and build no index, and nothing is
533
+ written to a bundle or to config. The only writes are accepting and rejecting proposals (and accepting several at once),
534
+ through three `POST` routes that call the functions `akm proposal accept` and `reject` call, so a decision made in the
535
+ UI is recorded and committed exactly like one made on the command line. Those routes need the token in the
536
+ `X-Akm-Ui-Token` header (not the query string), a JSON content type and an `Origin` equal to the server's own address.
537
+ A read is short and the server holds no lock between requests, so browsing never blocks `akm improve`, `akm index` or
538
+ `akm sync`; a decision takes the same write lease the CLI takes and answers `409` at once if another process holds it. Secrets are redacted: config values under secret-like keys and URL credentials
539
+ are masked, and env and secret assets are shown as a stub, never with their values. The endpoints are documented in
540
+ [ui-api.md](ui-api.md). `--format` has no effect on this command.
541
+
493
542
  ### search
494
543
 
495
544
  Search bundle assets, registries, or both.
@@ -1509,6 +1558,23 @@ akm clone "npm:@scope/pkg//scripts/deploy.sh" --dest /tmp/preview
1509
1558
 
1510
1559
  Skills (directories) are copied recursively. Other types copy a single file.
1511
1560
 
1561
+ **Refs outside the type directories.** A ref whose concept id is not under a
1562
+ type directory (a skill in a flat skills repo, `my-repo//domain-name-brainstormer`;
1563
+ a page from a website or wiki bundle) is placed by what the source file is: a
1564
+ `SKILL.md` directory clones to `skills/<name>/`, any other recognized type to its
1565
+ own directory, and markdown of any other type to `knowledge/<name>.md`. `<name>`
1566
+ is the last segment of the concept id, slugified; `--name` overrides it. An asset
1567
+ that is not markdown and has no place in a bundle fails with a usage error that
1568
+ points to `akm import <path>`.
1569
+
1570
+ **Provenance.** A cloned markdown asset (a skill's `SKILL.md` included) records
1571
+ where it came from: the origin ref, `<bundle>//<concept-id>`, is added to its
1572
+ `sources:` frontmatter, keeping any entries already listed. `sources:` is free-form
1573
+ provenance that `akm lint` does not resolve, so a clone shared where the origin
1574
+ bundle is not installed does not report a missing ref. Other bytes of the file
1575
+ stay as written, non-markdown files and workflows are copied unchanged, and the
1576
+ origin is never modified.
1577
+
1512
1578
  **Remote clone:** When the origin in the ref points to a package that is not
1513
1579
  installed locally (e.g. an npm package or local path not in your bundle
1514
1580
  sources), akm fetches it to the cache automatically and extracts the
@@ -2981,11 +3047,13 @@ akm proposal new <type> <name> --file ./prompt.md
2981
3047
  akm proposal new skill code-review --task "PR-style review skill"
2982
3048
  akm proposal new lesson docker-cleanup --file ./prompts/docker-cleanup.md
2983
3049
  akm proposal new skill code-review --path team --task "PR-style review skill" # writes under skills/team/
3050
+ akm proposal new skill code-review --bundle team --task "PR-style review skill" # queued for the `team` bundle
2984
3051
  ```
2985
3052
 
2986
3053
  | Flag | Description |
2987
3054
  | --- | --- |
2988
3055
  | `--path` | Relative subdirectory under the type dir to place the proposed asset in (e.g. `release`). The filename comes from `<name>`. |
3056
+ | `--bundle` | Configured writable bundle to queue the proposal for. The prompt takes its convention facts from that bundle, the proposal records it as its target (so `akm proposal accept` writes there), and it is queued in that bundle's queue (`akm proposal list --queue <bundle>`). An unknown, disabled or read-only bundle is refused, as for `akm remember --bundle`. Default: the working bundle |
2989
3057
  | `--task` | Inline task text |
2990
3058
  | `--file` | Read task text from a UTF-8 file |
2991
3059
  | `--engine` | Override the default execution engine. Any kind works: an LLM engine, an agent CLI or `opencode-sdk` |