akm-cli 0.10.261004-beta → 0.10.261006-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 (54) hide show
  1. package/CHANGELOG.md +58 -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/improve.js +17 -4
  27. package/dist/commands/improve/reflect-noise.js +4 -0
  28. package/dist/commands/improve/reflect.js +14 -5
  29. package/dist/commands/proposal/drain.js +13 -1
  30. package/dist/commands/proposal/proposal-cli.js +23 -6
  31. package/dist/commands/proposal/proposal.js +58 -11
  32. package/dist/commands/proposal/repository.js +67 -31
  33. package/dist/commands/read/search.js +2 -1
  34. package/dist/commands/read/show.js +8 -6
  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/write-source.js +9 -5
  43. package/dist/indexer/search/db-search.js +2 -1
  44. package/dist/output/format-exempt.js +3 -0
  45. package/dist/sources/providers/git-stash.js +46 -15
  46. package/dist/storage/repositories/improve-runs-repository.js +6 -0
  47. package/dist/storage/repositories/metrics-repository.js +17 -0
  48. package/docs/architecture/0.10-process-rules.md +4 -5
  49. package/docs/architecture/improvement.md +9 -5
  50. package/docs/architecture/internals/improve-workflow.md +2 -2
  51. package/docs/reference/README.md +1 -0
  52. package/docs/reference/cli.md +62 -3
  53. package/docs/reference/ui-api.md +576 -0
  54. package/package.json +1 -1
@@ -22,7 +22,8 @@
22
22
  * {@link commitWriteTargetBoundary} once — or wrap a custom mutation in
23
23
  * {@link withWriteTargetMutation}, which does both under the asset lease. An
24
24
  * accepted proposal commits its own paths ({@link commitAcceptedPaths}), on a
25
- * filesystem stash that is a git repository too.
25
+ * filesystem stash that is a git repository too. A process that accepts many
26
+ * proposals collects them and commits once at its boundary (#1185).
26
27
  */
27
28
  import fs from "node:fs";
28
29
  import path from "node:path";
@@ -423,6 +424,7 @@ export function commitWriteTargetBoundary(target, message, options) {
423
424
  const saved = saveGitStash(undefined, message, resolveWritable(target.config), {
424
425
  repoDir,
425
426
  paths: committable,
427
+ ...(options?.body ? { body: options.body } : {}),
426
428
  ...(options?.push === undefined ? {} : { push: options.push }),
427
429
  });
428
430
  if (saved.reason)
@@ -430,9 +432,10 @@ export function commitWriteTargetBoundary(target, message, options) {
430
432
  }
431
433
  catch (error) {
432
434
  if (error instanceof GitStashPushError) {
433
- throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
434
- cause: error,
435
- });
435
+ const what = error.committedNow
436
+ ? `Changes were committed as ${error.commit}`
437
+ : `Nothing new to commit; earlier commits up to ${error.commit} are unpushed`;
438
+ throw new Error(`${what}, but publication failed: ${error.message}`, { cause: error });
436
439
  }
437
440
  throw error;
438
441
  }
@@ -443,10 +446,11 @@ export function commitWriteTargetBoundary(target, message, options) {
443
446
  * boundary commit skips, locally (the end-of-run sync pushes). A failed commit
444
447
  * only warns, since the accept already landed.
445
448
  */
446
- export function commitAcceptedPaths(target, message, paths) {
449
+ export function commitAcceptedPaths(target, message, paths, body) {
447
450
  try {
448
451
  commitWriteTargetBoundary(target, message, {
449
452
  paths,
453
+ ...(body ? { body } : {}),
450
454
  filesystem: true,
451
455
  ...(target.source.kind === "git" ? {} : { push: false }),
452
456
  });
@@ -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
@@ -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
@@ -127,10 +127,13 @@ export function checkGitPathSafety(repoDir, pathspec) {
127
127
  }
128
128
  export class GitStashPushError extends Error {
129
129
  commit;
130
- constructor(message, commit) {
130
+ /** False when the push was of commits made earlier (nothing was committed by this call). */
131
+ committedNow;
132
+ constructor(message, commit, committedNow = true) {
131
133
  super(message);
132
134
  this.name = "GitStashPushError";
133
135
  this.commit = commit;
136
+ this.committedNow = committedNow;
134
137
  }
135
138
  }
136
139
  const GIT_PUSH_TIMEOUT_MS = 120_000;
@@ -191,7 +194,11 @@ export function saveGitStash(name, message, writableOverride, options) {
191
194
  // Co-Authored-By: someone-else"` and forge trailers in the commit log.
192
195
  // Empty result falls back to the timestamped default.
193
196
  const sanitized = message ? sanitizeCommitMessage(message) : "";
194
- const commitMessage = sanitized || `akm save ${timestamp}`;
197
+ const bodyLines = (options?.body ?? []).map(sanitizeCommitMessage).filter(Boolean);
198
+ const commitMessage = [
199
+ sanitized || `akm save ${timestamp}`,
200
+ ...(bodyLines.length > 0 ? ["", ...bodyLines] : []),
201
+ ].join("\n");
195
202
  const transactionId = options?.transactionId;
196
203
  if (transactionId !== undefined && !/^[A-Za-z0-9._-]{1,128}$/.test(transactionId)) {
197
204
  throw new Error("Invalid Git transaction identifier.");
@@ -317,22 +324,12 @@ export function saveGitStash(name, message, writableOverride, options) {
317
324
  commit: exactCommit,
318
325
  };
319
326
  }
320
- const pushResult = runGit([
321
- "-C",
322
- repoDir,
323
- "push",
324
- `--force-with-lease=${pushTarget.mergeRef}:${pushTarget.upstreamHead}`,
325
- pushTarget.remote,
326
- `${readOptionalHead(repoDir) ?? exactCommit}:${pushTarget.mergeRef}`,
327
- ], { timeout: GIT_PUSH_TIMEOUT_MS });
328
- if (pushResult.status !== 0) {
329
- throw new GitStashPushError(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`, exactCommit);
330
- }
327
+ const pushOutput = pushToUpstream(repoDir, pushTarget, readOptionalHead(repoDir) ?? exactCommit, exactCommit);
331
328
  return {
332
329
  committed: true,
333
330
  pushed: true,
334
331
  skipped: false,
335
- output: pushResult.stdout.trim() || "changes committed and pushed",
332
+ output: pushOutput || "changes committed and pushed",
336
333
  commit: exactCommit,
337
334
  };
338
335
  }
@@ -343,12 +340,46 @@ function readHasRemote(repoDir) {
343
340
  }
344
341
  return remoteResult.stdout.trim().length > 0;
345
342
  }
346
- /** Nothing to commit still brings in the upstream, so a host with no changes of its own keeps up. */
343
+ /**
344
+ * Push `head` to the upstream, leased on the upstream head just read. Throws {@link GitStashPushError}
345
+ * (carrying `commit`) when git refuses. Returns git's stdout.
346
+ */
347
+ function pushToUpstream(repoDir, target, head, commit, committedNow = true) {
348
+ const pushResult = runGit([
349
+ "-C",
350
+ repoDir,
351
+ "push",
352
+ `--force-with-lease=${target.mergeRef}:${target.upstreamHead}`,
353
+ target.remote,
354
+ `${head}:${target.mergeRef}`,
355
+ ], { timeout: GIT_PUSH_TIMEOUT_MS });
356
+ if (pushResult.status !== 0) {
357
+ throw new GitStashPushError(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`, commit, committedNow);
358
+ }
359
+ return pushResult.stdout.trim();
360
+ }
361
+ /**
362
+ * Nothing to commit still brings in the upstream, so a host with no changes of its own keeps up. Commits
363
+ * made earlier and never pushed (a proposal accept, a killed improve run) are pushed here, once the
364
+ * branch is up to date with its upstream and ahead of it.
365
+ */
347
366
  function nothingToCommit(repoDir, output, syncWithRemote) {
348
367
  const integration = syncWithRemote ? integrateUpstream(repoDir) : undefined;
349
368
  if (integration && "blocked" in integration) {
350
369
  return { committed: false, pushed: false, skipped: false, reason: `not pushed: ${integration.blocked}`, output };
351
370
  }
371
+ // readUpstreamConfig is undefined on a detached HEAD or a branch with no upstream: nothing to push.
372
+ const head = syncWithRemote && readUpstreamConfig(repoDir) ? readOptionalHead(repoDir) : undefined;
373
+ const upstream = head ? readActualUpstream(repoDir, head) : undefined;
374
+ if (head && typeof upstream === "object" && upstream.upstreamHead !== head) {
375
+ const pushOutput = pushToUpstream(repoDir, upstream, head, head, false);
376
+ return {
377
+ committed: false,
378
+ pushed: true,
379
+ skipped: false,
380
+ output: pushOutput || `pushed ${upstream.upstreamHead.slice(0, 7)}..${head.slice(0, 7)}`,
381
+ };
382
+ }
352
383
  return { committed: false, pushed: false, skipped: false, output: integration?.integrated ?? output };
353
384
  }
354
385
  function readUpstreamConfig(repoDir) {
@@ -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
@@ -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
 
@@ -658,7 +657,7 @@ Paths are relative to the rules-010 checkout. `drain.ts` is `src/commands/propos
658
657
  - **Judgment verdict handling** — The judge returns accept, reject or defer, and parse failures and runner errors leave the item deferred (`judgment-parse-failure`, `judgment-error`). A judge reject becomes an auto-reject (`judgment-reject`). The judge sees the live asset (for a promotion, which is a create, the prompt prints "(none — this proposal would create a new asset)"), same-ref sibling proposals, and, only for such a create, the 5 nearest knowledge notes (300-character excerpts, plus promotions accepted earlier in the same drain). It is told to reject a promotion those notes already cover, or one that reports changing state ("as of <date>", a rollout, a plan not yet carried out). A lesson drawn from an incident is durable. *Why:* the model never sees `knowledge/` otherwise, and 18 of 51 promotions minted on 2026-10-07 and later rejected held 30% or more in a neighbouring doc. (`drain.ts:253-306,371-497,499-534`; `src/commands/improve/consolidate/coverage.ts:154-186`)
659
658
  - **Judgment queue-mode staging** — In `queue` mode a judged accept is stamped `staged` (`judgment-accept`) with its content hash, for a later promote run. (`drain.ts:444-464`)
660
659
  - **Excluded proposals** — `excludeIds` is always empty in improve (`improve.ts:869`); "this run's fresh proposals are never touched" is an ordering effect, because the pre-pass runs before consolidation mints. `--older-than <days>` on `proposal drain` excludes proposals younger than that, counted from creation or the last reopen, and fails safe by excluding any proposal whose age cannot be computed. (`drain.ts:559-564`; `src/commands/proposal/proposal-cli.ts:468-505`; `src/commands/proposal/proposal-types.ts:316-326`; `improve.ts:869`)
661
- - **What auto-accept means for git** — Every auto-accept is the ordinary promote path, so it writes the asset and commits it to the target with the subject `akm accept: <source> <8-char id> <ref>`. For a consolidate promotion the commit also carries the archived source memory and its tombstone. Filesystem targets never push; a git-kind target uses the default push behaviour. Commit failure is only a warning, not a failure of the accept. The standalone `drain --promote` asks for confirmation before promoting ("commit to git and cannot be batch-reverted"). A revert is per proposal (`akm proposal revert`). (`src/commands/proposal/repository.ts:1459-1462,1574-1596`; `src/core/write-source.ts:615-627`; `proposal-cli.ts:471-482`)
660
+ - **What auto-accept means for git** — Every auto-accept is the ordinary promote path, so it writes the asset and commits it to the target with the subject `akm accept: <source> <8-char id> <ref>`, but a loop of accepts (`drain`, bulk `accept --generator`, `accept <id> <id>`) commits once per process: `<label>: N accepted (<source> n, ...)` with one `<id8> <source> <ref>` body line per proposal (#1185). Inside `akm improve` the triage accepts join the run's end-of-run batch commit (or, with sync off or no git repository, are committed once when the pre-pass ends). For a consolidate promotion the commit also carries the archived source memory and its tombstone. Filesystem targets never push; a git-kind target uses the default push behaviour. Commit failure is only a warning, not a failure of the accept. The standalone `drain --promote` asks for confirmation before promoting ("commit to git and cannot be batch-reverted"). A revert is per proposal (`akm proposal revert`). (`src/commands/proposal/repository.ts` (`commitAccept`, `commitCollectedAccepts`); `src/core/write-source.ts` (`commitAcceptedPaths`); `proposal-cli.ts:471-482`)
662
661
  - **Drain failures stay pending** — An attempt that fails (validation, promote error) is listed under `failed` with a categorized reason and the proposal stays pending. A rejected proposal is recorded with a gate decision under gate `triage`. (`drain.ts:142-150,641-656`)
663
662
 
664
663
  ## Proposals and feedback
@@ -117,9 +117,12 @@ everything else routes through `akm proposal accept`.
117
117
  7. `akm proposal accept` promotes the proposal into the bundle; `akm proposal
118
118
  revert` restores the prior content from the backup captured at promotion
119
119
  time, if the proposal overwrote an existing asset. When the bundle is a git
120
- repository (a `.git` directory, whatever its source kind), each accept is
121
- committed as it happens, locally and with exactly the paths it wrote or
122
- removed: `akm accept: <generator> <proposal-id-8> <ref>`. A retirement's
120
+ repository (a `.git` directory, whatever its source kind), an accept is
121
+ committed locally with exactly the paths it wrote or removed:
122
+ `akm accept: <generator> <proposal-id-8> <ref>`. A process that accepts
123
+ several (`akm proposal drain`, a bulk accept, `accept <id> <id>`) commits them
124
+ once at its end, with a summary subject and one body line per proposal; in
125
+ `akm improve` the triage accepts join the end-of-run batch commit. A retirement's
123
126
  archived copy and tombstone, and the source memory a consolidate promotion
124
127
  retires, go in the same commit. A commit that fails warns and the accept
125
128
  stands.
@@ -238,7 +241,8 @@ it for a person too. On 2026-10-05 the gate had
238
241
  staged 12 lessons and 10 were bad (they restated the memory, claimed what it
239
242
  does not say, or filed a dated status as a lesson), and no score separated them
240
243
  from the two good ones. Reflect revises only an asset's
241
- `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,
242
246
  those that fixed a frontmatter defect and left the body alone were good 24
243
247
  times in 26, and those that also rewrote the body were bad 175 times in 224.
244
248
 
@@ -438,7 +442,7 @@ tombstone `retiredAt` is more than 30 days old (`RETIRE_GRACE_DAYS`) AND
438
442
  whose archive directory is entirely git-tracked and clean (`git ls-files`
439
443
  plus `git status --porcelain -uall`, both checked once per sweep, not once
440
444
  per directory). `.git` presence alone does not guarantee a retirement was
441
- ever committed: an accept commits its own paths (`commitAcceptedPaths` in
445
+ ever committed: an accept commits its own paths, once per process (`commitAcceptedPaths` in
442
446
  `src/core/write-source.ts`), on a `kind: "filesystem"` source with a `.git`
443
447
  directory too, but a commit that failed only warned, and a retirement accepted
444
448
  by an older akm left archived bytes sitting on disk with no commit behind them
@@ -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.
@@ -3058,6 +3107,7 @@ UUID, an 8-character UUID prefix, or an asset ref.
3058
3107
 
3059
3108
  ```sh
3060
3109
  akm proposal accept <id>
3110
+ akm proposal accept <id> <id> <id> # several at once: one commit
3061
3111
  akm proposal accept 7c115132 # 8-char UUID prefix
3062
3112
  akm proposal accept skills/akm-dream # Asset ref
3063
3113
  akm proposal accept <id> --queue team-bundle
@@ -3081,10 +3131,19 @@ Bulk-accept all pending proposals from one generator with `--generator <name>`
3081
3131
  (e.g. `reflect`, `distill`) and no positional id. Bulk accept requires
3082
3132
  `-y`/`--yes` in non-interactive shells.
3083
3133
 
3134
+ Several ids accept in one process and return one `proposal-accept-batch`
3135
+ envelope (`accepted`, `failed`, and a `results` list in argument order). An id
3136
+ that fails appears as `{ ok: false, id, error }`, the command exits 1, and the
3137
+ others are still accepted and committed.
3138
+
3084
3139
  When the destination bundle is a git repository (a `.git` directory, whatever
3085
- its source kind), each accept is committed as it happens, with exactly the paths
3086
- it wrote or removed and the subject `akm accept: <generator> <proposal-id-8>
3087
- <ref>`. A retirement's archived copy and tombstone, and the source memory a
3140
+ its source kind), one accept is committed with exactly the paths it wrote or
3141
+ removed and the subject `akm accept: <generator> <proposal-id-8> <ref>`. A
3142
+ process that accepts several proposals (several ids, `--generator`, or
3143
+ `akm proposal drain`) commits them once at its end: the subject is
3144
+ `akm proposal accept: N accepted (<generator> n, ...)` (`akm proposal drain: ...`
3145
+ for a drain) and the body has one `<proposal-id-8> <generator> <ref>` line per
3146
+ proposal. A retirement's archived copy and tombstone, and the source memory a
3088
3147
  consolidate promotion retires, are part of the same commit. The commit is local:
3089
3148
  `akm sync`, or the end-of-run sync of `akm improve`, pushes it. A commit that
3090
3149
  fails warns and the accept stands.