akm-cli 0.10.261004-beta → 0.10.261005-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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261005] - 2026-10-14
10
+
11
+ ### Changed
12
+
13
+ - **A process that accepts several proposals commits once, not once per proposal.** `akm proposal drain`,
14
+ `akm proposal accept --generator` and the triage drain inside `akm improve` used to make one commit (and, on a git-kind
15
+ bundle, one push) per accepted proposal. They now commit once when they finish, with a subject such as
16
+ `akm proposal drain: 37 accepted (consolidate-pair 30, consolidate 7)` and one `<id8> <source> <ref>` body line per
17
+ proposal. In `akm improve`, triage accepts join the run's end-of-run batch commit. A single `akm proposal accept <id>`
18
+ is still one commit, and `akm proposal accept <id> <id> ...` now accepts several ids in one process and commits them
19
+ once; an id that fails is reported in the batch result (exit 1) and the others still land.
20
+
21
+ ### Fixed
22
+
23
+ - **`akm sync` pushes commits that were made earlier.** With a clean working tree it fetched and merged the upstream
24
+ but never pushed, so a commit made outside `akm sync` (an `akm proposal accept` on a filesystem bundle, an improve
25
+ run killed before its end-of-run sync, a manual commit) stayed ahead of the remote and `akm sync` reported
26
+ `pushed: false`. It now pushes those commits when the bundle is writable, has an upstream and push is allowed, and
27
+ reports `pushed: true`. `--no-push`, read-only bundles and branches without an upstream are unchanged.
28
+
9
29
  ## [0.10.261004] - 2026-10-13
10
30
 
11
31
  ### Fixed
@@ -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
  }
@@ -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
  }
