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
package/dist/cli.js CHANGED
@@ -85,6 +85,7 @@ import { renderMigrationHelp } from "./commands/sources/migration-help.js";
85
85
  import { cloneCommand, syncCommand, upgradeCommand } from "./commands/sources/sources-cli.js";
86
86
  import { importKnowledgeCommand, indexCommand, infoCommand } from "./commands/sources/stash-cli.js";
87
87
  import { taskCommand } from "./commands/tasks/tasks-cli.js";
88
+ import { uiCommand } from "./commands/ui/ui-cli.js";
88
89
  import { workflowCommand } from "./commands/workflow-cli.js";
89
90
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
90
91
  import { UsageError } from "./core/errors.js";
@@ -448,6 +449,7 @@ const commands = {
448
449
  index: indexCommand,
449
450
  health: healthCommand,
450
451
  metrics: metricsCommand,
452
+ ui: uiCommand,
451
453
  info: infoCommand,
452
454
  bundle: bundleCommand,
453
455
  upgrade: upgradeCommand,
@@ -778,6 +780,7 @@ const HELP_SECTIONS = [
778
780
  "lint",
779
781
  "health",
780
782
  "metrics",
783
+ "ui",
781
784
  "config",
782
785
  "models",
783
786
  "registry",
@@ -902,9 +902,11 @@ export const HEALTH_CHECKS = [
902
902
  kind: "deterministic",
903
903
  status: ctx.probe.ok ? "pass" : "fail",
904
904
  confidence: "high",
905
- message: ctx.probe.ok
906
- ? "state.db append/read round-trip succeeded."
907
- : `state.db round-trip failed: ${ctx.probe.error}`,
905
+ message: ctx.probe.skipped
906
+ ? "state.db append/read round-trip skipped: this run is read-only."
907
+ : ctx.probe.ok
908
+ ? "state.db append/read round-trip succeeded."
909
+ : `state.db round-trip failed: ${ctx.probe.error}`,
908
910
  evidence: { path: ctx.stateDbPath, durationMs: ctx.probe.durationMs },
909
911
  }),
910
912
  },
@@ -8,6 +8,7 @@
8
8
  * configured, matching the `stash-git-exposure` / `binary-config-skew`
9
9
  * advisories' pattern.
10
10
  */
11
+ import { redactCredentialPatterns } from "../../core/redaction.js";
11
12
  import { formatRegistryUrl } from "../../core/registry-url.js";
12
13
  /**
13
14
  * `egress-endpoints` (08 surfaces 3/9): the full list of remote destinations
@@ -42,6 +43,9 @@ export function collectEgressAdvisory(config) {
42
43
  endpoints.push(`embedding: ${config.embedding.endpoint}`);
43
44
  if (endpoints.length === 0)
44
45
  return undefined;
46
+ // A configured URL can carry a credential (`?token=…`, `user:pass@`); the list is for eyeballing destinations, so
47
+ // print the host and path and never the secret.
48
+ const safe = endpoints.map(redactCredentialPatterns);
45
49
  return {
46
50
  name: "egress-endpoints",
47
51
  kind: "deterministic",
@@ -49,6 +53,6 @@ export function collectEgressAdvisory(config) {
49
53
  confidence: "high",
50
54
  message: `${endpoints.length} remote endpoint(s) in the effective config (registries/sources/LLM/embedding) — ` +
51
55
  "review the evidence list for unexpected destinations.",
52
- evidence: { endpoints },
56
+ evidence: { endpoints: safe },
53
57
  };
54
58
  }
@@ -28,9 +28,9 @@ export function engineLastUsedSince(now) {
28
28
  * `engine` field (pre-#576 rows, or a call outside any resolved engine) is
29
29
  * skipped rather than bucketed under a synthetic key.
30
30
  */
31
- export function readLastEngineUsage(stateDbPath, now) {
31
+ export function readLastEngineUsage(stateDbPath, now, readOnly = false) {
32
32
  const since = engineLastUsedSince(now);
33
- const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events;
33
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath, readOnly }).events;
34
34
  const lastUsed = new Map();
35
35
  for (const event of events) {
36
36
  const record = decodeLlmUsageRecord(event.metadata);
@@ -59,8 +59,8 @@ export function summarizeLlmUsage(events) {
59
59
  }
60
60
  return aggregate;
61
61
  }
62
- export function readLlmUsageAggregate(stateDbPath, since, until) {
63
- const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events.filter((event) => {
62
+ export function readLlmUsageAggregate(stateDbPath, since, until, readOnly = false) {
63
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath, readOnly }).events.filter((event) => {
64
64
  if (until === undefined)
65
65
  return true;
66
66
  return new Date(event.ts ?? since).getTime() < new Date(until).getTime();
@@ -151,9 +151,9 @@ export function computeTaskWindowRates(taskRows, now) {
151
151
  * stats from the same per-run rows, and accepted-proposal coverage, all over
152
152
  * one `[since, until)`.
153
153
  */
154
- export function buildImproveWindowSummary(db, stateDbPath, since, until) {
154
+ export function buildImproveWindowSummary(db, stateDbPath, since, until, readOnly = false) {
155
155
  const untilMs = new Date(until).getTime();
156
- const eventsBeforeUntil = (type) => readEvents({ since, type }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < untilMs);
156
+ const eventsBeforeUntil = (type) => readEvents({ since, type }, { dbPath: stateDbPath, readOnly }).events.filter((event) => new Date(event.ts ?? since).getTime() < untilMs);
157
157
  const { metrics: improve, runCount } = summarizeImproveRuns(db, since, until);
158
158
  improve.invoked = eventsBeforeUntil("improve_invoked").length;
159
159
  improve.completed = eventsBeforeUntil(IMPROVE_COMPLETED_EVENT).length;
@@ -167,20 +167,20 @@ export function buildImproveWindowSummary(db, stateDbPath, since, until) {
167
167
  improve.coverage = computeWindowProposalCoverage(db, since, until);
168
168
  return { improve, runCount, perRunSummaries };
169
169
  }
170
- export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now()) {
170
+ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(), readOnly = false) {
171
171
  const untilMs = new Date(until).getTime();
172
172
  const taskRows = queryTaskHistory(db, { since }).filter((row) => {
173
173
  const startMs = new Date(row.started_at).getTime();
174
174
  return !Number.isFinite(untilMs) || startMs < untilMs;
175
175
  });
176
176
  const rates = computeTaskWindowRates(taskRows, now);
177
- const { improve, runCount } = buildImproveWindowSummary(db, stateDbPath, since, until);
177
+ const { improve, runCount } = buildImproveWindowSummary(db, stateDbPath, since, until, readOnly);
178
178
  const metrics = {
179
179
  taskFailRate: roundRate(rates.taskFailRate),
180
180
  agentFailureRate: roundRate(rates.agentFailureRate),
181
181
  agentFailureReasonCounts: rates.agentFailureReasonCounts,
182
182
  stuckActiveRuns: rates.stuckActiveRuns,
183
- llmUsage: readLlmUsageAggregate(stateDbPath, since, until),
183
+ llmUsage: readLlmUsageAggregate(stateDbPath, since, until, readOnly),
184
184
  };
185
185
  return { improve, metrics, runs: runCount };
186
186
  }
@@ -11,12 +11,14 @@ import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from ".
11
11
  import { listExistingTableNames, listPendingStateMigrations, openStateDatabaseWithReport, withStateDb, } from "../core/state-db.js";
12
12
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
13
13
  import { probeLlmEndpoint } from "../llm/client.js";
14
+ import { openDatabase } from "../storage/database.js";
14
15
  import { insertEvent } from "../storage/repositories/events-repository.js";
15
16
  import { getExtractOutcomeCountsSince } from "../storage/repositories/extract-sessions-repository.js";
16
17
  import { countImproveRunsSince } from "../storage/repositories/improve-runs-repository.js";
17
18
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
19
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
20
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
21
+ import { applyReadonlyPragmas } from "../storage/sqlite-pragmas.js";
20
22
  import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
21
23
  import { pkgVersion } from "../version.js";
22
24
  import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
@@ -176,12 +178,12 @@ function computeWorstTaskFailRate(rows) {
176
178
  return worst;
177
179
  }
178
180
  /** Table presence, the state.db round-trip probe, and task_history-derived rates. */
179
- function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
181
+ function gatherTaskHistoryPhase(db, since, stateDbPath, now, readOnly) {
180
182
  const tables = listExistingTableNames(db, ["events", "task_history", "proposals", "schema_migrations"]);
181
183
  const tableNames = tables.map((row) => row.name).sort();
182
184
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
183
185
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
184
- const probe = probeStateDbRoundTrip(stateDbPath);
186
+ const probe = readOnly ? { ok: true, durationMs: null, skipped: true } : probeStateDbRoundTrip(stateDbPath);
185
187
  // R0: read-only, independent of the round-trip probe above — integrity_check
186
188
  // catches corruption a successful append/read cannot (out-of-order rowids,
187
189
  // bad index entry counts), and the freelist reading is purely informational.
@@ -440,7 +442,7 @@ function resolveWindowComparePhase(options, db, stateDbPath, now) {
440
442
  windowResults = windowSpecs.map((spec) => {
441
443
  const winSince = parseHealthSince(spec.since);
442
444
  const winUntil = spec.until ? parseHealthSince(spec.until) : new Date(now()).toISOString();
443
- const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now);
445
+ const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now, options.readOnly === true);
444
446
  return {
445
447
  name: spec.name,
446
448
  since: winSince,
@@ -508,6 +510,25 @@ function unreadableStateDbCheck(detail) {
508
510
  evidence: { detail },
509
511
  };
510
512
  }
513
+ /**
514
+ * Why a read-only health run cannot use `stateDbPath`, as the failing check to
515
+ * report: the file is missing, or it is behind on migrations (a read-only open
516
+ * must not apply them). `undefined` when it can be read as it is.
517
+ */
518
+ function readOnlyStateDbUnavailable(stateDbPath) {
519
+ if (!fs.existsSync(stateDbPath)) {
520
+ return {
521
+ name: "state-db-readable",
522
+ kind: "deterministic",
523
+ status: "fail",
524
+ confidence: "high",
525
+ message: `state.db does not exist at ${stateDbPath}; a read-only health run does not create it. Run any akm command that records state, such as \`akm health\`.`,
526
+ evidence: { path: stateDbPath },
527
+ };
528
+ }
529
+ const pending = runPendingStateMigrationsCheck(stateDbPath, { listPendingStateMigrations });
530
+ return pending.status === "fail" ? pending : undefined;
531
+ }
511
532
  export async function akmHealth(options = {}) {
512
533
  validateAkmHealthOptions(options);
513
534
  const now = options.now ?? (() => Date.now());
@@ -521,9 +542,20 @@ export async function akmHealth(options = {}) {
521
542
  // to find out why everything else is behaving oddly. Report it as a finding
522
543
  // instead of dying before any check runs. The open itself applies every
523
544
  // pending migration; `state-db-migrations` reports what it applied.
545
+ const readOnly = options.readOnly === true;
524
546
  let opened;
525
547
  try {
526
- opened = openStateDatabaseWithReport(stateDbPath);
548
+ if (readOnly) {
549
+ const unavailable = readOnlyStateDbUnavailable(stateDbPath);
550
+ if (unavailable)
551
+ return degradedStateDbReport(unavailable, options);
552
+ const db = openDatabase(stateDbPath, { readonly: true, create: false });
553
+ applyReadonlyPragmas(db);
554
+ opened = { db, applied: [] };
555
+ }
556
+ else {
557
+ opened = openStateDatabaseWithReport(stateDbPath);
558
+ }
527
559
  }
528
560
  catch (error) {
529
561
  const { access, code } = classifyPathAccess(stateDbPath);
@@ -561,14 +593,14 @@ export async function akmHealth(options = {}) {
561
593
  cliVersion: pkgVersion,
562
594
  });
563
595
  schedulerBinaryDriftPromise.catch(() => undefined);
564
- const taskHistory = gatherTaskHistoryPhase(db, since, stateDbPath, now);
596
+ const taskHistory = gatherTaskHistoryPhase(db, since, stateDbPath, now, readOnly);
565
597
  const { tableNames, missingTables, probe } = taskHistory;
566
598
  const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
567
599
  // Same window bundle `--window-compare` builds per-window (windows.ts) —
568
600
  // reused here for the main `--since` window's improve summary and for
569
601
  // `--group-by run`, so neither re-reads `improve_runs`.
570
602
  const until = new Date(now()).toISOString();
571
- const { improve: improveSummary, perRunSummaries } = buildImproveWindowSummary(db, stateDbPath, since, until);
603
+ const { improve: improveSummary, perRunSummaries } = buildImproveWindowSummary(db, stateDbPath, since, until, readOnly);
572
604
  advisories.push(...gatherAncillaryAdvisories(db, options, egressConfigView));
573
605
  const sessionExtractionLedger = gatherSessionExtractionLedgerPhase(db, now);
574
606
  // #950: computed once (no IO beyond config/env, same as gatherEgressConfigPhase)
@@ -578,14 +610,14 @@ export async function akmHealth(options = {}) {
578
610
  // #950: `engine-last-used` reads a fixed lookback window independent of
579
611
  // `--since` (mirrors sessionExtractionLedger's independent window above).
580
612
  const engineLastUsedSinceIso = engineLastUsedSince(now);
581
- const engineLastUsed = readLastEngineUsage(stateDbPath, now);
613
+ const engineLastUsed = readLastEngineUsage(stateDbPath, now, readOnly);
582
614
  const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
583
615
  const engineProbes = await engineProbesPromise;
584
616
  const versionDrift = await versionDriftPromise;
585
617
  const schedulerBinaryDrift = await schedulerBinaryDriftPromise;
586
618
  // Read once, shared by the `thinking-control` check (#949) and the
587
619
  // `metrics.llmUsage` report field below — same window, same aggregate.
588
- const llmUsage = readLlmUsageAggregate(stateDbPath, since);
620
+ const llmUsage = readLlmUsageAggregate(stateDbPath, since, undefined, readOnly);
589
621
  // Run the ordered health-check registry. Each check projects the shared
590
622
  // context computed above into one HealthCheckResult; `channel` routes it to
591
623
  // hardChecks or advisories. Declaration order in HEALTH_CHECKS is the
@@ -34,6 +34,7 @@ import { getEntryCount } from "../../storage/repositories/index-entries-reposito
34
34
  import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../../storage/sqlite-read-snapshot.js";
35
35
  import { summarizeLlmUsageRecordsCrossTab } from "../health/llm-usage.js";
36
36
  import { drainProposals } from "../proposal/drain.js";
37
+ import { commitCollectedAccepts } from "../proposal/repository.js";
37
38
  import { describeGatedLanes, isAutonomyLaneAllowed } from "./autonomy-gate.js";
38
39
  import { akmDistill } from "./distill.js";
39
40
  import { collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
@@ -695,9 +696,13 @@ async function runTriagePrePass(run) {
695
696
  warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
696
697
  return undefined;
697
698
  }
699
+ // Triage accepts are journaled, so the end-of-run batch commit covers them; the drain does not commit
700
+ // them one by one (#1185). Without that batch (--no-sync, no git repository) they are committed here.
701
+ const commitCollector = [];
698
702
  try {
699
703
  const triageConfig = improveProfile.processes?.triage;
700
704
  return await withLlmStage("triage", () => run.drainProposalsFn({
705
+ commitCollector,
701
706
  stashDir: primaryStashDir,
702
707
  ...(options.target ? { target: options.target } : {}),
703
708
  config: options.config,
@@ -712,6 +717,10 @@ async function runTriagePrePass(run) {
712
717
  warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
713
718
  return undefined;
714
719
  }
720
+ finally {
721
+ if (!syncsStash(run))
722
+ commitCollectedAccepts(commitCollector, "akm improve triage");
723
+ }
715
724
  }
716
725
  /**
717
726
  * The exact repo-relative paths the auto-sync commit stages (#652): the paths
@@ -740,6 +749,11 @@ export function resolveSyncPathSet(input) {
740
749
  unattributed: changedPaths.filter((p) => !attributed.has(p) && !initialPaths.has(p) && inScope(p)).sort(),
741
750
  };
742
751
  }
752
+ /** True when the run's end-of-run sync will commit what it wrote: sync is on and the write target is a git repository. */
753
+ function syncsStash(run) {
754
+ const repoDir = run.writeTarget?.source.repoPath ?? run.primaryStashDir;
755
+ return Boolean(run.primaryStashDir && repoDir && run.effectiveSync.enabled !== false && isGitBackedStash(repoDir));
756
+ }
743
757
  /**
744
758
  * One `saveGitStash` call for the run's write-target bundle, shared by the
745
759
  * pre-run sync and the auto-sync commit: never throws, records a `stash_synced`
@@ -749,11 +763,10 @@ export function resolveSyncPathSet(input) {
749
763
  * `akm sync` stages (the managed paths).
750
764
  */
751
765
  function runStashSync(run, eventsCtx, phase, input) {
752
- const { writeTarget, primaryStashDir, effectiveSync, options, config, improveProfile } = run;
766
+ const { writeTarget, primaryStashDir, options, config, improveProfile } = run;
753
767
  const repoDir = writeTarget?.source.repoPath ?? primaryStashDir;
754
- if (!primaryStashDir || !repoDir || effectiveSync.enabled === false || !isGitBackedStash(repoDir)) {
768
+ if (!primaryStashDir || !repoDir || !syncsStash(run))
755
769
  return undefined;
756
- }
757
770
  const saveGitStashFn = options.saveGitStashFn ?? saveGitStash;
758
771
  const writableOverride = writeTarget ? resolveWritable(writeTarget.config) : resolveWritableOverride(config);
759
772
  const push = input.push ?? options.sync?.push ?? improveProfile.sync?.push ?? true;
@@ -786,7 +799,7 @@ function runStashSync(run, eventsCtx, phase, input) {
786
799
  const reason = errMessage(syncErr);
787
800
  warn(`improve: stash sync failed (non-fatal): ${reason}`);
788
801
  // A push that failed after the commit landed still left the commit in the repo.
789
- const committed = syncErr instanceof GitStashPushError;
802
+ const committed = syncErr instanceof GitStashPushError && syncErr.committedNow;
790
803
  record({ committed, pushed: false, skipped: true, reason });
791
804
  return { committed, pushed: false, skipped: true, reason };
792
805
  }
@@ -158,6 +158,10 @@ export function splitFrontmatter(raw) {
158
158
  const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
159
159
  return m ? { fmText: m[1] ?? "", body: m[2] ?? "" } : { fmText: null, body: raw };
160
160
  }
161
+ /** A level-1 heading line (`# text`) anywhere in a body: what reflect's title adds only when none is there. */
162
+ export function bodyHasH1(body) {
163
+ return /^#[ \t]+\S/m.test(body);
164
+ }
161
165
  /** Frontmatter text, fenced code blocks, and prose lines (an unclosed fence counts as prose). */
162
166
  function parseSections(text) {
163
167
  const { fmText, body } = splitFrontmatter(normalizeTrailingWhitespace(text));
@@ -44,7 +44,7 @@ import { contentHash } from "./content-hash.js";
44
44
  import { findAssetFilePath } from "./eligibility.js";
45
45
  import { resolveImproveExecution } from "./execution.js";
46
46
  import { recordLedgerAttempt } from "./ledger.js";
47
- import { classifyReflectChange, findReflectDefect, splitFrontmatter } from "./reflect-noise.js";
47
+ import { bodyHasH1, classifyReflectChange, findReflectDefect, splitFrontmatter, } from "./reflect-noise.js";
48
48
  import { callStageOnce, errMessage, mintProposal, noticeSet, rejectedProposalContext, resolveQualityGateJudge, runReflectQualityJudge, } from "./stage.js";
49
49
  const MAX_FEEDBACK_LINES = 10;
50
50
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
@@ -151,10 +151,17 @@ function firstProseSentence(body) {
151
151
  }
152
152
  return "";
153
153
  }
154
+ /** The body with `# title` and one blank line inserted at its first non-blank line; the rest is untouched. */
155
+ function withH1(body, title) {
156
+ const eol = body.match(/\r?\n/)?.[0] ?? "\n";
157
+ const leading = body.match(/^(?:\r?\n)*/)?.[0] ?? "";
158
+ return `${leading}# ${title}${eol}${eol}${body.slice(leading.length)}`;
159
+ }
154
160
  /**
155
161
  * The asset with the patch applied, or `undefined` when the patch changes
156
162
  * nothing (or there is no asset to patch). The body is the source's own, byte
157
- * for byte; the one thing akm adds to it is a `# title` heading it lacks. A
163
+ * for byte; the one thing akm adds to it is a `# title` heading it lacks, with
164
+ * one blank line after it (#1183). A
158
165
  * required description that neither the source nor the patch has is derived
159
166
  * from the asset's own text (#636). Only the changed keys' frontmatter lines are
160
167
  * rewritten: every other line is kept as it is, so a source whose YAML the
@@ -170,8 +177,10 @@ export function applyReflectPatch(patch, sourceContent, targetRef) {
170
177
  return undefined;
171
178
  const sourceFm = fmText !== null ? parseFrontmatter(sourceContent).data : {};
172
179
  const { title, ...changes } = patch;
173
- const addTitle = title !== undefined && !/^#[ \t]+\S/m.test(sourceBody);
174
- const body = addTitle ? `# ${title}\n\n${sourceBody.replace(/^(\r?\n)+/, "")}` : sourceBody;
180
+ const addTitle = title !== undefined && !bodyHasH1(sourceBody);
181
+ // The heading and one blank line go in at the body's first non-blank line, in
182
+ // the body's own line ending; every other byte of the body stays where it was.
183
+ const body = addTitle ? withH1(sourceBody, title) : sourceBody;
175
184
  // Only a source that already has frontmatter but no description gets one:
176
185
  // injecting a whole block, or overwriting an authored one, is out of scope.
177
186
  const refType = lenientRefType(targetRef);
@@ -198,7 +207,7 @@ export function applyReflectPatch(patch, sourceContent, targetRef) {
198
207
  const content = `---\n${serializeFrontmatter(changes)}\n---\n\n${body}`;
199
208
  return { content, frontmatter: parseFrontmatter(content).data };
200
209
  }
201
- const content = `---\n${patchFrontmatterLines(fmText, changes)}\n---\n${addTitle ? "\n" : ""}${body}`;
210
+ const content = `---\n${patchFrontmatterLines(fmText, changes)}\n---\n${body}`;
202
211
  return { content, frontmatter: parseFrontmatter(content).data };
203
212
  }
204
213
  /** The frontmatter text with each changed key's lines (the key line and its indented continuation) replaced, or appended. */
@@ -41,7 +41,7 @@ import { NEIGHBOUR_EXCERPT_CHARS, NEIGHBOUR_NOTE_COUNT, nearestKnowledgeNotes, s
41
41
  import { errMessage, noticeSet } from "../improve/stage.js";
42
42
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
43
43
  import { isRetireProposal, PAIR_PASS_GATE, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
44
- import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
44
+ import { commitCollectedAccepts, listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
45
45
  /** The gate label on every decision the drain records. */
46
46
  const DRAIN_GATE = "triage";
47
47
  /** A consolidate promotion: a memory proposed as a new knowledge note. The only kind the judgment tier may accept. */
@@ -89,6 +89,7 @@ async function acceptProposal(opts, proposal, id, reason, promoteFn, rejectFn, j
89
89
  id,
90
90
  ...(opts.target ? { target: opts.target } : {}),
91
91
  ...(opts.config ? { config: opts.config } : {}),
92
+ ...(opts.commitCollector ? { commitCollector: opts.commitCollector } : {}),
92
93
  gateDecision,
93
94
  });
94
95
  }
@@ -416,6 +417,17 @@ function readLiveAssetContent(stashDir, ref) {
416
417
  * test seams.
417
418
  */
418
419
  export async function drainProposals(opts, promoteFn = akmProposalAccept, rejectFn = akmProposalReject, judgmentSeams = {}) {
420
+ if (opts.commitCollector)
421
+ return drainPending(opts, promoteFn, rejectFn, judgmentSeams);
422
+ const commitCollector = [];
423
+ try {
424
+ return await drainPending({ ...opts, commitCollector }, promoteFn, rejectFn, judgmentSeams);
425
+ }
426
+ finally {
427
+ commitCollectedAccepts(commitCollector, "akm proposal drain");
428
+ }
429
+ }
430
+ async function drainPending(opts, promoteFn, rejectFn, judgmentSeams) {
419
431
  const exclude = opts.excludeIds ?? new Set();
420
432
  // A judgment runner's credentials are validated before any live state
421
433
  // connection, so its classification reads an isolated snapshot.
@@ -14,7 +14,7 @@
14
14
  * registration remains for either spelling.
15
15
  */
16
16
  import { parsePositiveIntFlag } from "../../cli/parse-args.js";
17
- import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js";
17
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../../cli/shared.js";
18
18
  import { resolveStashDir } from "../../core/common.js";
19
19
  import { loadConfig } from "../../core/config/config.js";
20
20
  import { UsageError } from "../../core/errors.js";
@@ -24,7 +24,7 @@ import { resolveImproveExecution } from "../improve/execution.js";
24
24
  import { extractCommand } from "../improve/extract-cli.js";
25
25
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
26
26
  import { drainProposals } from "./drain.js";
27
- import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalReopen, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
27
+ import { akmProposalAccept, akmProposalAcceptMany, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalReopen, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
28
28
  import { proposalWaitingSince } from "./proposal-types.js";
29
29
  import { proposeCommand } from "./propose-cli.js";
30
30
  export function mergeProposalDrainNotices(resolutionNotices, dispatchNotices) {
@@ -94,11 +94,14 @@ const proposalListCommand = defineJsonCommand({
94
94
  },
95
95
  });
96
96
  const proposalAcceptCommand = defineJsonCommand({
97
- meta: { name: "accept", description: "Accept a proposal and promote it into the bundle" },
97
+ meta: {
98
+ name: "accept",
99
+ description: "Accept proposals and promote them into the bundle. Several ids are accepted in one process and committed once.",
100
+ },
98
101
  args: {
99
102
  id: {
100
103
  type: "positional",
101
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skills/akm-dream). Optional when --generator is provided.",
104
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skills/akm-dream); repeat it to accept several at once. Optional when --generator is provided.",
102
105
  required: false,
103
106
  },
104
107
  queue: { type: "string", description: "Select the proposal queue by source name" },
@@ -161,10 +164,24 @@ const proposalAcceptCommand = defineJsonCommand({
161
164
  return;
162
165
  }
163
166
  if (!args.id) {
164
- throw new UsageError("Usage: akm proposal accept <id> OR akm proposal accept --generator <generator>", "MISSING_REQUIRED_ARGUMENT");
167
+ throw new UsageError("Usage: akm proposal accept <id> [<id> ...] OR akm proposal accept --generator <generator>", "MISSING_REQUIRED_ARGUMENT");
168
+ }
169
+ // citty keeps every positional token in `_`, though it declares only `id`.
170
+ const ids = (Array.isArray(args._) && args._.length > 0 ? args._ : [args.id]).map(String);
171
+ if (ids.length > 1) {
172
+ const results = await akmProposalAcceptMany({
173
+ ids,
174
+ queue: args.queue,
175
+ target: args.bundle,
176
+ });
177
+ const failed = results.filter((result) => !result.ok).length;
178
+ output("proposal-accept-batch", { accepted: results.length - failed, failed, results });
179
+ if (failed > 0)
180
+ process.exitCode = EXIT_CODES.GENERAL;
181
+ return;
165
182
  }
166
183
  const result = await akmProposalAccept({
167
- id: args.id,
184
+ id: ids[0],
168
185
  queue: args.queue,
169
186
  target: args.bundle,
170
187
  });
@@ -17,7 +17,7 @@ import { NotFoundError } from "../../core/errors.js";
17
17
  import { resolveWriteTarget } from "../../core/write-source.js";
18
18
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
19
19
  import { isRetireProposal, proposalWaitingSince } from "./proposal-types.js";
20
- import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, reopenProposals, resolveProposalId, revertProposal, } from "./repository.js";
20
+ import { commitCollectedAccepts, diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, reopenProposals, resolveProposalId, revertProposal, } from "./repository.js";
21
21
  import { validateProposal } from "./validators/proposals.js";
22
22
  // ── Shared helpers ──────────────────────────────────────────────────────────
23
23
  function resolveStash(stashDir) {
@@ -95,7 +95,12 @@ export async function akmProposalAccept(options) {
95
95
  const queue = resolveProposalQueue(options.stashDir, options.queue, config);
96
96
  const stash = queue.stashDir;
97
97
  const resolvedId = resolveProposalId(stash, options.id).id;
98
- const result = await promoteProposal(stash, config, resolvedId, { target: options.target, queueTarget: queue.target, gateDecision: options.gateDecision }, options.ctx);
98
+ const result = await promoteProposal(stash, config, resolvedId, {
99
+ target: options.target,
100
+ queueTarget: queue.target,
101
+ gateDecision: options.gateDecision,
102
+ ...(options.commitCollector ? { commitCollector: options.commitCollector } : {}),
103
+ }, options.ctx);
99
104
  return {
100
105
  schemaVersion: 1,
101
106
  ok: true,
@@ -105,6 +110,35 @@ export async function akmProposalAccept(options) {
105
110
  proposal: result.proposal,
106
111
  };
107
112
  }
113
+ /**
114
+ * Accept several proposals in one process and commit them once. An id that
115
+ * fails is reported and the rest carry on; what was accepted is committed
116
+ * either way.
117
+ */
118
+ export async function akmProposalAcceptMany(options) {
119
+ const { ids, ...rest } = options;
120
+ const commitCollector = [];
121
+ const results = [];
122
+ try {
123
+ for (const id of ids) {
124
+ try {
125
+ results.push(await akmProposalAccept({ ...rest, id, commitCollector }));
126
+ }
127
+ catch (error) {
128
+ results.push({
129
+ schemaVersion: 1,
130
+ ok: false,
131
+ id,
132
+ error: error instanceof Error ? error.message : String(error),
133
+ });
134
+ }
135
+ }
136
+ }
137
+ finally {
138
+ commitCollectedAccepts(commitCollector, "akm proposal accept");
139
+ }
140
+ return results;
141
+ }
108
142
  export async function akmProposalReject(options) {
109
143
  return withAssetMutationLease("proposal-reject", async () => {
110
144
  const config = options.config ?? loadConfig();
@@ -273,16 +307,29 @@ export async function bulkAdjudicateProposals(options) {
273
307
  const skippedForContinuityRisk = matched.filter(isContinuityExcluded).length;
274
308
  const pending = matched.filter((p) => !isContinuityExcluded(p));
275
309
  const results = [];
276
- for (const proposal of pending) {
277
- if (options.dryRun) {
278
- results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
279
- }
280
- else if (options.action === "accept") {
281
- results.push(await akmProposalAccept({ stashDir, id: proposal.id, queue: options.queue, target: options.target, config }));
282
- }
283
- else {
284
- results.push(await akmProposalReject({ stashDir, id: proposal.id, queue: options.queue, reason: options.reason, config }));
310
+ const commitCollector = [];
311
+ try {
312
+ for (const proposal of pending) {
313
+ if (options.dryRun) {
314
+ results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
315
+ }
316
+ else if (options.action === "accept") {
317
+ results.push(await akmProposalAccept({
318
+ stashDir,
319
+ id: proposal.id,
320
+ queue: options.queue,
321
+ target: options.target,
322
+ config,
323
+ commitCollector,
324
+ }));
325
+ }
326
+ else {
327
+ results.push(await akmProposalReject({ stashDir, id: proposal.id, queue: options.queue, reason: options.reason, config }));
328
+ }
285
329
  }
286
330
  }
331
+ finally {
332
+ commitCollectedAccepts(commitCollector, "akm proposal accept");
333
+ }
287
334
  return { count: results.length, results, skippedForContinuityRisk };
288
335
  }