@forwardimpact/libwiki 0.2.27 → 0.2.29

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/src/wiki-sync.js CHANGED
@@ -13,6 +13,25 @@ const REMOTE_BRANCH = `${REMOTE}/${BRANCH}`;
13
13
  /** The commit range a wiki push introduces relative to the remote it reconciles against. */
14
14
  const PUSH_RANGE = "origin/master..HEAD";
15
15
 
16
+ /** Working-tree status XY codes that signal an unmerged (conflicted) path. */
17
+ const UNMERGED_CODES = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]);
18
+
19
+ /**
20
+ * The honest push-outcome reason taxonomy (D2). Success-shaped
21
+ * outcomes (`landed`, `nothing-to-push`) are returned; every other reason is
22
+ * carried by a thrown {@link WikiPushFailure}.
23
+ */
24
+ export const PUSH_REASONS = Object.freeze({
25
+ LANDED: "landed",
26
+ NOTHING: "nothing-to-push",
27
+ REJECTED: "rejected",
28
+ CONFLICT: "conflict",
29
+ RESIDUE_CONFLICT: "residue-conflict",
30
+ TRANSPORT: "transport",
31
+ PRECONDITION: "precondition",
32
+ CONSERVATION: "conservation",
33
+ });
34
+
16
35
  /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