@@ -1060,6 +1060,37 @@ function requireAcceptedTarget(proposal) {
1060
1060
  function acceptCommitMessage(proposal) {
1061
1061
  return `akm accept: ${proposal.source} ${proposal.id.slice(0, 8)} ${proposal.ref}`;
1062
1062
  }
1063
+ function commitAccept(collector, target, proposal, paths) {
1064
+ if (collector)
1065
+ collector.push({ target, proposal, paths });
1066
+ else
1067
+ commitAcceptedPaths(target, acceptCommitMessage(proposal), paths);
1068
+ }
1069
+ /**
1070
+ * Commit everything `collector` holds, one commit per target repository: a lone
1071
+ * accept keeps its own subject; several get `<label>: N accepted (source count,
1072
+ * ...)` with one `<id8> <source> <ref>` body line per proposal. Empties the
1073
+ * collector. A failed commit only warns, as for a single accept.
1074
+ */
1075
+ export function commitCollectedAccepts(collector, label) {
1076
+ const byRepo = new Map();
1077
+ for (const entry of collector.splice(0)) {
1078
+ const key = path.resolve(entry.target.source.repoPath ?? entry.target.source.path);
1079
+ byRepo.set(key, [...(byRepo.get(key) ?? []), entry]);
1080
+ }
1081
+ for (const entries of byRepo.values()) {
1082
+ const first = entries[0];
1083
+ if (entries.length === 1) {
1084
+ commitAcceptedPaths(first.target, acceptCommitMessage(first.proposal), first.paths);
1085
+ continue;
1086
+ }
1087
+ const counts = new Map();
1088
+ for (const { proposal } of entries)
1089
+ counts.set(proposal.source, (counts.get(proposal.source) ?? 0) + 1);
1090
+ const bySource = [...counts].sort((a, b) => b[1] - a[1]).map(([source, count]) => `${source} ${count}`);
1091
+ commitAcceptedPaths(first.target, `${label}: ${entries.length} accepted (${bySource.join(", ")})`, entries.flatMap((entry) => entry.paths), entries.map(({ proposal }) => `${proposal.id.slice(0, 8)} ${proposal.source} ${proposal.ref}`));
1092
+ }
1093
+ }
1063
1094
  /**
1064
1095
  * O1 (alpha.9): an accepted consolidate PROMOTION retires its source memory
1065
1096
  * (and its `.derived` twin), so promotion no longer leaves a memory/
@@ -1163,7 +1194,7 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1163
1194
  const paths = [assetPath];
1164
1195
  if (accepted.status === "accepted" && accepted.source === "consolidate")
1165
1196
  retirePromotionSource(mutationTarget, accepted, paths);
1166
- commitAcceptedPaths(mutationTarget, acceptCommitMessage(accepted), paths);
1197
+ commitAccept(options.commitCollector, mutationTarget, accepted, paths);
1167
1198
  return { proposal: accepted, assetPath, ref: accepted.ref };
1168
1199
  }
1169
1200
  /**
@@ -1422,7 +1453,7 @@ async function retireProposalWithLease(stashDir, config, proposal, options, ctx)
1422
1453
  paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1423
1454
  }
1424
1455
  if (paths.length > 0)
1425
- commitAcceptedPaths(mutationTarget, acceptCommitMessage(proposal), paths);
1456
+ commitAccept(options.commitCollector, mutationTarget, proposal, paths);
1426
1457
  if (archiveDirs.length === 0) {
1427
1458
  // Recorded intent, but neither file is at its original location NOR
1428
1459
  // archived under this proposal's id: something else removed the target
@@ -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
  });
@@ -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) {
@@ -658,7 +658,7 @@ Paths are relative to the rules-010 checkout. `drain.ts` is `src/commands/propos
658
658
  - **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
659
  - **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
660
  - **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`)
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>`, 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
662
  - **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
663
 
664
664
  ## 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.
@@ -438,7 +441,7 @@ tombstone `retiredAt` is more than 30 days old (`RETIRE_GRACE_DAYS`) AND
438
441
  whose archive directory is entirely git-tracked and clean (`git ls-files`
439
442
  plus `git status --porcelain -uall`, both checked once per sweep, not once
440
443
  per directory). `.git` presence alone does not guarantee a retirement was
441
- ever committed: an accept commits its own paths (`commitAcceptedPaths` in
444
+ ever committed: an accept commits its own paths, once per process (`commitAcceptedPaths` in
442
445
  `src/core/write-source.ts`), on a `kind: "filesystem"` source with a `.git`
443
446
  directory too, but a commit that failed only warned, and a retirement accepted
444
447
  by an older akm left archived bytes sitting on disk with no commit behind them
@@ -3058,6 +3058,7 @@ UUID, an 8-character UUID prefix, or an asset ref.
3058
3058
 
3059
3059
  ```sh
3060
3060
  akm proposal accept <id>
3061
+ akm proposal accept <id> <id> <id> # several at once: one commit
3061
3062
  akm proposal accept 7c115132 # 8-char UUID prefix
3062
3063
  akm proposal accept skills/akm-dream # Asset ref
3063
3064
  akm proposal accept <id> --queue team-bundle
@@ -3081,10 +3082,19 @@ Bulk-accept all pending proposals from one generator with `--generator <name>`
3081
3082
  (e.g. `reflect`, `distill`) and no positional id. Bulk accept requires
3082
3083
  `-y`/`--yes` in non-interactive shells.
3083
3084
 
3085
+ Several ids accept in one process and return one `proposal-accept-batch`
3086
+ envelope (`accepted`, `failed`, and a `results` list in argument order). An id
3087
+ that fails appears as `{ ok: false, id, error }`, the command exits 1, and the
3088
+ others are still accepted and committed.
3089
+
3084
3090
  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
3091
+ its source kind), one accept is committed with exactly the paths it wrote or
3092
+ removed and the subject `akm accept: <generator> <proposal-id-8> <ref>`. A
3093
+ process that accepts several proposals (several ids, `--generator`, or
3094
+ `akm proposal drain`) commits them once at its end: the subject is
3095
+ `akm proposal accept: N accepted (<generator> n, ...)` (`akm proposal drain: ...`
3096
+ for a drain) and the body has one `<proposal-id-8> <generator> <ref>` line per
3097
+ proposal. A retirement's archived copy and tombstone, and the source memory a
3088
3098
  consolidate promotion retires, are part of the same commit. The commit is local:
3089
3099
  `akm sync`, or the end-of-run sync of `akm improve`, pushes it. A commit that
3090
3100
  fails warns and the accept stands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.10.261004-beta",
3
+ "version": "0.10.261005-beta",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [