@forwardimpact/libwiki 0.3.0 → 0.3.1

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 (49) hide show
  1. package/README.md +30 -29
  2. package/package.json +1 -1
  3. package/src/active-claims.js +5 -5
  4. package/src/agent-roster.js +2 -2
  5. package/src/audit/admission.js +14 -11
  6. package/src/audit/conflict-markers-rule.js +9 -9
  7. package/src/audit/grammar.js +21 -18
  8. package/src/audit/rule-builders.js +20 -19
  9. package/src/audit/rules.js +30 -27
  10. package/src/audit/scopes.js +34 -33
  11. package/src/audit/status-row.js +14 -15
  12. package/src/block-renderer.js +5 -4
  13. package/src/boot.js +10 -8
  14. package/src/budget-gate.js +39 -36
  15. package/src/budget.js +3 -3
  16. package/src/cli-definition.js +19 -16
  17. package/src/commands/audit.js +3 -3
  18. package/src/commands/boot.js +1 -1
  19. package/src/commands/claim.js +44 -38
  20. package/src/commands/curate.js +34 -31
  21. package/src/commands/fix.js +68 -64
  22. package/src/commands/inbox.js +1 -1
  23. package/src/commands/init.js +12 -7
  24. package/src/commands/ledger.js +11 -11
  25. package/src/commands/log.js +19 -17
  26. package/src/commands/memo.js +4 -1
  27. package/src/commands/product-mix.js +16 -15
  28. package/src/commands/refresh.js +25 -22
  29. package/src/commands/rotate.js +9 -8
  30. package/src/commands/sync.js +26 -17
  31. package/src/conflict-markers.js +21 -21
  32. package/src/constants.js +37 -33
  33. package/src/gitattributes.js +10 -9
  34. package/src/integrity.js +29 -27
  35. package/src/issue-list-renderer.js +24 -16
  36. package/src/lane-files.js +11 -10
  37. package/src/ledger/anchor.js +6 -6
  38. package/src/ledger/projection.js +35 -32
  39. package/src/ledger/reader.js +4 -4
  40. package/src/marker-scanner.js +3 -2
  41. package/src/sanitize.js +12 -11
  42. package/src/secret-gate.js +41 -40
  43. package/src/status.js +12 -11
  44. package/src/storyboard-skeleton.js +20 -18
  45. package/src/util/agent-flag.js +8 -8
  46. package/src/util/clock.js +1 -1
  47. package/src/util/wiki-dir.js +7 -7
  48. package/src/weekly-log.js +115 -101
  49. package/src/wiki-sync.js +393 -361
package/src/wiki-sync.js CHANGED
@@ -9,15 +9,17 @@ import { currentDayIso } from "./util/clock.js";
9
9
 
10
10
  /**
11
11
  * A git `insteadOf` rule that maps a URL to itself. Some sandboxed
12
- * environments install a broad global rewrite —
13
- * `url.<local-proxy>.insteadOf = https://github.com/` — that diverts every
14
- * github.com URL, the wiki included, to a proxy that serves only the main repo
15
- * and returns 403 for the wiki. An identity rule keyed to the full wiki URL is
16
- * a longer prefix match than the broad `https://github.com/` rule, so git wins
17
- * it by longest-match and leaves the URL untouched; the request then reaches
18
- * github.com over the ambient HTTPS proxy. Where no such broad rewrite exists,
19
- * the rule is a harmless no-op. Applied inline on clone and persisted into the
20
- * clone's local config for every later network op (see {@link WikiSync#pinTransport}).
12
+ * environments install a broad global rewrite,
13
+ * `url.<local-proxy>.insteadOf = https://github.com/`. That rewrite diverts
14
+ * every github.com URL to a proxy, the wiki URL included. The proxy serves
15
+ * only the main repo and returns 403 for the wiki. An identity rule keyed to
16
+ * the full wiki URL is a longer prefix match than the broad
17
+ * `https://github.com/` rule. So git picks it by longest match and leaves the
18
+ * URL untouched. The request then reaches github.com over the ambient HTTPS
19
+ * proxy. Where no such broad rewrite exists, the rule is a harmless no-op.
20
+ * `WikiSync` applies the rule inline on clone. It also persists the rule into
21
+ * the clone's local config for every later network op
22
+ * (see {@link WikiSync#pinTransport}).
21
23
  * @param {string} url - The wiki clone URL.
22
24
  * @returns {string} A `-c`-form `url.<url>.insteadOf=<url>` entry.
23
25
  */
@@ -37,9 +39,9 @@ const PUSH_RANGE = "origin/master..HEAD";
37
39
  const UNMERGED_CODES = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]);
38
40
 
39
41
  /**
40
- * The honest push-outcome reason taxonomy (D2). Success-shaped
41
- * outcomes (`landed`, `nothing-to-push`) are returned; every other reason is
42
- * carried by a thrown {@link WikiPushFailure}.
42
+ * The honest push-outcome reason taxonomy (D2). `commitAndPush` returns the
43
+ * success-shaped outcomes (`landed`, `nothing-to-push`). A thrown
44
+ * {@link WikiPushFailure} carries every other reason.
43
45
  */
44
46
  export const PUSH_REASONS = Object.freeze({
45
47
  LANDED: "landed",
@@ -53,7 +55,7 @@ export const PUSH_REASONS = Object.freeze({
53
55
  BUDGET: "budget",
54
56
  });
55
57
 
56
- /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
58
+ /** Error thrown when a wiki pull hits a rebase conflict it cannot resolve automatically. */
57
59
  export class WikiPullConflict extends Error {
58
60
  /** Create a WikiPullConflict with the stderr output from the failed rebase. */
59
61
  constructor(stderr) {
@@ -64,8 +66,8 @@ export class WikiPullConflict extends Error {
64
66
  }
65
67
 
66
68
  // A push rejected because the remote tip moved (non-fast-forward) is the
67
- // re-apply loop's retry signal; an auth or network failure is not contention.
68
- // git surfaces a rejection on stderr with these markers.
69
+ // re-apply loop's retry signal. An auth or network failure is not
70
+ // contention. git surfaces a rejection on stderr with these markers.
69
71
  const PUSH_REJECTION_RE =
70
72
  /\b(rejected|non-fast-forward|fetch first|tip of your current branch is behind)\b/i;
71
73
 
@@ -76,17 +78,17 @@ function isPushRejection(err) {
76
78
  }
77
79
 
78
80
  /**
79
- * Error thrown when the ancestry guard refuses to commit or push because the
80
- * relationship between the history that would be published and the remote
81
- * branch cannot be positively confirmed. `kind` is `"unrelated"`
82
- * (confirmed no shared history) or `"unverifiable"` (the relationship could be
83
- * neither confirmed nor refuted). The two kinds carry distinct messages so the
84
- * operator knows which state they are recovering from.
81
+ * Error thrown when the ancestry guard refuses to commit or push. The guard
82
+ * refuses when it cannot positively confirm the relationship between the
83
+ * remote branch and the history the push would publish. `kind` is
84
+ * `"unrelated"` (confirmed no shared history) or `"unverifiable"` (the guard
85
+ * could neither confirm nor refute the relationship). The two kinds carry
86
+ * distinct messages, so the operator knows which state they recover from.
85
87
  */
86
88
  export class AncestryRefusal extends Error {
87
89
  /**
88
90
  * @param {"unrelated"|"unverifiable"} kind
89
- * @param {string} message - Recovery-naming message.
91
+ * @param {string} message - A message that names the recovery.
90
92
  */
91
93
  constructor(kind, message) {
92
94
  super(message);
@@ -97,8 +99,8 @@ export class AncestryRefusal extends Error {
97
99
 
98
100
  /**
99
101
  * Error thrown when a registered-singleton operation cannot land within the
100
- * bounded re-apply budget: contention recurred on each round, so the publish
101
- * fails loud rather than resolving the contended hunk textually.
102
+ * bounded re-apply budget. Contention recurred on each round. The publish
103
+ * fails loud and does not resolve the contended hunk textually.
102
104
  */
103
105
  export class WikiSyncConflict extends Error {
104
106
  /** @param {string[]} paths @param {string} reason */
@@ -111,14 +113,15 @@ export class WikiSyncConflict extends Error {
111
113
  }
112
114
 
113
115
  /**
114
- * The refusal reason taxonomy for {@link WikiSync.commitAndPush}. A refusal is
115
- * surfaced in the result rather than thrown, so callers reading the result keep
116
- * working; `WikiSyncRefusal.result(reason, details)` builds that result object.
117
- * `reason` is one of `mid-merge`, `stranded-merge`, `would-publish-markers`,
118
- * `introduced-scan-failed`; `workAt` (only for `stranded-merge`) names where
119
- * retained work lives. The reason set is additive to the existing `clean` and
120
- * `pushed` outcomes, so a future refusal taxonomy on this flow can union new
121
- * reasons in without rewriting the existing ones.
116
+ * The refusal reason taxonomy for {@link WikiSync.commitAndPush}. The method
117
+ * surfaces a refusal in the result and does not throw it, so a caller that
118
+ * reads the result keeps working. `WikiSyncRefusal.result(reason, details)`
119
+ * builds that result object. `reason` is one of `mid-merge`,
120
+ * `stranded-merge`, `would-publish-markers`, `introduced-scan-failed`.
121
+ * `workAt` (only for `stranded-merge`) names where retained work lives. The
122
+ * reason set is additive to the `clean` and `pushed` outcomes already
123
+ * defined. So a future refusal taxonomy on this flow can union new reasons in
124
+ * and rewrite none of the reasons that exist.
122
125
  */
123
126
  export class WikiSyncRefusal {
124
127
  /** @type {readonly string[]} The recognized refusal reasons. */
@@ -144,9 +147,9 @@ export class WikiSyncRefusal {
144
147
 
145
148
  // Markers introduced into a prose markdown surface may be legitimately quoted
146
149
  // inside a fenced code block (the false-positive surface Layer 1 exempts).
147
- // STATUS.md is data, not prose — its fenced rows are never legitimately marked
148
- // — and non-markdown files (e.g. metrics CSVs) have no quoted-form idiom, so
149
- // neither is fence-exempt on the publish path.
150
+ // STATUS.md is data. It is not prose, and its fenced rows are never
151
+ // legitimately marked. Non-markdown files (e.g. metrics CSVs) have no
152
+ // quoted-form idiom. So neither is fence-exempt on the publish path.
150
153
  function pushFenceExempt(filePath) {
151
154
  const base = path.basename(filePath);
152
155
  return filePath.endsWith(".md") && base !== "STATUS.md";
@@ -155,15 +158,16 @@ function pushFenceExempt(filePath) {
155
158
  /**
156
159
  * Error thrown when `commitAndPush` cannot honestly report a landed push.
157
160
  * `reason` is one of {@link PUSH_REASONS} other than `landed` /
158
- * `nothing-to-push`; `stashSha` names a preserved autostash on a
159
- * `residue-conflict`; on a `budget` refusal `refusals` and `surfaced` carry the
160
- * offending and surfaced-only `(file, ruleId, baseline, value)` tuples from the
161
- * size-axis re-validation gate.
161
+ * `nothing-to-push`. `stashSha` names a preserved autostash on a
162
+ * `residue-conflict`. On a `budget` refusal, `refusals` and `surfaced` carry
163
+ * the offending and surfaced-only `(file, ruleId, baseline, value)` tuples
164
+ * from the size-axis re-validation gate.
162
165
  */
163
166
  export class WikiPushFailure extends Error {
164
167
  /**
165
168
  * @param {string} reason - A {@link PUSH_REASONS} value.
166
- * @param {string} message - Operator message naming the reason and recovery.
169
+ * @param {string} message - An operator message that names the reason and
170
+ * the recovery.
167
171
  * @param {object} [opts]
168
172
  * @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
169
173
  * @param {Array<object>} [opts.refusals] - Budget refusal tuples (budget).
@@ -182,14 +186,15 @@ export class WikiPushFailure extends Error {
182
186
  /**
183
187
  * Consolidates the wiki repository's pull / rebase / conflict-resolve / push
184
188
  * flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
185
- * Replaces the pre-1370 `WikiRepo`: all shelling-out flows through
189
+ * Replaces the pre-1370 `WikiRepo`. Every subprocess call flows through
186
190
  * `gitClient` (itself over `runtime.subprocess`), so libwiki never imports
187
191
  * `node:child_process` and tests inject `createMockGitClient`.
188
192
  *
189
- * Network operations (fetch / clone / push) authenticate by resolving a token
190
- * lazily through `resolveToken` and threading it via `gitClient.withAuth`;
191
- * local operations never call `resolveToken`. The callback owns the entire
192
- * resolution policy and its throws propagate to the caller.
193
+ * Network operations (fetch / clone / push) authenticate with a token. They
194
+ * resolve the token lazily through `resolveToken`. They thread it through
195
+ * `gitClient.withAuth`. Local operations never call `resolveToken`. The
196
+ * callback owns the entire resolution policy. Its throws propagate to the
197
+ * caller.
193
198
  */
194
199
  export class WikiSync {
195
200
  #runtime;
@@ -205,7 +210,7 @@ export class WikiSync {
205
210
  * @param {string} options.wikiDir - The wiki clone directory.
206
211
  * @param {string} options.parentDir - The parent project directory (identity source).
207
212
  * @param {() => (string|null)} [options.resolveToken] - Lazy token resolver
208
- * for network operations; returns a token string or null for anonymous.
213
+ * for network operations. Returns a token string, or null for anonymous.
209
214
  */
210
215
  constructor({ runtime, gitClient, wikiDir, parentDir, resolveToken }) {
211
216
  if (!runtime) throw new Error("WikiSync: runtime is required");
@@ -250,12 +255,12 @@ export class WikiSync {
250
255
 
251
256
  /**
252
257
  * Persist the identity-`insteadOf` for the wiki's own URL into the clone's
253
- * local config, so every later network op (fetch / push / ls-remote) that
254
- * runs against the stored remote URL — and re-applies `insteadOf` at
255
- * transport time — is covered without threading `-c` through each call. The
256
- * clone command itself takes the same rule inline (the `.git/config` does not
257
- * exist yet). Idempotent; safe to re-run on a resumed clone. See
258
- * {@link selfInsteadOf} for why the rule is needed.
258
+ * local config. Every later network op (fetch / push / ls-remote) runs
259
+ * against the stored remote URL and re-applies `insteadOf` at transport
260
+ * time. The stored rule covers each one, and no call has to thread `-c`.
261
+ * The clone command itself takes the same rule inline (the `.git/config`
262
+ * does not exist yet). This method is idempotent. It is safe to re-run on a
263
+ * resumed clone. See {@link selfInsteadOf} for why the rule is needed.
259
264
  */
260
265
  async #pinTransport(url) {
261
266
  await this.#git.configSet(`url.${url}.insteadOf`, url, {
@@ -278,17 +283,18 @@ export class WikiSync {
278
283
  }
279
284
  }
280
285
 
281
- /** Fetch origin/master using token auth when available. */
286
+ /** Fetch origin/master with token auth when it is available. */
282
287
  async fetch() {
283
288
  // Resolve auth first so a misconfigured `resolveToken` still surfaces.
284
289
  const client = this.#authed();
285
290
  try {
286
291
  await client.fetch("origin", "master", { cwd: this.#wikiDir });
287
292
  } catch {
288
- // WikiRepo treated fetch as fire-and-forget (it ignored the git result);
289
- // a failed fetch leaves the local origin/master ref in place and the
290
- // rebase proceeds against it. Preserved so push/pull degrade gracefully
291
- // rather than crash when the network or credentials are unavailable.
293
+ // WikiRepo treated fetch as fire-and-forget (it ignored the git result).
294
+ // A failed fetch leaves the local origin/master ref in place, and the
295
+ // rebase proceeds against it. This code keeps that behaviour so push and
296
+ // pull degrade gracefully when the network or credentials are
297
+ // unavailable. They do not crash.
292
298
  }
293
299
  }
294
300
 
@@ -302,7 +308,7 @@ export class WikiSync {
302
308
  return r.stdout.trim() === "";
303
309
  }
304
310
 
305
- /** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
311
+ /** Fetch and rebase on origin/master. Throws WikiPullConflict if the rebase fails. */
306
312
  async pull() {
307
313
  await this.fetch();
308
314
  const r = await this.#git.rebase("origin/master", { cwd: this.#wikiDir });
@@ -313,146 +319,155 @@ export class WikiSync {
313
319
  }
314
320
 
315
321
  /**
316
- * Stage and commit working-tree changes, then reconcile on origin/master and
317
- * push — reporting an honest outcome The commit gate and the
318
- * push gate are independent so a clean tree with local commits still pushes.
322
+ * Stage and commit working-tree changes. Then reconcile on origin/master
323
+ * and push. Report an honest outcome. The commit gate and the push gate are
324
+ * independent, so a clean tree with local commits still pushes.
319
325
  *
320
- * The commit is always pathspec-scoped: with `paths` the scope is the
321
- * caller's declared write-set, and without `paths` it is the session's own
326
+ * The commit is always pathspec-scoped. With `paths` the scope is the
327
+ * caller's declared write-set. Without `paths` it is the session's own
322
328
  * dirty set (`#dirtyPaths`, the `gemba-wiki push` contract under per-session
323
- * checkout isolation). The whole-tree `add -A` sweep is gone, so foreign
324
- * content on undeclared paths is never staged and the stale fast-forward
325
- * eraser it carried no longer exists; the rebase runs with --autostash
326
- * because any foreign residue stays uncommitted.
329
+ * checkout isolation). The whole-tree `add -A` sweep is gone. So the commit
330
+ * never stages foreign content on undeclared paths, and the stale
331
+ * fast-forward eraser that sweep carried no longer exists. The rebase runs
332
+ * with --autostash because any foreign residue stays uncommitted.
327
333
  *
328
334
  * Outcome contract (D2 taxonomy):
329
- * - Returns `{ landed: true, reason: "landed" }` only when the push is
330
- * **grounded** in observed remote state — the per-ref `--porcelain` report,
331
- * or a post-push read of the remote tip containing HEAD — never inferred
332
- * from the subprocess's exit or prose.
335
+ * - Returns `{ landed: true, reason: "landed" }` only when observed remote
336
+ * state **grounds** the push. That state is the per-ref `--porcelain`
337
+ * report, or a post-push read of a remote tip that contains HEAD. The
338
+ * method never infers the outcome from the subprocess's exit or prose.
333
339
  * - Returns `{ landed: false, reason: "nothing-to-push" }` only when the
334
- * observed remote ref already contains local HEAD (never pre-fetch
335
- * arithmetic), so a stranded-resume tree re-pushes.
340
+ * observed remote ref already contains local HEAD. The method never uses
341
+ * pre-fetch arithmetic, so a stranded-resume tree re-pushes.
336
342
  * - Throws {@link WikiPushFailure} for every failure reason: `precondition`
337
- * (rebase-in-progress / detached HEAD, before mutating), `conflict`
338
- * (rebase conflict — aborted, the remote side never mechanically
339
- * discarded), `residue-conflict` (autostash pop left unmerged paths — stash
340
- * preserved by SHA), `conservation` (the push would drop foreign content),
341
- * `rejected` (non-fast-forward after a successful fetch), `transport`
342
- * (push/fetch transport failure). A failed push never loses uncommitted
343
- * work.
343
+ * (rebase-in-progress / detached HEAD, before any mutation), `conflict`
344
+ * (a rebase conflict that the flow aborts, and it never mechanically
345
+ * discards the remote side), `residue-conflict` (the autostash pop left
346
+ * unmerged paths, and the flow preserves the stash by SHA),
347
+ * `conservation` (the push would drop foreign content), `rejected`
348
+ * (non-fast-forward after a successful fetch), `transport` (push/fetch
349
+ * transport failure). A failed push never loses uncommitted work.
344
350
  *
345
- * Bounded retry (D3) is in contract: the ancestry judgment is present
346
- * (this is the second lander), so a `rejected` outcome reconciles once and
347
- * re-pushes, re-entering {@link #assertPublishable} before the replay so the
348
- * empty-remote allowance is never auto-re-granted. The retry is bounded at
349
- * one, never re-pops a conflicted autostash, and never masks the final
350
- * outcome — exhaustion reports `rejected`.
351
+ * Bounded retry (D3) is in contract. The ancestry judgment is present
352
+ * (this is the second lander). So a `rejected` outcome reconciles once and
353
+ * re-pushes. It re-enters {@link #assertPublishable} before the replay, so
354
+ * the flow never auto-re-grants the empty-remote allowance. The retry is
355
+ * bounded at one. It never re-pops a conflicted autostash. It never masks
356
+ * the final outcome. Exhaustion reports `rejected`.
351
357
  *
352
- * Ancestry guard: before the commit and again before the push,
353
- * {@link AncestryRefusal} is thrown when the published history's relationship
354
- * to `origin/master` cannot be positively confirmed — a detached HEAD, an
355
- * unborn HEAD or unrelated history against an existing remote branch, or a
356
- * remote that cannot be observed. A new wiki's first publication is allowed
357
- * only on positive evidence the remote branch is absent (a non-swallowed
358
- * `ls-remote`); mere absence of the local remote-tracking ref never grants
359
- * it, and the allowance is re-derived from live git on every call so a failed
360
- * first publication is re-judged rather than auto-re-granted. The guard
361
- * creates no commit, attempts no push, and adds no working-tree changes.
358
+ * Ancestry guard: the flow throws {@link AncestryRefusal} before the commit
359
+ * and again before the push. It throws when it cannot positively confirm
360
+ * the published history's relationship to `origin/master`. The unconfirmed
361
+ * cases are a detached HEAD, an unborn HEAD or unrelated history against a
362
+ * remote branch that exists, and a remote it cannot observe. A new wiki's
363
+ * first publication needs positive evidence that the remote branch is
364
+ * absent (a non-swallowed `ls-remote`). Mere absence of the local
365
+ * remote-tracking ref never grants it. The guard re-derives the allowance
366
+ * from live git on every call, so it re-judges a failed first publication
367
+ * and never auto-re-grants it. The guard creates no commit, attempts no
368
+ * push, and adds no working-tree changes.
362
369
  *
363
- * Before the gates, the metrics-CSV union merge declaration is ensured in
364
- * `.gitattributes`. When the ensure writes the file, the commit must carry it
365
- * regardless of the session's payload: on the pathspec-scoped path the
366
- * declaration is outside `paths` and would otherwise be autostashed aside, and
367
- * on a no-payload sync there would be no commit at all. So `.gitattributes` is
368
- * appended to the effective commit pathspec only when the ensure changed it;
369
- * when it is already present-and-correct, behavior is byte-identical to a
370
- * commit-and-push that ensures nothing.
370
+ * Before the gates, the flow ensures the metrics-CSV union merge
371
+ * declaration in `.gitattributes`. When the ensure writes the file, the
372
+ * commit must carry it whatever the session's payload is. On the
373
+ * pathspec-scoped path the declaration sits outside `paths`, and the
374
+ * autostash would otherwise set it aside. On a no-payload sync there would
375
+ * be no commit at all. So the flow appends `.gitattributes` to the
376
+ * effective commit pathspec only when the ensure changed it. When the
377
+ * declaration is already present-and-correct, behavior is byte-identical to
378
+ * a commit-and-push that ensures nothing.
371
379
  *
372
- * **Singleton merge discipline.** The discipline applies when a
373
- * rebase conflict arises for a *registered* row-structured singleton (the
374
- * single committed path is in `SINGLETON_PATHS`) and the caller supplied a
375
- * `reapply` operation. The contended hunk is then never resolved textually.
376
- * The conflicting local commit is dropped with `resetSoft`, which preserves
377
- * the working tree. Only the registered file is reset to the fresh tip with
378
- * `checkoutPaths`. The operation is re-derived against that tip's content,
379
- * re-committed, and pushed, bounded by `maxReapply`. A rejected push (the tip
380
- * moved again) drives the retry; exhaustion fails loud with
381
- * {@link WikiSyncConflict}. Foreign rows and untouched prose ride through from
382
- * the tip. Without a `reapply` the conflict keeps the `-X ours` fallback, so
383
- * prose surfaces and unregistered paths stay on the side-biased behavior.
380
+ * **Singleton merge discipline.** The discipline applies when a rebase
381
+ * conflict arises for a *registered* row-structured singleton and the
382
+ * caller supplied a `reapply` operation. A registered singleton has its
383
+ * single committed path in `SINGLETON_PATHS`. The flow then never resolves
384
+ * the contended hunk textually. It uses `resetSoft` to drop the local commit
385
+ * that conflicts, which preserves the working tree. It resets only the
386
+ * registered file to the fresh tip with `checkoutPaths`. It re-derives the
387
+ * operation against that tip's content, re-commits it, and pushes, bounded
388
+ * by `maxReapply`. A rejected push (the tip moved again) drives the retry.
389
+ * Exhaustion fails loud with {@link WikiSyncConflict}. Foreign rows and
390
+ * untouched prose ride through from the tip. Without a `reapply` the
391
+ * conflict keeps the `-X ours` fallback. Prose surfaces and unregistered
392
+ * paths then stay on the side-biased behavior.
384
393
  *
385
394
  * **Fail-closed secret gate.** After the reconcile and before the push, the
386
- * content the push introduces (the commit range `origin/master..HEAD`) is
387
- * secret-scanned fail-closed. A detected secret or an unavailable scanner
388
- * refuses the push with a distinct reason and no remote contact, unless the
389
- * matching off-by-default override is set in the environment —
390
- * `FIT_WIKI_SECRET_OVERRIDE` permits a finding, `FIT_WIKI_SCANNER_ABSENT_OK`
391
- * permits a scanner absence. Each override appends an audited line to the wiki
392
- * tree's `secret-overrides.log` before the push. A *network/credential* push
393
- * failure is distinct: it still degrades to "saved locally" (the preserved
395
+ * gate secret-scans the content the push introduces (the commit range
396
+ * `origin/master..HEAD`). The scan is fail-closed. A detected secret or an
397
+ * unavailable scanner refuses the push with a distinct reason and no remote
398
+ * contact. The matching off-by-default override lifts the refusal only when
399
+ * it is set in the environment. `FIT_WIKI_SECRET_OVERRIDE` permits a
400
+ * finding, and `FIT_WIKI_SCANNER_ABSENT_OK` permits a scanner absence. Each
401
+ * override appends an audited line to the wiki tree's
402
+ * `secret-overrides.log` before the push. A *network/credential* push
403
+ * failure is distinct. It still degrades to "saved locally" (the preserved
394
404
  * fire-and-forget behaviour).
395
405
  *
396
- * **Budget re-validation gate (size axis).** After the reconcile
397
- * (rebase / singleton re-apply re-derives content against the fresh tip) and
398
- * after the secret gate, before the push, the flow re-runs the wiki audit's
399
- * budget predicates over the **outgoing committed `HEAD`** (the tree that
400
- * publishes — not the working dir, so autostash residue never counts). The
401
- * push is refused with {@link WikiPushFailure} `budget` when it introduces or
402
- * deepens a per-file/per-predicate budget breach relative to the worse of the
403
- * writer's pre-fetch session base and the landed origin tip; the failure
404
- * carries the offending and surfaced `(file, ruleId, baseline, value)` tuples.
405
- * Equal-or-better states and foreign pre-existing breaches the writer did not
406
- * worsen pass. Summary breaches on `exemptSummaryFiles` are surfaced (rode on
407
- * the landed result's `surfaced`) rather than refused — the memo-delivery
408
- * seam, so a delivery into deficient headroom is reported, not blocked.
409
- * An unreadable baseline ref aborts the gate without refusing (the gate only
410
- * refuses a regression it can prove), never fabricating a value-0 baseline.
411
- * Inheriting the audit's rule objects by id, a future predicate change flows
412
- * through the gate with no gate-code change.
406
+ * **Budget re-validation gate (size axis).** The reconcile (rebase or
407
+ * singleton re-apply) re-derives content against the fresh tip. After that
408
+ * reconcile, after the secret gate, and before the push, the flow re-runs
409
+ * the wiki audit's budget predicates over the **outgoing committed `HEAD`**.
410
+ * That is the tree that publishes. It is not the working dir, so autostash
411
+ * residue never counts. The gate refuses the push with
412
+ * {@link WikiPushFailure} `budget` when the push introduces or deepens a
413
+ * per-file/per-predicate budget breach. The baseline is the worse of the
414
+ * writer's pre-fetch session base and the landed origin tip. The failure
415
+ * carries the offending and surfaced `(file, ruleId, baseline, value)`
416
+ * tuples. Equal-or-better states pass. Foreign pre-existing breaches the
417
+ * writer did not worsen also pass. The gate surfaces a summary breach on
418
+ * `exemptSummaryFiles` on the landed result's `surfaced` and does not refuse
419
+ * it. That is the memo-delivery seam. The gate reports a delivery into
420
+ * deficient headroom. It does not block it. An unreadable baseline ref
421
+ * aborts the gate and refuses nothing, because the gate only refuses a
422
+ * regression it can prove. The gate never fabricates a value-0 baseline. The
423
+ * gate inherits the audit's rule objects by id, so a future predicate change
424
+ * flows through the gate with no gate-code change.
413
425
  *
414
426
  * @param {string} message - The commit message.
415
- * @param {string[]} [paths] - Pathspecs limiting what gets committed.
427
+ * @param {string[]} [paths] - Pathspecs that limit what gets committed.
416
428
  * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number, exemptSummaryFiles?: string[]}} [options]
417
429
  * `reapply` re-derives the registered file's content from the operation's
418
- * own row edit against the fresh tip text; returns the new text or null when
419
- * the op is already satisfied on the tip. `exemptSummaryFiles` lists files
420
- * (relative to the wiki root) whose summary budget breach is surfaced rather
421
- * than refused (the memo-delivery seam).
430
+ * own row edit against the fresh tip text. It returns the new text, or
431
+ * null when the op is already satisfied on the tip. `exemptSummaryFiles`
432
+ * lists files (relative to the wiki root). The gate surfaces a summary
433
+ * budget breach in those files and does not refuse it (the memo-delivery
434
+ * seam).
422
435
  * @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], surfaced?: object[], workAt?: string}>}
423
- * A grounded landing (`{landed: true, reason: "landed", surfaced}`), a grounded
424
- * nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
425
- * re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
436
+ * A grounded landed outcome (`{landed: true, reason: "landed", surfaced}`),
437
+ * a grounded nothing-to-push (`{landed: false, reason: "nothing-to-push"}`),
438
+ * a re-apply outcome (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
426
439
  * or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
427
440
  * `would-publish-markers`, `introduced-scan-failed`, `secret-detected`,
428
441
  * `scanner-unavailable`).
429
442
  * @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
430
443
  * `precondition`, `conflict`, `residue-conflict`, `conservation`,
431
444
  * `rejected`, `transport`) or a `budget` breach.
432
- * @throws {AncestryRefusal} When the published history cannot be verified.
433
- * @throws {WikiSyncConflict} When the re-apply budget is exhausted.
445
+ * @throws {AncestryRefusal} When the guard cannot verify the published
446
+ * history.
447
+ * @throws {WikiSyncConflict} When the flow exhausts the re-apply budget.
434
448
  */
435
449
  async commitAndPush(
436
450
  message,
437
451
  paths,
438
452
  { reapply, maxReapply = 3, exemptSummaryFiles = [] } = {},
439
453
  ) {
440
- // Precondition (D7): refuse mid-rebase before mutating. A
441
- // detached HEAD is judged by the ancestry guard below (its `unverifiable`
442
- // refusal and this `precondition` collapse to one observable refusal); the
454
+ // Precondition (D7): refuse mid-rebase before any mutation. The ancestry
455
+ // guard below judges a detached HEAD (its `unverifiable` refusal and this
456
+ // `precondition` collapse to one observable refusal). The
443
457
  // rebase-in-progress check is the residual this guard owns.
444
458
  await this.#assertPreconditions();
445
- // Guard 1 (hole 1): refuse mid-merge before staging. An abandoned merge
446
- // leaves unmerged hunks or a pinned MERGE_HEAD; sweeping them would
447
- // silently "complete" the merge and publish the markers. Decidable from
448
- // the index/working tree alone, so it holds on a shallow clone.
459
+ // Guard 1 (hole 1): refuse mid-merge before the flow stages anything. An
460
+ // abandoned merge leaves unmerged hunks or a pinned MERGE_HEAD. A sweep of
461
+ // them would silently "complete" the merge and publish the markers. The
462
+ // index and working tree alone decide this guard, so it holds on a shallow
463
+ // clone.
449
464
  if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
450
465
  return WikiSyncRefusal.result("mid-merge");
451
466
  }
452
467
  // Capture the writer's branch point before the reconcile's fetch advances
453
- // origin/master. The local commit below does not move origin/master, so
454
- // reading it here yields the pre-edit session base; "" means an unborn
455
- // origin (a fresh clone), which the gate treats as a value-0 baseline.
468
+ // origin/master. The local commit below does not move origin/master, so a
469
+ // read here yields the pre-edit session base. "" means an unborn origin (a
470
+ // fresh clone), which the gate treats as a value-0 baseline.
456
471
  const sessionBaseSha = await this.#git.revParse("origin/master", {
457
472
  cwd: this.#wikiDir,
458
473
  });
@@ -463,18 +478,19 @@ export class WikiSync {
463
478
  this.#wikiDir,
464
479
  this.#runtime.fsSync,
465
480
  ).changed;
466
- // Attribute the commit's write-set. A caller that knows
467
- // its narrower write-set passes `paths` (the claim/release path, scoped to
468
- // MEMORY.md). The session-close `gemba-wiki push` passes none and the
469
- // write-set is the session's own dirty set, read from the working tree —
470
- // correct because the canonical mechanism runs it in a per-session isolated
481
+ // Attribute the commit's write-set. A caller that knows its narrower
482
+ // write-set passes `paths` (the claim/release path, scoped to MEMORY.md).
483
+ // The session-close `gemba-wiki push` passes none, and the write-set is
484
+ // the session's own dirty set, read from the working tree. That is correct
485
+ // because the canonical mechanism runs it in a per-session isolated
471
486
  // checkout where the dirty set holds no foreign content. The whole-tree
472
- // `commitAll` sweep is gone: it carried the stale fast-forward eraser, a
473
- // clean fast-forward no loud-conflict contract could reach, so the scoped
474
- // commit closes it at the source rather than relying on a later conflict.
487
+ // `commitAll` sweep is gone. It carried the stale fast-forward eraser, a
488
+ // clean fast-forward no loud-conflict contract could reach. So the scoped
489
+ // commit closes it at the source and does not rely on a later conflict.
475
490
  const writeSet = paths ?? (await this.#dirtyPaths());
476
- // The metrics-CSV declaration must be committed when it was just written,
477
- // even on the pathspec-scoped path; fold it into the effective pathspec.
491
+ // The commit must carry the metrics-CSV declaration when the ensure just
492
+ // wrote it, even on the pathspec-scoped path. Fold it into the effective
493
+ // pathspec.
478
494
  const commitPaths = gitattributesChanged
479
495
  ? [...writeSet, GITATTRIBUTES_FILE]
480
496
  : writeSet;
@@ -484,16 +500,17 @@ export class WikiSync {
484
500
  });
485
501
  }
486
502
 
487
- // Grounded nothing-to-push (D2): assert it only when the
488
- // observed remote ref already contains local HEAD — never pre-fetch
489
- // arithmetic, so a stranded-resume tree (clean, ahead) re-pushes.
503
+ // Grounded nothing-to-push (D2): assert it only when the observed remote
504
+ // ref already contains local HEAD. Never use pre-fetch arithmetic, so a
505
+ // stranded-resume tree (clean, ahead) re-pushes.
490
506
  const preTip = await this.#observeRemoteTip();
491
507
  if (preTip && (await this.#headContainedIn(preTip))) {
492
508
  return { landed: false, reason: PUSH_REASONS.NOTHING };
493
509
  }
494
510
 
495
- // Ancestry guard again before the push (the empty-remote allowance is
496
- // re-derived per call so a failed first publication is re-judged).
511
+ // Ancestry guard again before the push (the guard re-derives the
512
+ // empty-remote allowance per call, so it re-judges a failed first
513
+ // publication).
497
514
  await this.#assertPublishable();
498
515
  return this.#reconcileAndPush(message, paths, preTip, {
499
516
  reapply,
@@ -504,13 +521,13 @@ export class WikiSync {
504
521
  }
505
522
 
506
523
  /**
507
- * Reconcile on the remote and push, grounding the outcome (D2/D3).
508
- * Split from {@link commitAndPush} so the bounded ×1 retry (D3) re-enters the
509
- * ancestry judgment and re-reconciles without duplicating the gates. On a
510
- * `rejected` outcome it retries once: re-asserts {@link #assertPublishable}
524
+ * Reconcile on the remote and push. Ground the outcome (D2/D3). Split from
525
+ * {@link commitAndPush} so the bounded ×1 retry (D3) re-enters the ancestry
526
+ * judgment and re-reconciles with no duplicate gates. On a `rejected`
527
+ * outcome it retries once. The retry re-asserts {@link #assertPublishable}
511
528
  * (no auto-re-grant), refreshes the observed tip, and replays. The retry
512
- * never re-pops a conflicted autostash — a `residue-conflict` is refused, not
513
- * retried.
529
+ * never re-pops a conflicted autostash. The flow refuses a
530
+ * `residue-conflict`. It never retries one.
514
531
  *
515
532
  * @param {string} message
516
533
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
@@ -522,16 +539,18 @@ export class WikiSync {
522
539
  */
523
540
  async #reconcileAndPush(message, paths, preTip, opts) {
524
541
  // Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
525
- // first iteration uses `preTip`; the retry re-asserts the ancestry judgment
526
- // (no auto-re-grant) and re-observes the tip before replaying. `transport`,
527
- // `conflict`, `residue-conflict`, and `conservation` are never retried.
542
+ // first iteration uses `preTip`. The retry re-asserts the ancestry
543
+ // judgment (no auto-re-grant) and re-observes the tip before the replay.
544
+ // The loop never retries `transport`, `conflict`, `residue-conflict`, and
545
+ // `conservation`.
528
546
  let tip = preTip;
529
547
  for (let attempt = 0; attempt <= 1; attempt++) {
530
548
  const outcome = await this.#reconcileAttempt(message, paths, tip, opts);
531
549
  if (outcome.result) return outcome.result;
532
- // A non-landed grounded verdict. `transport` is never retried; `rejected`
533
- // retries once, re-entering the ancestry judgment first so the
534
- // empty-remote allowance is never auto-re-granted. Outcome never masked.
550
+ // A non-landed grounded verdict. The loop never retries `transport`. It
551
+ // retries `rejected` once, and it re-enters the ancestry judgment first
552
+ // so it never auto-re-grants the empty-remote allowance. The loop never
553
+ // masks the outcome.
535
554
  if (outcome.verdict.reason === PUSH_REASONS.TRANSPORT || attempt === 1) {
536
555
  throw outcome.verdict.error;
537
556
  }
@@ -545,10 +564,10 @@ export class WikiSync {
545
564
 
546
565
  /**
547
566
  * One reconcile-guards-push attempt. Returns `{ result }` for a terminal
548
- * outcome (a landed/re-applied push, or a pre-push gate refusal), or
549
- * `{ verdict }` carrying a non-landed grounded verdict the retry loop reads
550
- * (it owns the retry decision). Throws for the unsafe-state refusals that are
551
- * never retried: `conflict`, `residue-conflict`, `conservation`.
567
+ * outcome (a landed/re-applied push, or a pre-push gate refusal). Returns
568
+ * `{ verdict }` with a non-landed grounded verdict that the retry loop reads
569
+ * (it owns the retry decision). Throws for the unsafe-state refusals the
570
+ * loop never retries: `conflict`, `residue-conflict`, `conservation`.
552
571
  * @param {string} message
553
572
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
554
573
  * @param {string} tip - The remote tip observed before this attempt's reconcile.
@@ -562,19 +581,20 @@ export class WikiSync {
562
581
  });
563
582
 
564
583
  // Rebase conflict (D2): a non-zero rebase exit is a conflict on the rebase
565
- // itself. For a registered singleton with a `reapply` op the singleton merge
566
- // discipline re-derives the row against the fresh tip; the no-intent path
567
- // fails loud (the `mergeOursStrategy` clobber fallback is removed — the
568
- // merge-discipline fail-loud floor applies). Checked before the residue read
569
- // because a stopped rebase also leaves UU markers.
584
+ // itself. For a registered singleton with a `reapply` op, the singleton
585
+ // merge discipline re-derives the row against the fresh tip. The no-intent
586
+ // path fails loud (the `mergeOursStrategy` clobber fallback is removed,
587
+ // and the merge-discipline fail-loud floor applies). This check runs
588
+ // before the residue read because a stopped rebase also leaves UU markers.
570
589
  if (rebase.exitCode !== 0) {
571
590
  const resolved = await this.#resolveRebaseConflict(message, paths, opts);
572
591
  if (resolved) return { result: resolved };
573
592
  }
574
593
 
575
- // Residue check (D9): the rebase exited 0 but the autostash pop conflicted,
576
- // leaving unmerged paths — grounded in tree state, the sole conflict-capable
577
- // autostash site after the clobber fallback's removal. Never retried (D3).
594
+ // Residue check (D9): the rebase exited 0 but the autostash pop
595
+ // conflicted and left unmerged paths. Tree state grounds this check. The
596
+ // pop is the sole conflict-capable autostash site after the clobber
597
+ // fallback's removal. The loop never retries this refusal (D3).
578
598
  await this.#assertNoResidue();
579
599
 
580
600
  // Guard 3 (hole 3 / Layer 2): refuse to push commits that introduce an
@@ -586,21 +606,21 @@ export class WikiSync {
586
606
  // observed remote tip unless the removal is a deliberate act.
587
607
  await this.#assertConserved(tip, message);
588
608
 
589
- // Capture the pushed delta now: HEAD is the final (rebased) local tip and
609
+ // Capture the pushed delta now. HEAD is the final (rebased) local tip and
590
610
  // origin/master is still the pre-push base (the tier-1 integrity probe).
591
611
  const pushedDelta = await this.#capturePushedDelta();
592
612
 
593
613
  // Fail-closed secret gate. Scan exactly the commits this push introduces
594
- // (the reconcile above made the range correct) before any remote contact;
595
- // a finding or missing scanner refuses unless its own override is set.
614
+ // (the reconcile above made the range correct) before any remote contact.
615
+ // A finding or an absent scanner refuses unless its own override is set.
596
616
  const refusal = await this.#gateOrRefuse();
597
617
  if (refusal) return { result: refusal };
598
618
 
599
619
  // Budget re-validation gate (size axis). The reconcile above made HEAD the
600
- // final outgoing tree (rebase / singleton re-apply re-derived its
601
- // content against the fresh tip), so measuring HEAD here measures exactly
602
- // what publishes. A breach this push introduces or deepens throws `budget`;
603
- // surfaced-only memo-delivery breaches ride the landed result.
620
+ // final outgoing tree (rebase / singleton re-apply re-derived its content
621
+ // against the fresh tip). So a measurement of HEAD here measures exactly
622
+ // what publishes. A breach this push introduces or deepens throws
623
+ // `budget`. Surfaced-only memo-delivery breaches ride the landed result.
604
624
  const gate = await this.#revalidateBudgets(opts.sessionBaseSha, {
605
625
  exemptSummaryFiles: opts.exemptSummaryFiles ?? [],
606
626
  });
@@ -610,14 +630,14 @@ export class WikiSync {
610
630
 
611
631
  const verdict = await this.#groundedPush(fetched);
612
632
  if (verdict.landed) {
613
- // The push landed; any declared removal it carried is now published, so
614
- // clear the intent sidecar — it must not leak into an unrelated push.
633
+ // The push landed. Any declared removal it carried is now published, so
634
+ // clear the intent sidecar. It must not leak into an unrelated push.
615
635
  this.#clearIntentSidecar();
616
636
  const detections = await this.#tier1Probe(pushedDelta);
617
637
  const result = { landed: true, reason: PUSH_REASONS.LANDED, detections };
618
638
  // Attach `surfaced` only when the gate surfaced a (memo-delivery exempt)
619
639
  // breach, so a clean under-budget sync's landed result is byte-identical
620
- // to today's — the gate adds no happy-path behaviour change (criterion 10).
640
+ // to today's. The gate adds no happy-path behaviour change (criterion 10).
621
641
  if (gate.surfaced.length > 0) result.surfaced = gate.surfaced;
622
642
  return { result };
623
643
  }
@@ -625,11 +645,12 @@ export class WikiSync {
625
645
  }
626
646
 
627
647
  /**
628
- * Build the `budget` {@link WikiPushFailure} for a refusing gate result,
629
- * naming the worst offending file and carrying every refusal and surfaced
630
- * tuple so the operator can adjudicate (trim own content, or surface the
631
- * carried content to its owner). The gate never edits — it refuses, keeping
632
- * the commits local for re-push after the breach is resolved.
648
+ * Build the `budget` {@link WikiPushFailure} for a gate result that refuses.
649
+ * The failure names the worst offending file. It carries every refusal and
650
+ * surfaced tuple, so the operator can adjudicate (trim own content, or
651
+ * surface the carried content to its owner). The gate never edits. It
652
+ * refuses and keeps the commits local for re-push after the operator
653
+ * resolves the breach.
633
654
  * @param {{refusals: Array<object>, surfaced: Array<object>}} gate
634
655
  * @returns {WikiPushFailure}
635
656
  */
@@ -641,16 +662,16 @@ export class WikiSync {
641
662
  "gemba-wiki: refusing to push — it would introduce or deepen a budget " +
642
663
  `breach in ${files} (${lead.ruleId}: ${lead.value} vs baseline ` +
643
664
  `${lead.baseline}). Trim your own content or surface the carried ` +
644
- "content to its owner, then re-push; your work is committed locally.",
665
+ "content to its owner, then re-push. Your work is committed locally.",
645
666
  { refusals: gate.refusals, surfaced: gate.surfaced },
646
667
  );
647
668
  }
648
669
 
649
670
  /**
650
- * Run the budget gate over the outgoing tree, measuring the committed `HEAD`
651
- * against the pre-fetch session base and the landed origin tip. Delegates the
652
- * measurement, the unreadable-ref fail-visible posture, and the delta to
653
- * {@link runBudgetGate}; this method binds only the clone's git/fs/clock.
671
+ * Run the budget gate over the outgoing tree. Measure the committed `HEAD`
672
+ * against the pre-fetch session base and the landed origin tip. Delegates
673
+ * the measurement, the unreadable-ref fail-visible posture, and the delta to
674
+ * {@link runBudgetGate}. This method binds only the clone's git/fs/clock.
654
675
  * @param {string} sessionBaseSha - origin/master before fetch, or "" when unborn.
655
676
  * @param {{exemptSummaryFiles: string[]}} options
656
677
  * @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
@@ -670,9 +691,10 @@ export class WikiSync {
670
691
  }
671
692
 
672
693
  /**
673
- * Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
674
- * autostash pop conflicted under an exit-0 rebase (D9). The stash is left
675
- * intact (git already kept it) and named by SHA for recovery.
694
+ * Refuse (`residue-conflict`) when the reconcile left unmerged paths. The
695
+ * autostash pop conflicted under an exit-0 rebase (D9). The stash stays
696
+ * intact (git already kept it), and the message names it by SHA for
697
+ * recovery.
676
698
  * @throws {WikiPushFailure} `residue-conflict` when the tree carries UU paths.
677
699
  */
678
700
  async #assertNoResidue() {
@@ -683,7 +705,7 @@ export class WikiSync {
683
705
  throw new WikiPushFailure(
684
706
  PUSH_REASONS.RESIDUE_CONFLICT,
685
707
  "gemba-wiki: refusing to push — a foreign writer's residue conflicted " +
686
- "on the autostash pop; your stash is preserved at " +
708
+ "on the autostash pop. Your stash is preserved at " +
687
709
  `${stashSha || "refs/stash"} (git stash list). Resolve or pop it ` +
688
710
  "from the true tip.",
689
711
  { stashSha: stashSha || undefined },
@@ -692,9 +714,9 @@ export class WikiSync {
692
714
 
693
715
  /**
694
716
  * Capture the `origin/master..HEAD` delta for the post-push tier-1 probe. A
695
- * two-tree range diff (not a single-commit show) is correct even when HEAD is
696
- * a merge commit. Detection-only: a capture failure degrades to `null` so the
697
- * probe never gates the push it follows.
717
+ * two-tree range diff is correct even when HEAD is a merge commit. A
718
+ * single-commit show is not. This capture is detection-only. A capture
719
+ * failure degrades to `null`, so the probe never gates the push it follows.
698
720
  * @returns {Promise<string|null>}
699
721
  */
700
722
  async #capturePushedDelta() {
@@ -708,14 +730,14 @@ export class WikiSync {
708
730
  }
709
731
 
710
732
  /**
711
- * Resolve a failed rebase against the fresh tip. Aborts the rebase, then for a
712
- * registered singleton with a `reapply` op re-derives the row against the tip
713
- * via the bounded re-apply loop (the singleton merge discipline). Without a
714
- * registered `reapply` the conflict fails loud: the `-X ours` clobber fallback
715
- * is **removed** (the merge-discipline fail-loud floor), so the remote side is
716
- * never mechanically discarded. The rebase is already aborted, leaving the working
717
- * tree at `orig_head` with the autostash re-applied, so a `conflict` throw
718
- * loses no uncommitted work.
733
+ * Resolve a failed rebase against the fresh tip. Aborts the rebase. Then,
734
+ * for a registered singleton with a `reapply` op, re-derives the row against
735
+ * the tip through the bounded re-apply loop (the singleton merge
736
+ * discipline). Without a registered `reapply` the conflict fails loud. The
737
+ * `-X ours` clobber fallback is **removed** (the merge-discipline fail-loud
738
+ * floor), so the method never mechanically discards the remote side. The
739
+ * rebase is already aborted, and the working tree stays at `orig_head` with
740
+ * the autostash re-applied. So a `conflict` throw loses no uncommitted work.
719
741
  * @param {string} message - The commit message.
720
742
  * @param {string[]} [paths] - Pathspecs committed.
721
743
  * @param {{reapply?: (freshText: string) => string | null, maxReapply: number}} options
@@ -744,12 +766,13 @@ export class WikiSync {
744
766
  /**
745
767
  * Scan the content introduced by `origin/master..HEAD` for unresolved
746
768
  * conflict-marker blocks (Guard 3). Runs after the fetch + rebase/merge
747
- * resolve, so the diff is against the freshly-fetched origin tip; pre-existing
748
- * origin corruption is on the base side, never the added side, so an unrelated
749
- * writer's push is not blocked. A throw from the scan (unresolvable ref on a
750
- * shallow clone) refuses with a reason — never a silent pass.
751
- * @returns {Promise<{pushed: false, reason: string}|null>} A refusal result, or
752
- * null when nothing introduced would publish a marker.
769
+ * resolve, so the diff is against the freshly-fetched origin tip.
770
+ * Pre-existing origin corruption is on the base side. It is never on the
771
+ * added side, so the guard does not block an unrelated writer's push. A
772
+ * throw from the scan (unresolvable ref on a shallow clone) refuses with a
773
+ * reason. It never passes silently.
774
+ * @returns {Promise<{pushed: false, reason: string}|null>} A refusal result,
775
+ * or null when nothing introduced would publish a marker.
753
776
  */
754
777
  async #refuseIfIntroducedMarkers() {
755
778
  let introduced;
@@ -772,10 +795,11 @@ export class WikiSync {
772
795
  }
773
796
 
774
797
  /**
775
- * Tier-1 post-push integrity probe: re-fetch the origin tip and verify the
776
- * just-pushed delta — the full delta including shared surfaces —
777
- * is still content-present, returning detections for any absence. Reads only;
778
- * any error degrades to no detections so the probe never gates the push.
798
+ * Tier-1 post-push integrity probe: re-fetch the origin tip and verify that
799
+ * the just-pushed delta is still content-present. That delta is the full
800
+ * delta, and it covers shared surfaces. The probe returns detections for any
801
+ * absence. It only reads. Any error degrades to no detections, so the probe
802
+ * never gates the push.
779
803
  * @param {string|null} pushedDelta - `diffRange` text of the pushed delta.
780
804
  * @returns {Promise<object[]>}
781
805
  */
@@ -814,13 +838,13 @@ export class WikiSync {
814
838
 
815
839
  /**
816
840
  * Re-apply a registered singleton operation against the fresh remote tip,
817
- * bounded by `maxReapply` rounds. The caller has already aborted the rebase.
841
+ * bounded by `maxReapply` rounds. The caller already aborted the rebase.
818
842
  * Each round: refresh the tip, drop the stale local commit (`resetSoft`,
819
843
  * working tree untouched so foreign residue survives), reset only the
820
- * registered file to the tip (`checkoutPaths`, tolerating a tip that lacks
821
- * it), re-derive via `reapply`, and — when the op still changes the tip —
822
- * re-commit and push. A rejected push (the tip moved again) loops; an
823
- * unchanged op is already satisfied; bound exhaustion throws.
844
+ * registered file to the tip (`checkoutPaths`, which tolerates a tip that
845
+ * lacks it), re-derive through `reapply`, and re-commit and push when the op
846
+ * still changes the tip. A rejected push (the tip moved again) loops. An
847
+ * unchanged op is already satisfied. Bound exhaustion throws.
824
848
  */
825
849
  async #reapplyLoop(message, paths, reapply, maxReapply) {
826
850
  const filePath = path.join(this.#wikiDir, paths[0]);
@@ -828,9 +852,9 @@ export class WikiSync {
828
852
  await this.fetch();
829
853
  await this.#git.resetSoft("origin/master", { cwd: this.#wikiDir });
830
854
  // Reset only the registered file to the tip. `resetSoft` leaves the
831
- // working tree (so the dropped commit's copy of the file may linger), so
832
- // a non-zero checkout — the tip lacks the file (a founding write) — means
833
- // the fresh base is empty, NOT the lingering local copy.
855
+ // working tree, so the dropped commit's copy of the file may linger. A
856
+ // non-zero checkout means the tip lacks the file (a founding write). The
857
+ // fresh base is then empty. It is not the local copy that lingered.
834
858
  const checkout = await this.#git.checkoutPaths("origin/master", paths, {
835
859
  cwd: this.#wikiDir,
836
860
  allowMissing: true,
@@ -842,7 +866,7 @@ export class WikiSync {
842
866
  : "";
843
867
  const newText = reapply(freshText);
844
868
  if (newText === null) {
845
- // The op is already satisfied on the tip; HEAD now equals the tip.
869
+ // The op is already satisfied on the tip. HEAD now equals the tip.
846
870
  return { pushed: false, reason: "already-satisfied" };
847
871
  }
848
872
  this.#runtime.fsSync.writeFileSync(filePath, newText);
@@ -851,10 +875,10 @@ export class WikiSync {
851
875
  await this.#authed().push("origin", "master", { cwd: this.#wikiDir });
852
876
  return { pushed: true, reason: "reapplied" };
853
877
  } catch (err) {
854
- // Only a rejected push (the tip moved again) is a retry signal. An auth
855
- // or network failure is not contention: rethrow it so the caller
856
- // degrades to "saved locally" rather than burning the budget and
857
- // misreporting a conflict that never happened.
878
+ // Only a rejected push (the tip moved again) is a retry signal. An
879
+ // auth or network failure is not contention. Rethrow it so the caller
880
+ // degrades to "saved locally". The loop must not burn the budget and
881
+ // misreport a conflict that never happened.
858
882
  if (!isPushRejection(err)) throw err;
859
883
  }
860
884
  }
@@ -866,7 +890,7 @@ export class WikiSync {
866
890
  * Returns a refusal envelope to short-circuit the push, or `null` to proceed
867
891
  * (clean, or an override that wrote its audit record). An override appends a
868
892
  * secret-free line to `secret-overrides.log` and commits it into the push
869
- * range before returning `null`.
893
+ * range before the method returns `null`.
870
894
  *
871
895
  * @returns {Promise<{pushed: false, reason: "secret-detected"|"scanner-unavailable", findings?: Array<{file: string, line: number, rule: string}>}|null>}
872
896
  */
@@ -911,20 +935,19 @@ export class WikiSync {
911
935
  }
912
936
 
913
937
  /**
914
- * Refuse before mutating when a rebase is mid-flight (D7). The other D7
915
- * fixture — a detached HEAD — is deferred to the ancestry guard
916
- * ({@link #assertPublishable}), where it surfaces as an `AncestryRefusal`
917
- * ("unverifiable"): the two refusals collapse to one observable refusal, and
918
- * the ancestry guard owns the reason naming for that fixture. This guard owns
919
- * only the rebase-in-progress residual, which the ancestry guard does not
920
- * cover.
938
+ * Refuse before any mutation when a rebase is mid-flight (D7). The ancestry
939
+ * guard ({@link #assertPublishable}) takes the other D7 fixture, a detached
940
+ * HEAD. There it surfaces as an `AncestryRefusal` ("unverifiable"). The two
941
+ * refusals collapse to one observable refusal, and the ancestry guard names
942
+ * the reason for that fixture. This guard owns only the rebase-in-progress
943
+ * residual, which the ancestry guard does not cover.
921
944
  */
922
945
  async #assertPreconditions() {
923
946
  if (this.#rebaseInProgress()) {
924
947
  throw new WikiPushFailure(
925
948
  PUSH_REASONS.PRECONDITION,
926
949
  "gemba-wiki: refusing to act — a rebase is in progress. Resolve or " +
927
- "abort it before retrying; your uncommitted edit is preserved.",
950
+ "abort it before you retry. Your uncommitted edit is preserved.",
928
951
  );
929
952
  }
930
953
  }
@@ -949,12 +972,12 @@ export class WikiSync {
949
972
  }
950
973
  }
951
974
 
952
- /** Whether HEAD is contained in `tip` (grounded nothing-to-push). */
975
+ /** Whether `tip` contains HEAD (grounded nothing-to-push). */
953
976
  async #headContainedIn(tip) {
954
977
  return this.#git.isAncestor("HEAD", tip, { cwd: this.#wikiDir });
955
978
  }
956
979
 
957
- /** Fetch, returning whether it succeeded (feeds the rejected-vs-transport split). */
980
+ /** Fetch and return whether it succeeded (feeds the rejected-vs-transport split). */
958
981
  async #fetchObserved() {
959
982
  try {
960
983
  await this.#authed().fetch(REMOTE, BRANCH, { cwd: this.#wikiDir });
@@ -973,16 +996,17 @@ export class WikiSync {
973
996
  }
974
997
 
975
998
  /**
976
- * The paths dirty in the working tree — the session's own write-set when no
977
- * explicit pathspec was supplied. Each porcelain v1 line is `XY <path>` or,
978
- * for a rename, `XY <orig> -> <new>`; the destination is the path that exists
979
- * in the tree and is the one emitted (a `git mv` source no longer exists, so
980
- * adding it to the pathspec would fault). A `"`-quoted path (a name with a
981
- * space or non-ASCII byte) is unquoted so the pathspec matches the
982
- * working-tree entry. Under the canonical per-session isolated checkout this
983
- * set holds no foreign content, so committing exactly it stages the session's
984
- * own work and nothing else; wiki session writes are edits and appends, not
985
- * renames, so the destination-only scope covers the real workload.
999
+ * The paths dirty in the working tree. This is the session's own write-set
1000
+ * when the caller supplied no explicit pathspec. Each porcelain v1 line is
1001
+ * `XY <path>` or, for a rename, `XY <orig> -> <new>`. The destination is the
1002
+ * path that exists in the tree, and the method emits that one. A `git mv`
1003
+ * source no longer exists, so a pathspec that named it would fault. The
1004
+ * method unquotes a `"`-quoted path (a name with a space or non-ASCII byte),
1005
+ * so the pathspec matches the working-tree entry. Under the canonical
1006
+ * per-session isolated checkout this set holds no foreign content. So a
1007
+ * commit of exactly this set stages the session's own work and nothing else.
1008
+ * Wiki session writes are edits and appends. They are not renames, so the
1009
+ * destination-only scope covers the real workload.
986
1010
  */
987
1011
  async #dirtyPaths() {
988
1012
  const r = await this.#git.status({ cwd: this.#wikiDir });
@@ -998,15 +1022,15 @@ export class WikiSync {
998
1022
  }
999
1023
 
1000
1024
  /**
1001
- * Refuse (`conservation`) when the would-be-pushed tree drops foreign content
1002
- * present at the observed remote tip, unless a deliberate removal carries it
1003
- * (D5). After a clean rebase HEAD descends from the remote tip, so the
1004
- * tip-first diff (`D`/`M`) is exactly the net effect of the pushed history:
1005
- * a `D` is a foreign file deleted; an `M` carries the pushed history's
1006
- * authored changes, where a row rewritten to a new state is an authored
1007
- * transition (passes) but a row removed without replacement is a drop
1008
- * (refuses). Row identity is the line's leading field, so a `plan approved`
1009
- * written over a foreign row keeps the row key and passes.
1025
+ * Refuse (`conservation`) when the would-be-pushed tree drops foreign
1026
+ * content present at the observed remote tip, unless a deliberate removal
1027
+ * carries it (D5). After a clean rebase HEAD descends from the remote tip.
1028
+ * So the tip-first diff (`D`/`M`) is exactly the net effect of the pushed
1029
+ * history. A `D` is a foreign file deleted. An `M` carries the pushed
1030
+ * history's authored changes. There, a row rewritten to a new state is an
1031
+ * authored transition (passes), and a row removed without replacement is a
1032
+ * drop (refuses). Row identity is the line's first field, so a
1033
+ * `plan approved` written over a foreign row keeps the row key and passes.
1010
1034
  *
1011
1035
  * @param {string} remoteTip - The observed remote tip SHA.
1012
1036
  * @param {string} message - The pushed commit message (carries release intent).
@@ -1019,12 +1043,13 @@ export class WikiSync {
1019
1043
  const status = await this.#git.diffNameStatus(remoteTip, "HEAD", {
1020
1044
  cwd: this.#wikiDir,
1021
1045
  });
1022
- // When HEAD does not descend from the observed remote tip, the pushed
1023
- // history was written from a stale base and never saw the remote's advance,
1024
- // so a surviving-key row whose value differs from the remote is a stale
1025
- // revert (no authored transition to the restored state in the pushed
1026
- // history), not an approval-propagating transition. Only a HEAD that
1027
- // descends from the remote tip can have authored a transition over it.
1046
+ // When HEAD does not descend from the observed remote tip, the writer
1047
+ // built the pushed history from a stale base. That history never saw the
1048
+ // remote's advance. So a row that keeps its key but carries a value
1049
+ // different from the remote is a stale revert. The pushed history holds
1050
+ // no authored transition to the restored state. It is not a transition
1051
+ // that propagates an approval. Only a HEAD that descends from the remote
1052
+ // tip can author a transition over it.
1028
1053
  const headAuthoredOverRemote = await this.#git.isAncestor(
1029
1054
  remoteTip,
1030
1055
  "HEAD",
@@ -1071,20 +1096,21 @@ export class WikiSync {
1071
1096
 
1072
1097
  /**
1073
1098
  * Whether the pushed tree drops foreign content present at the remote tip.
1074
- * A whole-file deletion drops it. Otherwise a remote line is dropped only
1075
- * when neither it **nor a line sharing its identity key** survives in HEAD —
1076
- * so a row rewritten to a new state (an authored transition) is conserved,
1077
- * while a row removed outright is a drop. The pusher's own additive edits
1078
- * never trip this because they remove no remote line.
1099
+ * A whole-file deletion drops it. Otherwise the tree drops a remote line
1100
+ * only when neither it **nor a line that shares its identity key** survives
1101
+ * in HEAD. So the tree conserves a row rewritten to a new state (an authored
1102
+ * transition), and a row removed outright is a drop. The pusher's own
1103
+ * additive edits never trip this because they remove no remote line.
1079
1104
  *
1080
- * A surviving key with a changed value is an authored transition **only when
1081
- * the pushed history descends from the remote tip** (`headAuthoredOverRemote`).
1082
- * When it does not — a stale-base commit that never saw the remote's advance —
1083
- * the changed value restores a superseded state with no authoring commit, so
1084
- * it is a stale revert and counts as a drop (erases the foreign advance).
1105
+ * A key that survives with a changed value is an authored transition **only
1106
+ * when the pushed history descends from the remote tip**
1107
+ * (`headAuthoredOverRemote`). When it does not, the commit has a stale base
1108
+ * and never saw the remote's advance. The changed value then restores a
1109
+ * superseded state with no commit that authored it. So it is a stale revert
1110
+ * and counts as a drop (it erases the foreign advance).
1085
1111
  *
1086
- * `showFile` returns `null` for an absent blob; both `null` and `""` mean the
1087
- * file is gone at that ref.
1112
+ * `showFile` returns `null` for an absent blob. Both `null` and `""` mean
1113
+ * the file is gone at that ref.
1088
1114
  */
1089
1115
  #dropsForeignContent(remoteContent, headContent, headAuthoredOverRemote) {
1090
1116
  if (remoteContent == null || remoteContent === "") return false;
@@ -1097,29 +1123,31 @@ export class WikiSync {
1097
1123
  if (headSet.has(line)) return false; // exact line survives
1098
1124
  const key = this.#rowKey(line);
1099
1125
  if (key === null) {
1100
- // Unkeyed prose absent from HEAD. When the pushed history descends from
1101
- // the remote tip, the pusher saw this line and authored its edit — a
1102
- // legitimate prose change, not a foreign drop. Only a stale-base commit
1103
- // that never saw the line (a side-pick / clean-replay erasure) drops it.
1126
+ // Unkeyed prose absent from HEAD. When the pushed history descends
1127
+ // from the remote tip, the pusher saw this line and authored its edit.
1128
+ // That is a legitimate prose change. It is not a foreign drop. Only a
1129
+ // stale-base commit that never saw the line (a side-pick /
1130
+ // clean-replay erasure) drops it.
1104
1131
  return !headAuthoredOverRemote;
1105
1132
  }
1106
1133
  if (!headKeys.has(key)) return true; // key gone outright ⇒ drop
1107
- // Key survives with a changed value: an authored transition only if the
1108
- // pushed history was built over the remote tip; otherwise a stale revert.
1134
+ // The key survives with a changed value. This is an authored transition
1135
+ // only if the writer built the pushed history over the remote tip.
1136
+ // Otherwise it is a stale revert.
1109
1137
  return !headAuthoredOverRemote;
1110
1138
  });
1111
1139
  }
1112
1140
 
1113
1141
  /**
1114
- * The identity key of a structured row, used to tell an authored transition
1115
- * (same row, new state) from a drop (row gone). For a Markdown table row the
1116
- * key is the **first two cells** — the canonical Active Claims table is keyed
1117
- * by `(agent, target)`, and `agent` alone is non-unique (one agent holds many
1118
- * rows), so a single-cell key would let a real foreign-row drop masquerade as
1119
- * a transition. For a tab-delimited ledger row (e.g. STATUS
1120
- * `id<TAB>phase<TAB>status`) the key is the first field, whose later fields
1121
- * are the state that transitions. Unstructured prose has no stable key
1122
- * (`null`) and is conserved by exact-line match only.
1142
+ * The identity key of a structured row. The caller uses it to tell an
1143
+ * authored transition (same row, new state) from a drop (row gone). For a
1144
+ * Markdown table row the key is the **first two cells**. The canonical
1145
+ * Active Claims table has the key `(agent, target)`, and `agent` alone is
1146
+ * non-unique (one agent holds many rows). So a single-cell key would let a
1147
+ * real foreign-row drop masquerade as a transition. For a tab-delimited
1148
+ * ledger row (e.g. STATUS `id<TAB>phase<TAB>status`) the key is the first
1149
+ * field, whose later fields are the state that transitions. Unstructured
1150
+ * prose has no stable key (`null`). An exact-line match alone conserves it.
1123
1151
  */
1124
1152
  #rowKey(line) {
1125
1153
  const trimmed = line.trim();
@@ -1139,13 +1167,13 @@ export class WikiSync {
1139
1167
  /** Whether the removal of `file` is declared deliberate (release/expiry/sidecar). */
1140
1168
  #removalDeclared(file, message, sidecar, headAuthoredOverRemote) {
1141
1169
  // A claim release/expiry records the deliberate act in the commit message,
1142
- // and a claim/release commit is pathspec-scoped to MEMORY.md — so the
1143
- // exemption is confined to that file, never a whole-tree trim. The blanket
1144
- // message exemption is honored only when HEAD descends from the remote tip:
1145
- // a release authored over current state drops exactly the row it released,
1146
- // but a stale-base release never saw a foreign row another writer added, so
1147
- // it must not blanket-exempt that collateral live-row drop (D5 — the
1148
- // deliberate act is the released row, not a file-level pass).
1170
+ // and a claim/release commit is pathspec-scoped to MEMORY.md. So the
1171
+ // exemption covers that file only. It never covers a whole-tree trim. The
1172
+ // blanket message exemption holds only when HEAD descends from the remote
1173
+ // tip. A release authored over current state drops exactly the row it
1174
+ // released. A stale-base release never saw a foreign row another writer
1175
+ // added, so it must not blanket-exempt that collateral live-row drop (D5).
1176
+ // The deliberate act is the released row. It is not a file-level pass.
1149
1177
  if (
1150
1178
  headAuthoredOverRemote &&
1151
1179
  /^wiki: release\b/.test(message) &&
@@ -1159,10 +1187,10 @@ export class WikiSync {
1159
1187
 
1160
1188
  /**
1161
1189
  * Declare that the next push deliberately removes foreign content in `paths`
1162
- * (the cross-lane budget-trim shape, D5). The declaration is recorded
1163
- * clone-locally so it survives a stranded-push retry from the same clone, and
1164
- * is cleared only once a push lands (so the declaration never leaks into an
1165
- * unrelated later push).
1190
+ * (the cross-lane budget-trim shape, D5). This method records the
1191
+ * declaration clone-locally, so it survives a stranded-push retry from the
1192
+ * same clone. Only a landed push clears it, so the declaration never leaks
1193
+ * into an unrelated later push.
1166
1194
  * @param {string[]} paths - Files whose foreign-content removal is deliberate.
1167
1195
  */
1168
1196
  declareRemoval(paths) {
@@ -1204,12 +1232,13 @@ export class WikiSync {
1204
1232
  }
1205
1233
 
1206
1234
  /**
1207
- * Push once and classify the outcome, grounding *landed* in the
1208
- * remote-originated per-ref report or a post-push remote-tip read. Returns a
1209
- * verdict the retry loop reads (it carries the {@link WikiPushFailure} to
1210
- * throw on a terminal non-land so the loop owns the retry decision): a
1211
- * non-landed push is `rejected` when the fetch succeeded, `transport` when it
1212
- * failed or the push itself raised a transport error.
1235
+ * Push once and classify the outcome. Ground *landed* in the
1236
+ * remote-originated per-ref report or in a post-push remote-tip read.
1237
+ * Returns a verdict the retry loop reads. The verdict carries the
1238
+ * {@link WikiPushFailure} to throw on a terminal non-land, so the loop owns
1239
+ * the retry decision. A non-landed push is `rejected` when the fetch
1240
+ * succeeded. It is `transport` when the fetch failed or the push itself
1241
+ * raised a transport error.
1213
1242
  * @param {boolean} fetched - Whether the pre-push fetch observed the remote.
1214
1243
  * @returns {Promise<{landed: boolean, reason: string, error?: WikiPushFailure}>}
1215
1244
  */
@@ -1227,7 +1256,7 @@ export class WikiSync {
1227
1256
  error: new WikiPushFailure(
1228
1257
  PUSH_REASONS.TRANSPORT,
1229
1258
  "gemba-wiki: push failed at transport (network or credentials). " +
1230
- "Your work is committed locally; retry when connectivity returns.",
1259
+ "Your work is committed locally. Retry when connectivity returns.",
1231
1260
  ),
1232
1261
  };
1233
1262
  }
@@ -1257,10 +1286,10 @@ export class WikiSync {
1257
1286
  }
1258
1287
 
1259
1288
  /**
1260
- * Whether the push landed, grounded in observed remote state: the per-ref
1261
- * `--porcelain` report for `refs/heads/master` (flag ` `/`=` accepted, `!`
1262
- * rejected), falling back to a post-push remote-tip read when the report is
1263
- * unparseable.
1289
+ * Whether the push landed. Observed remote state grounds the answer. That
1290
+ * state is the per-ref `--porcelain` report for `refs/heads/master` (flag
1291
+ * ` `/`=` accepted, `!` rejected). The method falls back to a post-push
1292
+ * remote-tip read when it cannot parse the report.
1264
1293
  */
1265
1294
  async #pushLanded(result) {
1266
1295
  const verdict = this.#parsePorcelain(result.stdout);
@@ -1293,29 +1322,31 @@ export class WikiSync {
1293
1322
  }
1294
1323
 
1295
1324
  /**
1296
- * Refuse, before any commit or push, whenever the relationship between the
1297
- * history that would be published (the `master` branch ref, never bare HEAD)
1298
- * and the remote branch can be neither confirmed nor refuted. Implements the
1299
- * the ancestry decision table; throws {@link AncestryRefusal} on refusal and
1300
- * returns silently when publication is verified or the remote is positively
1301
- * empty. The emptiness probe runs only on the absent-tracking-ref path, so
1302
- * the healthy hot path adds no remote round-trip.
1325
+ * Refuse before any commit or push. Refuse whenever the guard can neither
1326
+ * confirm nor refute the relationship between the remote branch and the
1327
+ * history the push would publish. That history is the `master` branch ref,
1328
+ * never bare HEAD. Implements the ancestry decision table. Throws
1329
+ * {@link AncestryRefusal} on refusal. Returns silently when the guard
1330
+ * verifies the publication or finds the remote positively empty. The
1331
+ * emptiness probe runs only on the absent-tracking-ref path, so the healthy
1332
+ * hot path adds no remote round-trip.
1303
1333
  */
1304
1334
  async #assertPublishable() {
1305
1335
  const cwd = this.#wikiDir;
1306
1336
 
1307
- // 1. Detached HEAD: the push publishes the branch ref, not HEAD, so the
1308
- // session's commits would be silently lost. Verify nothing — refuse.
1337
+ // 1. Detached HEAD: the push publishes the branch ref. It does not publish
1338
+ // HEAD, so the session would silently lose its commits. Verify nothing.
1339
+ // Refuse.
1309
1340
  if ((await this.#git.headBranch({ cwd })) !== BRANCH) {
1310
1341
  throw new AncestryRefusal(
1311
1342
  "unverifiable",
1312
- "gemba-wiki: refusing to publish — HEAD is detached, so the configured " +
1313
- "branch would be pushed instead of your work. Re-clone the wiki.",
1343
+ "gemba-wiki: refusing to publish — HEAD is detached, so the push would " +
1344
+ "send the configured branch instead of your work. Re-clone the wiki.",
1314
1345
  );
1315
1346
  }
1316
1347
 
1317
1348
  // 2. Establish whether the remote branch is present. A resolvable local
1318
- // remote-tracking ref is sufficient; otherwise probe the remote (the
1349
+ // remote-tracking ref is sufficient. Otherwise probe the remote (the
1319
1350
  // only added round-trip, and only here).
1320
1351
  const branchPresent = await this.#git.refExists(REMOTE_BRANCH, { cwd });
1321
1352
  if (!branchPresent) {
@@ -1326,14 +1357,15 @@ export class WikiSync {
1326
1357
  throw new AncestryRefusal(
1327
1358
  "unverifiable",
1328
1359
  "gemba-wiki: refusing to publish — could not observe the remote to " +
1329
- "verify ancestry; the local change is not published.",
1360
+ "verify ancestry. The local change is not published.",
1330
1361
  );
1331
1362
  }
1332
1363
  // Positive evidence the remote branch is absent ⇒ empty-new-wiki.
1333
1364
  if (!observed) return;
1334
- // Remote branch present but no local tracking ref: fetch it into the
1335
- // tracking ref so the unborn-HEAD and merge-base steps below judge
1336
- // against the probed branch tip rather than an unresolvable ref.
1365
+ // The remote branch is present but there is no local tracking ref.
1366
+ // Fetch it into the tracking ref, so the unborn-HEAD and merge-base
1367
+ // steps below judge against the probed branch tip. They must not judge
1368
+ // against an unresolvable ref.
1337
1369
  try {
1338
1370
  await this.#git.fetch(
1339
1371
  REMOTE,
@@ -1346,7 +1378,7 @@ export class WikiSync {
1346
1378
  throw new AncestryRefusal(
1347
1379
  "unverifiable",
1348
1380
  "gemba-wiki: refusing to publish — could not fetch the remote branch " +
1349
- "to verify ancestry; the local change is not published.",
1381
+ "to verify ancestry. The local change is not published.",
1350
1382
  );
1351
1383
  }
1352
1384
  }
@@ -1378,7 +1410,7 @@ export class WikiSync {
1378
1410
  throw new AncestryRefusal(
1379
1411
  "unverifiable",
1380
1412
  "gemba-wiki: refusing to publish — could not deepen history to verify " +
1381
- "ancestry; the local change is not published.",
1413
+ "ancestry. The local change is not published.",
1382
1414
  );
1383
1415
  }
1384
1416
  if (await this.#git.mergeBaseExists(REMOTE_BRANCH, "HEAD", { cwd })) return;