17
36
  export class WikiPullConflict extends Error {
18
37
  /** Create a WikiPullConflict with the stderr output from the failed rebase. */
@@ -112,6 +131,27 @@ function pushFenceExempt(filePath) {
112
131
  return filePath.endsWith(".md") && base !== "STATUS.md";
113
132
  }
114
133
 
134
+ /**
135
+ * Error thrown when `commitAndPush` cannot honestly report a landed push
136
+ * `reason` is one of {@link PUSH_REASONS} other than `landed` /
137
+ * `nothing-to-push`; `stashSha` names a preserved autostash on a
138
+ * `residue-conflict`.
139
+ */
140
+ export class WikiPushFailure extends Error {
141
+ /**
142
+ * @param {string} reason - A {@link PUSH_REASONS} value.
143
+ * @param {string} message - Operator message naming the reason and recovery.
144
+ * @param {object} [opts]
145
+ * @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
146
+ */
147
+ constructor(reason, message, { stashSha } = {}) {
148
+ super(message);
149
+ this.name = "WikiPushFailure";
150
+ this.reason = reason;
151
+ if (stashSha) this.stashSha = stashSha;
152
+ }
153
+ }
154
+
115
155
  /**
116
156
  * Consolidates the wiki repository's pull / rebase / conflict-resolve / push
117
157
  * flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
@@ -225,16 +265,38 @@ export class WikiSync {
225
265
  }
226
266
 
227
267
  /**
228
- * Stage and commit working-tree changes, then fetch, rebase on
229
- * origin/master (falling back to a merge with -X ours if the rebase fails),
230
- * and push if HEAD is ahead of origin/master. The commit gate and the push
231
- * gate are independent so a clean tree with local commits still pushes.
268
+ * Stage and commit working-tree changes, then reconcile on origin/master and
269
+ * push — reporting an honest outcome The commit gate and the
270
+ * push gate are independent so a clean tree with local commits still pushes.
232
271
  *
233
272
  * Without `paths` the commit sweeps the whole tree (`fit-wiki push`
234
273
  * contract). With `paths` the commit is pathspec-scoped so foreign residue
235
274
  * from parallel writers in the shared workspace is never swept in; the
236
- * rebase and merge fallback then run with --autostash because that residue
237
- * stays uncommitted in the tree.
275
+ * rebase runs with --autostash because that residue stays uncommitted.
276
+ *
277
+ * Outcome contract (D2 taxonomy):
278
+ * - Returns `{ landed: true, reason: "landed" }` only when the push is
279
+ * **grounded** in observed remote state — the per-ref `--porcelain` report,
280
+ * or a post-push read of the remote tip containing HEAD — never inferred
281
+ * from the subprocess's exit or prose.
282
+ * - Returns `{ landed: false, reason: "nothing-to-push" }` only when the
283
+ * observed remote ref already contains local HEAD (never pre-fetch
284
+ * arithmetic), so a stranded-resume tree re-pushes.
285
+ * - Throws {@link WikiPushFailure} for every failure reason: `precondition`
286
+ * (rebase-in-progress / detached HEAD, before mutating), `conflict`
287
+ * (rebase conflict — aborted, the remote side never mechanically
288
+ * discarded), `residue-conflict` (autostash pop left unmerged paths — stash
289
+ * preserved by SHA), `conservation` (the push would drop foreign content),
290
+ * `rejected` (non-fast-forward after a successful fetch), `transport`
291
+ * (push/fetch transport failure). A failed push never loses uncommitted
292
+ * work.
293
+ *
294
+ * Bounded retry (D3) is in contract: the ancestry judgment is present
295
+ * (this is the second lander), so a `rejected` outcome reconciles once and
296
+ * re-pushes, re-entering {@link #assertPublishable} before the replay so the
297
+ * empty-remote allowance is never auto-re-granted. The retry is bounded at
298
+ * one, never re-pops a conflicted autostash, and never masks the final
299
+ * outcome — exhaustion reports `rejected`.
238
300
  *
239
301
  * Ancestry guard: before the commit and again before the push,
240
302
  * {@link AncestryRefusal} is thrown when the published history's relationship
@@ -286,11 +348,25 @@ export class WikiSync {
286
348
  * `reapply` re-derives the registered file's content from the operation's
287
349
  * own row edit against the fresh tip text; returns the new text or null when
288
350
  * the op is already satisfied on the tip.
289
- * @returns {Promise<{pushed: boolean, reason: "pushed"|"clean"|"secret-detected"|"scanner-unavailable"|"mid-merge"|"stranded-merge"|"would-publish-markers"|"introduced-scan-failed", findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], workAt?: string}>}
351
+ * @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], workAt?: string}>}
352
+ * A grounded landing (`{landed: true, reason: "landed"}`), a grounded
353
+ * nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
354
+ * re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
355
+ * or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
356
+ * `would-publish-markers`, `introduced-scan-failed`, `secret-detected`,
357
+ * `scanner-unavailable`).
358
+ * @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
359
+ * `precondition`, `conflict`, `residue-conflict`, `conservation`,
360
+ * `rejected`, `transport`).
290
361
  * @throws {AncestryRefusal} When the published history cannot be verified.
291
362
  * @throws {WikiSyncConflict} When the re-apply budget is exhausted.
292
363
  */
293
364
  async commitAndPush(message, paths, { reapply, maxReapply = 3 } = {}) {
365
+ // Precondition (D7): refuse mid-rebase before mutating. A
366
+ // detached HEAD is judged by the ancestry guard below (its `unverifiable`
367
+ // refusal and this `precondition` collapse to one observable refusal); the
368
+ // rebase-in-progress check is the residual this guard owns.
369
+ await this.#assertPreconditions();
294
370
  // Guard 1 (hole 1): refuse mid-merge before staging. An abandoned merge
295
371
  // leaves unmerged hunks or a pinned MERGE_HEAD; sweeping them would
296
372
  // silently "complete" the merge and publish the markers. Decidable from
@@ -298,6 +374,8 @@ export class WikiSync {
298
374
  if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
299
375
  return WikiSyncRefusal.result("mid-merge");
300
376
  }
377
+ // Ancestry guard: refuse a detached/unborn/unrelated history
378
+ // before any mutation.
301
379
  await this.#assertPublishable();
302
380
  const gitattributesChanged = ensureMetricsCsvMergeAttribute(
303
381
  this.#wikiDir,
@@ -320,46 +398,147 @@ export class WikiSync {
320
398
  await this.#git.commitAll(message, { cwd: this.#wikiDir });
321
399
  }
322
400
  }
323
- if (!(await this.#hasCommitsAhead())) {
324
- return { pushed: false, reason: "clean", detections: [] };
401
+
402
+ // Grounded nothing-to-push (D2): assert it only when the
403
+ // observed remote ref already contains local HEAD — never pre-fetch
404
+ // arithmetic, so a stranded-resume tree (clean, ahead) re-pushes.
405
+ const preTip = await this.#observeRemoteTip();
406
+ if (preTip && (await this.#headContainedIn(preTip))) {
407
+ return { landed: false, reason: PUSH_REASONS.NOTHING };
325
408
  }
409
+
410
+ // Ancestry guard again before the push (the empty-remote allowance is
411
+ // re-derived per call so a failed first publication is re-judged).
326
412
  await this.#assertPublishable();
327
- await this.fetch();
328
- const rebase = await this.#git.rebase("origin/master", {
413
+ return this.#reconcileAndPush(message, paths, preTip, {
414
+ reapply,
415
+ maxReapply,
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Reconcile on the remote and push, grounding the outcome (D2/D3).
421
+ * Split from {@link commitAndPush} so the bounded ×1 retry (D3) re-enters the
422
+ * ancestry judgment and re-reconciles without duplicating the gates. On a
423
+ * `rejected` outcome it retries once: re-asserts {@link #assertPublishable}
424
+ * (no auto-re-grant), refreshes the observed tip, and replays. The retry
425
+ * never re-pops a conflicted autostash — a `residue-conflict` is refused, not
426
+ * retried.
427
+ *
428
+ * @param {string} message
429
+ * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
430
+ * @param {string[]} [commitPaths] - The effective commit pathspec.
431
+ * @param {string} preTip - The remote tip observed before the first reconcile.
432
+ * @param {{reapply?: function, maxReapply: number}} options
433
+ */
434
+ async #reconcileAndPush(message, paths, preTip, opts) {
435
+ // Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
436
+ // first iteration uses `preTip`; the retry re-asserts the ancestry judgment
437
+ // (no auto-re-grant) and re-observes the tip before replaying. `transport`,
438
+ // `conflict`, `residue-conflict`, and `conservation` are never retried.
439
+ let tip = preTip;
440
+ for (let attempt = 0; attempt <= 1; attempt++) {
441
+ const outcome = await this.#reconcileAttempt(message, paths, tip, opts);
442
+ if (outcome.result) return outcome.result;
443
+ // A non-landed grounded verdict. `transport` is never retried; `rejected`
444
+ // retries once, re-entering the ancestry judgment first so the
445
+ // empty-remote allowance is never auto-re-granted. Outcome never masked.
446
+ if (outcome.verdict.reason === PUSH_REASONS.TRANSPORT || attempt === 1) {
447
+ throw outcome.verdict.error;
448
+ }
449
+ await this.#assertPublishable();
450
+ tip = await this.#observeRemoteTip();
451
+ }
452
+ // Unreachable: attempt 1 always returns a result or throws above.
453
+ /* c8 ignore next */
454
+ throw new Error("commitAndPush: retry loop fell through");
455
+ }
456
+
457
+ /**
458
+ * One reconcile-guards-push attempt. Returns `{ result }` for a terminal
459
+ * outcome (a landed/re-applied push, or a pre-push gate refusal), or
460
+ * `{ verdict }` carrying a non-landed grounded verdict the retry loop reads
461
+ * (it owns the retry decision). Throws for the unsafe-state refusals that are
462
+ * never retried: `conflict`, `residue-conflict`, `conservation`.
463
+ * @param {string} message
464
+ * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
465
+ * @param {string} tip - The remote tip observed before this attempt's reconcile.
466
+ * @param {{reapply?: function, maxReapply: number}} opts
467
+ */
468
+ async #reconcileAttempt(message, paths, tip, opts) {
469
+ const fetched = await this.#fetchObserved();
470
+ const rebase = await this.#git.rebase(REMOTE_BRANCH, {
329
471
  cwd: this.#wikiDir,
330
472
  autostash: true,
331
473
  });
474
+
475
+ // Rebase conflict (D2): a non-zero rebase exit is a conflict on the rebase
476
+ // itself. For a registered singleton with a `reapply` op the singleton merge
477
+ // discipline re-derives the row against the fresh tip; the no-intent path
478
+ // fails loud (the `mergeOursStrategy` clobber fallback is removed — the
479
+ // merge-discipline fail-loud floor applies). Checked before the residue read
480
+ // because a stopped rebase also leaves UU markers.
332
481
  if (rebase.exitCode !== 0) {
333
- const resolved = await this.#resolveRebaseConflict(message, paths, {
334
- reapply,
335
- maxReapply,
336
- });
337
- if (resolved) return resolved;
482
+ const resolved = await this.#resolveRebaseConflict(message, paths, opts);
483
+ if (resolved) return { result: resolved };
338
484
  }
485
+
486
+ // Residue check (D9): the rebase exited 0 but the autostash pop conflicted,
487
+ // leaving unmerged paths — grounded in tree state, the sole conflict-capable
488
+ // autostash site after the clobber fallback's removal. Never retried (D3).
489
+ await this.#assertNoResidue();
490
+
339
491
  // Guard 3 (hole 3 / Layer 2): refuse to push commits that introduce an
340
- // unresolved conflict block.
492
+ // unresolved conflict block (the conflict-marker guard).
341
493
  const markerRefusal = await this.#refuseIfIntroducedMarkers();
342
- if (markerRefusal) return markerRefusal;
343
- // Capture the pushed delta now: HEAD is the final (rebased/merged) local
344
- // tip and origin/master is still the pre-push base.
494
+ if (markerRefusal) return { result: markerRefusal };
495
+
496
+ // Conservation guard (D5): refuse to drop foreign content present at the
497
+ // observed remote tip unless the removal is a deliberate act.
498
+ await this.#assertConserved(tip, message);
499
+
500
+ // Capture the pushed delta now: HEAD is the final (rebased) local tip and
501
+ // origin/master is still the pre-push base (the tier-1 integrity probe).
345
502
  const pushedDelta = await this.#capturePushedDelta();
503
+
346
504
  // Fail-closed secret gate. Scan exactly the commits this push introduces
347
505
  // (the reconcile above made the range correct) before any remote contact;
348
506
  // a finding or missing scanner refuses unless its own override is set.
349
507
  const refusal = await this.#gateOrRefuse();
350
- if (refusal) return refusal;
351
- // Resolve auth first so a misconfigured `resolveToken` still surfaces; the
352
- // push itself is fire-and-forget like WikiRepo (which ignored the push
353
- // result and reported pushed:true regardless), so a network/credential
354
- // failure degrades to "saved locally" rather than crashing the command.
355
- const client = this.#authed();
356
- try {
357
- await client.push("origin", "master", { cwd: this.#wikiDir });
358
- } catch {
359
- // Intentionally ignored — preserves WikiRepo's fire-and-forget push.
508
+ if (refusal) return { result: refusal };
509
+
510
+ const verdict = await this.#groundedPush(fetched);
511
+ if (verdict.landed) {
512
+ // The push landed; any declared removal it carried is now published, so
513
+ // clear the intent sidecar — it must not leak into an unrelated push.
514
+ this.#clearIntentSidecar();
515
+ const detections = await this.#tier1Probe(pushedDelta);
516
+ return {
517
+ result: { landed: true, reason: PUSH_REASONS.LANDED, detections },
518
+ };
360
519
  }
361
- const detections = await this.#tier1Probe(pushedDelta);
362
- return { pushed: true, reason: "pushed", detections };
520
+ return { verdict };
521
+ }
522
+
523
+ /**
524
+ * Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
525
+ * autostash pop conflicted under an exit-0 rebase (D9). The stash is left
526
+ * intact (git already kept it) and named by SHA for recovery.
527
+ * @throws {WikiPushFailure} `residue-conflict` when the tree carries UU paths.
528
+ */
529
+ async #assertNoResidue() {
530
+ if (!(await this.#hasUnmergedPaths())) return;
531
+ const stashSha = await this.#git.revParse("refs/stash", {
532
+ cwd: this.#wikiDir,
533
+ });
534
+ throw new WikiPushFailure(
535
+ PUSH_REASONS.RESIDUE_CONFLICT,
536
+ "fit-wiki: refusing to push — a foreign writer's residue conflicted " +
537
+ "on the autostash pop; your stash is preserved at " +
538
+ `${stashSha || "refs/stash"} (git stash list). Resolve or pop it ` +
539
+ "from the true tip.",
540
+ { stashSha: stashSha || undefined },
541
+ );
363
542
  }
364
543
 
365
544
  /**
@@ -382,15 +561,19 @@ export class WikiSync {
382
561
  /**
383
562
  * Resolve a failed rebase against the fresh tip. Aborts the rebase, then for a
384
563
  * registered singleton with a `reapply` op re-derives the row against the tip
385
- * via the bounded re-apply loop; otherwise falls back to the `-X ours` merge
386
- * with failure allowance (Guard 2). A conflicting merge is aborted and refused
387
- * rather than left mid-merge for the next sweep (hole 1) to publish.
564
+ * via the bounded re-apply loop (the singleton merge discipline). Without a
565
+ * registered `reapply` the conflict fails loud: the `-X ours` clobber fallback
566
+ * is **removed** (the merge-discipline fail-loud floor), so the remote side is
567
+ * never mechanically discarded. The rebase is already aborted, leaving the working
568
+ * tree at `orig_head` with the autostash re-applied, so a `conflict` throw
569
+ * loses no uncommitted work.
388
570
  * @param {string} message - The commit message.
389
571
  * @param {string[]} [paths] - Pathspecs committed.
390
572
  * @param {{reapply?: (freshText: string) => string | null, maxReapply: number}} options
391
- * @returns {Promise<object|null>} A terminal result (re-apply outcome or a
392
- * stranded-merge refusal), or null when the conflict resolved and the push
393
- * should proceed.
573
+ * @returns {Promise<object|null>} A terminal re-apply result, or null when the
574
+ * conflict resolved (registered op satisfied on the tip) and the push should
575
+ * proceed.
576
+ * @throws {WikiPushFailure} `conflict` when a non-registered rebase conflicts.
394
577
  */
395
578
  async #resolveRebaseConflict(message, paths, { reapply, maxReapply }) {
396
579
  await this.#git.rebaseAbort({ cwd: this.#wikiDir });
@@ -401,21 +584,12 @@ export class WikiSync {
401
584
  if (registered) {
402
585
  return this.#reapplyLoop(message, paths, reapply, maxReapply);
403
586
  }
404
- // Guard 2 (hole 2/3): the ours-strategy fallback runs with failure
405
- // allowance. On a conflict it would otherwise throw and strand a mid-merge
406
- // tree for the next sweep (hole 1) to publish; instead abort and refuse,
407
- // reporting where retained work went (the autostash stash).
408
- const merge = await this.#git.mergeOursStrategy({
409
- cwd: this.#wikiDir,
410
- ref: "origin/master",
411
- autostash: true,
412
- allowFailure: true,
413
- });
414
- if (merge.exitCode !== 0) {
415
- await this.#git.mergeAbort({ cwd: this.#wikiDir });
416
- return WikiSyncRefusal.result("stranded-merge", { workAt: "stash" });
417
- }
418
- return null;
587
+ // No-intent path: fail loud rather than discard the remote side (D2).
588
+ throw new WikiPushFailure(
589
+ PUSH_REASONS.CONFLICT,
590
+ "fit-wiki: refusing to push — rebase conflict with the remote. " +
591
+ "Resolve or retry from the true tip (fit-wiki pull, then push).",
592
+ );
419
593
  }
420
594
 
421
595
  /**
@@ -587,11 +761,354 @@ export class WikiSync {
587
761
  return null;
588
762
  }
589
763
 
590
- async #hasCommitsAhead() {
591
- const count = await this.#git.revListCount("origin/master..HEAD", {
764
+ /**
765
+ * Refuse before mutating when a rebase is mid-flight (D7). The other D7
766
+ * fixture — a detached HEAD — is deferred to the ancestry guard
767
+ * ({@link #assertPublishable}), where it surfaces as an `AncestryRefusal`
768
+ * ("unverifiable"): the two refusals collapse to one observable refusal, and
769
+ * the ancestry guard owns the reason naming for that fixture. This guard owns
770
+ * only the rebase-in-progress residual, which the ancestry guard does not
771
+ * cover.
772
+ */
773
+ async #assertPreconditions() {
774
+ if (this.#rebaseInProgress()) {
775
+ throw new WikiPushFailure(
776
+ PUSH_REASONS.PRECONDITION,
777
+ "fit-wiki: refusing to act — a rebase is in progress. Resolve or " +
778
+ "abort it before retrying; your uncommitted edit is preserved.",
779
+ );
780
+ }
781
+ }
782
+
783
+ /** Whether a rebase is mid-flight (`.git/rebase-merge` or `rebase-apply`). */
784
+ #rebaseInProgress() {
785
+ const gitDir = path.join(this.#wikiDir, ".git");
786
+ return (
787
+ this.#runtime.fsSync.existsSync(path.join(gitDir, "rebase-merge")) ||
788
+ this.#runtime.fsSync.existsSync(path.join(gitDir, "rebase-apply"))
789
+ );
790
+ }
791
+
792
+ /** Read the remote ref tip fresh, or "" when absent/unobservable. */
793
+ async #observeRemoteTip() {
794
+ try {
795
+ return await this.#authed().remoteRefTip(REMOTE, BRANCH, {
796
+ cwd: this.#wikiDir,
797
+ });
798
+ } catch {
799
+ return "";
800
+ }
801
+ }
802
+
803
+ /** Whether HEAD is contained in `tip` (grounded nothing-to-push). */
804
+ async #headContainedIn(tip) {
805
+ return this.#git.isAncestor("HEAD", tip, { cwd: this.#wikiDir });
806
+ }
807
+
808
+ /** Fetch, returning whether it succeeded (feeds the rejected-vs-transport split). */
809
+ async #fetchObserved() {
810
+ try {
811
+ await this.#authed().fetch(REMOTE, BRANCH, { cwd: this.#wikiDir });
812
+ return true;
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+
818
+ /** Whether the working tree carries unmerged (conflicted) paths. */
819
+ async #hasUnmergedPaths() {
820
+ const r = await this.#git.statusPorcelain({ cwd: this.#wikiDir });
821
+ return r.stdout
822
+ .split("\n")
823
+ .some((line) => UNMERGED_CODES.has(line.slice(0, 2)));
824
+ }
825
+
826
+ /**
827
+ * Refuse (`conservation`) when the would-be-pushed tree drops foreign content
828
+ * present at the observed remote tip, unless a deliberate removal carries it
829
+ * (D5). After a clean rebase HEAD descends from the remote tip, so the
830
+ * tip-first diff (`D`/`M`) is exactly the net effect of the pushed history:
831
+ * a `D` is a foreign file deleted; an `M` carries the pushed history's
832
+ * authored changes, where a row rewritten to a new state is an authored
833
+ * transition (passes) but a row removed without replacement is a drop
834
+ * (refuses). Row identity is the line's leading field, so a `plan approved`
835
+ * written over a foreign row keeps the row key and passes.
836
+ *
837
+ * @param {string} remoteTip - The observed remote tip SHA.
838
+ * @param {string} message - The pushed commit message (carries release intent).
839
+ */
840
+ async #assertConserved(remoteTip, message) {
841
+ if (!remoteTip) {
842
+ this.#reportConservation("pass");
843
+ return;
844
+ }
845
+ const status = await this.#git.diffNameStatus(remoteTip, "HEAD", {
592
846
  cwd: this.#wikiDir,
593
847
  });
594
- return count > 0;
848
+ // When HEAD does not descend from the observed remote tip, the pushed
849
+ // history was written from a stale base and never saw the remote's advance,
850
+ // so a surviving-key row whose value differs from the remote is a stale
851
+ // revert (no authored transition to the restored state in the pushed
852
+ // history), not an approval-propagating transition. Only a HEAD that
853
+ // descends from the remote tip can have authored a transition over it.
854
+ const headAuthoredOverRemote = await this.#git.isAncestor(
855
+ remoteTip,
856
+ "HEAD",
857
+ { cwd: this.#wikiDir },
858
+ );
859
+ const sidecar = this.#readIntentSidecar();
860
+ let declaredAny = false;
861
+ for (const line of status.split("\n")) {
862
+ if (!line) continue;
863
+ const [code, file] = line.split("\t");
864
+ if (code !== "D" && code !== "M") continue; // A/R/etc. add nothing to drop
865
+
866
+ const remoteContent = await this.#git.showFile(remoteTip, file, {
867
+ cwd: this.#wikiDir,
868
+ });
869
+ const headContent = await this.#git.showFile("HEAD", file, {
870
+ cwd: this.#wikiDir,
871
+ });
872
+ if (
873
+ !this.#dropsForeignContent(
874
+ remoteContent,
875
+ headContent,
876
+ headAuthoredOverRemote,
877
+ )
878
+ )
879
+ continue;
880
+
881
+ if (
882
+ this.#removalDeclared(file, message, sidecar, headAuthoredOverRemote)
883
+ ) {
884
+ declaredAny = true;
885
+ continue;
886
+ }
887
+ this.#reportConservation("refusal");
888
+ throw new WikiPushFailure(
889
+ PUSH_REASONS.CONSERVATION,
890
+ "fit-wiki: refusing to push — it would drop another writer's " +
891
+ `content in ${file} that is present on the remote. Pull and ` +
892
+ "re-apply, or declare the removal if it is deliberate.",
893
+ );
894
+ }
895
+ this.#reportConservation(declaredAny ? "declared-removal" : "pass");
896
+ }
897
+
898
+ /**
899
+ * Whether the pushed tree drops foreign content present at the remote tip.
900
+ * A whole-file deletion drops it. Otherwise a remote line is dropped only
901
+ * when neither it **nor a line sharing its identity key** survives in HEAD —
902
+ * so a row rewritten to a new state (an authored transition) is conserved,
903
+ * while a row removed outright is a drop. The pusher's own additive edits
904
+ * never trip this because they remove no remote line.
905
+ *
906
+ * A surviving key with a changed value is an authored transition **only when
907
+ * the pushed history descends from the remote tip** (`headAuthoredOverRemote`).
908
+ * When it does not — a stale-base commit that never saw the remote's advance —
909
+ * the changed value restores a superseded state with no authoring commit, so
910
+ * it is a stale revert and counts as a drop (erases the foreign advance).
911
+ *
912
+ * `showFile` returns `null` for an absent blob; both `null` and `""` mean the
913
+ * file is gone at that ref.
914
+ */
915
+ #dropsForeignContent(remoteContent, headContent, headAuthoredOverRemote) {
916
+ if (remoteContent == null || remoteContent === "") return false;
917
+ if (headContent == null || headContent === "") return true;
918
+ const headLines = headContent.split("\n");
919
+ const headSet = new Set(headLines);
920
+ const headKeys = new Set(headLines.map((l) => this.#rowKey(l)));
921
+ return remoteContent.split("\n").some((line) => {
922
+ if (line.trim() === "") return false;
923
+ if (headSet.has(line)) return false; // exact line survives
924
+ const key = this.#rowKey(line);
925
+ if (key === null) {
926
+ // Unkeyed prose absent from HEAD. When the pushed history descends from
927
+ // the remote tip, the pusher saw this line and authored its edit — a
928
+ // legitimate prose change, not a foreign drop. Only a stale-base commit
929
+ // that never saw the line (a side-pick / clean-replay erasure) drops it.
930
+ return !headAuthoredOverRemote;
931
+ }
932
+ if (!headKeys.has(key)) return true; // key gone outright ⇒ drop
933
+ // Key survives with a changed value: an authored transition only if the
934
+ // pushed history was built over the remote tip; otherwise a stale revert.
935
+ return !headAuthoredOverRemote;
936
+ });
937
+ }
938
+
939
+ /**
940
+ * The identity key of a structured row, used to tell an authored transition
941
+ * (same row, new state) from a drop (row gone). For a Markdown table row the
942
+ * key is the **first two cells** — the canonical Active Claims table is keyed
943
+ * by `(agent, target)`, and `agent` alone is non-unique (one agent holds many
944
+ * rows), so a single-cell key would let a real foreign-row drop masquerade as
945
+ * a transition. For a tab-delimited ledger row (e.g. STATUS
946
+ * `id<TAB>phase<TAB>status`) the key is the first field, whose later fields
947
+ * are the state that transitions. Unstructured prose has no stable key
948
+ * (`null`) and is conserved by exact-line match only.
949
+ */
950
+ #rowKey(line) {
951
+ const trimmed = line.trim();
952
+ if (trimmed.startsWith("|")) {
953
+ const cells = trimmed
954
+ .split("|")
955
+ .slice(1, -1)
956
+ .map((c) => c.trim());
957
+ const a = cells[0] ?? "";
958
+ const b = cells[1] ?? "";
959
+ return a || b ? `|${a}|${b}` : null;
960
+ }
961
+ if (line.includes("\t")) return `\t${line.split("\t")[0]}`;
962
+ return null;
963
+ }
964
+
965
+ /** Whether the removal of `file` is declared deliberate (release/expiry/sidecar). */
966
+ #removalDeclared(file, message, sidecar, headAuthoredOverRemote) {
967
+ // A claim release/expiry records the deliberate act in the commit message,
968
+ // and a claim/release commit is pathspec-scoped to MEMORY.md — so the
969
+ // exemption is confined to that file, never a whole-tree trim. The blanket
970
+ // message exemption is honored only when HEAD descends from the remote tip:
971
+ // a release authored over current state drops exactly the row it released,
972
+ // but a stale-base release never saw a foreign row another writer added, so
973
+ // it must not blanket-exempt that collateral live-row drop (D5 — the
974
+ // deliberate act is the released row, not a file-level pass).
975
+ if (
976
+ headAuthoredOverRemote &&
977
+ /^wiki: release\b/.test(message) &&
978
+ file === "MEMORY.md"
979
+ )
980
+ return true;
981
+ // The intent sidecar names the specific file and survives a stranded-push
982
+ // retry, so it passes regardless of base freshness (D5 retry-survival).
983
+ return sidecar.includes(file);
984
+ }
985
+
986
+ /**
987
+ * Declare that the next push deliberately removes foreign content in `paths`
988
+ * (the cross-lane budget-trim shape, D5). The declaration is recorded
989
+ * clone-locally so it survives a stranded-push retry from the same clone, and
990
+ * is cleared only once a push lands (so the declaration never leaks into an
991
+ * unrelated later push).
992
+ * @param {string[]} paths - Files whose foreign-content removal is deliberate.
993
+ */
994
+ declareRemoval(paths) {
995
+ if (!paths?.length) return;
996
+ const existing = this.#readIntentSidecar();
997
+ const merged = [...new Set([...existing, ...paths])];
998
+ this.#runtime.fsSync.writeFileSync(
999
+ this.#sidecarPath(),
1000
+ `${merged.join("\n")}\n`,
1001
+ );
1002
+ }
1003
+
1004
+ #sidecarPath() {
1005
+ return path.join(this.#wikiDir, ".git", "fit-wiki-removal-intent");
1006
+ }
1007
+
1008
+ /** Read the clone-local removal-intent sidecar (declared deliberate removals). */
1009
+ #readIntentSidecar() {
1010
+ const sidecar = this.#sidecarPath();
1011
+ if (!this.#runtime.fsSync.existsSync(sidecar)) return [];
1012
+ return this.#runtime.fsSync
1013
+ .readFileSync(sidecar, "utf-8")
1014
+ .split("\n")
1015
+ .map((l) => l.trim())
1016
+ .filter(Boolean);
1017
+ }
1018
+
1019
+ /** Clear the removal-intent sidecar after a landed push. */
1020
+ #clearIntentSidecar() {
1021
+ const sidecar = this.#sidecarPath();
1022
+ if (this.#runtime.fsSync.existsSync(sidecar)) {
1023
+ this.#runtime.fsSync.unlinkSync(sidecar);
1024
+ }
1025
+ }
1026
+
1027
+ /** Emit the per-event conservation self-report at the guard seam (D8). */
1028
+ #reportConservation(outcomeClass) {
1029
+ this.#runtime.proc.stderr.write(`wiki-conservation: ${outcomeClass}\n`);
1030
+ }
1031
+
1032
+ /**
1033
+ * Push once and classify the outcome, grounding *landed* in the
1034
+ * remote-originated per-ref report or a post-push remote-tip read. Returns a
1035
+ * verdict the retry loop reads (it carries the {@link WikiPushFailure} to
1036
+ * throw on a terminal non-land so the loop owns the retry decision): a
1037
+ * non-landed push is `rejected` when the fetch succeeded, `transport` when it
1038
+ * failed or the push itself raised a transport error.
1039
+ * @param {boolean} fetched - Whether the pre-push fetch observed the remote.
1040
+ * @returns {Promise<{landed: boolean, reason: string, error?: WikiPushFailure}>}
1041
+ */
1042
+ async #groundedPush(fetched) {
1043
+ const client = this.#authed();
1044
+ let result;
1045
+ try {
1046
+ result = await client.pushPorcelain(REMOTE, BRANCH, {
1047
+ cwd: this.#wikiDir,
1048
+ });
1049
+ } catch {
1050
+ return {
1051
+ landed: false,
1052
+ reason: PUSH_REASONS.TRANSPORT,
1053
+ error: new WikiPushFailure(
1054
+ PUSH_REASONS.TRANSPORT,
1055
+ "fit-wiki: push failed at transport (network or credentials). " +
1056
+ "Your work is committed locally; retry when connectivity returns.",
1057
+ ),
1058
+ };
1059
+ }
1060
+ if (await this.#pushLanded(result)) {
1061
+ return { landed: true, reason: PUSH_REASONS.LANDED };
1062
+ }
1063
+ if (!fetched) {
1064
+ return {
1065
+ landed: false,
1066
+ reason: PUSH_REASONS.TRANSPORT,
1067
+ error: new WikiPushFailure(
1068
+ PUSH_REASONS.TRANSPORT,
1069
+ "fit-wiki: push did not land and the remote could not be observed " +
1070
+ "(network or credentials). Your work is committed locally.",
1071
+ ),
1072
+ };
1073
+ }
1074
+ return {
1075
+ landed: false,
1076
+ reason: PUSH_REASONS.REJECTED,
1077
+ error: new WikiPushFailure(
1078
+ PUSH_REASONS.REJECTED,
1079
+ "fit-wiki: push rejected — the remote advanced. Rerun from the true " +
1080
+ "tip (fit-wiki pull, then push).",
1081
+ ),
1082
+ };
1083
+ }
1084
+
1085
+ /**
1086
+ * Whether the push landed, grounded in observed remote state: the per-ref
1087
+ * `--porcelain` report for `refs/heads/master` (flag ` `/`=` accepted, `!`
1088
+ * rejected), falling back to a post-push remote-tip read when the report is
1089
+ * unparseable.
1090
+ */
1091
+ async #pushLanded(result) {
1092
+ const verdict = this.#parsePorcelain(result.stdout);
1093
+ if (verdict === "accepted") return true;
1094
+ if (verdict === "rejected") return false;
1095
+ // Ambiguous report ⇒ ground in a fresh remote-tip read.
1096
+ const tip = await this.#observeRemoteTip();
1097
+ return tip ? this.#headContainedIn(tip) : false;
1098
+ }
1099
+
1100
+ /** Classify a `push --porcelain` report for the pushed branch ref. */
1101
+ #parsePorcelain(stdout) {
1102
+ for (const line of stdout.split("\n")) {
1103
+ const fields = line.split("\t");
1104
+ if (fields.length < 2) continue;
1105
+ const flag = fields[0];
1106
+ const refspec = fields[1];
1107
+ if (!refspec.includes(`refs/heads/${BRANCH}`)) continue;
1108
+ if (flag === " " || flag === "=") return "accepted";
1109
+ if (flag === "!") return "rejected";
1110
+ }
1111
+ return "ambiguous";
595
1112
  }
596
1113
 
597
1114
  /** Whether the wiki clone is shallow (has a `.git/shallow` file). */