@ericrisco/rsc 3.0.8 → 3.0.10

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.
@@ -36,7 +36,7 @@
36
36
  // but Node. Never throws into a hook; whatever goes wrong is said on the next message.
37
37
  import { execFileSync, spawn } from 'node:child_process';
38
38
  import {
39
- closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
39
+ appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
40
40
  unlinkSync, writeFileSync,
41
41
  } from 'node:fs';
42
42
  import { dirname, isAbsolute, join, resolve } from 'node:path';
@@ -147,10 +147,29 @@ function acquire(root) {
147
147
 
148
148
  function release(root) { try { unlinkSync(join(root, '.rsc', LOCK)); } catch { /* gone */ } }
149
149
 
150
+ /**
151
+ * Commits handed over without the lock (the post-merge close runs inside somebody's `git pull` and
152
+ * must not wait for a push in flight): one SHA per line, taken into the queue by the next locked body.
153
+ * Renamed before it is read, so a line appended meanwhile lands in a new file and is never lost.
154
+ */
155
+ const PENDING = 'knowledge-sync.pending';
156
+ function absorbPending(root, s) {
157
+ const path = join(root, '.rsc', PENDING);
158
+ const taking = `${path}.taking`;
159
+ try { renameSync(path, taking); } catch { return; }
160
+ try {
161
+ for (const sha of readFileSync(taking, 'utf8').split('\n').map((x) => x.trim()).filter(Boolean)) {
162
+ if (!s.queue.includes(sha)) s.queue.push(sha);
163
+ }
164
+ } catch { /* unreadable: nothing to take */ }
165
+ try { unlinkSync(taking); } catch { /* gone */ }
166
+ }
167
+
150
168
  /** The lock and the state live together: a body that runs without the lock never runs. */
151
169
  function locked(root, body) {
152
170
  if (!acquire(root)) return null;
153
171
  const s = readState(root);
172
+ absorbPending(root, s);
154
173
  try { return body(s); } finally { writeState(root, s); release(root); }
155
174
  }
156
175
 
@@ -366,7 +385,8 @@ function replay(root, base, sha, ours = []) {
366
385
  // to replace whole. (A snapshot from a closed trunk can start from HEAD, behind our own last
367
386
  // upload — after an upgrade, or once the chain is lost.)
368
387
  const last = line(root, ['log', '-1', '--format=%H', base, '--', f]);
369
- if (there === blob(root, `${sha}^`, f) || (last && ours.includes(last))) {
388
+ const from = blob(root, `${sha}^`, f);
389
+ if (there === from || (last && ours.includes(last)) || behindDefault(root, base, f, there, from, last)) {
370
390
  const entry = next && line(root, ['ls-tree', sha, '--', f]);
371
391
  const ok = next
372
392
  ? run(root, ['update-index', '--add', '--cacheinfo', `${entry.split(' ')[0]},${next},${f}`], { env }).ok
@@ -383,6 +403,23 @@ function replay(root, base, sha, ours = []) {
383
403
  }
384
404
  }
385
405
 
406
+ /**
407
+ * The exchange branch holds this file only in a version the remote default branch has already gone
408
+ * past — or never held it — and the change starts from the default branch's version: replacing it
409
+ * whole is a fast-forward of the file, not a clash. rsc/knowledge is born from main and never merges
410
+ * it back, so a document merged through a pull request it never carried, then closed on merge
411
+ * (`closeLanded`), met it as a clash. A file the exchange branch once had and then lost is not this
412
+ * case: somebody deleted it there, and that is not ours to undo.
413
+ */
414
+ function behindDefault(root, base, f, there, from, last) {
415
+ const def = defaultBranch(root);
416
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
417
+ if (!remoteDef || !from || blob(root, remoteDef, f) !== from) return false;
418
+ if (!there) return !last;
419
+ const revs = (line(root, ['rev-list', '-n', '50', remoteDef, '--', f]) || '').split('\n').filter(Boolean);
420
+ return revs.some((c) => blob(root, c, f) === there);
421
+ }
422
+
386
423
  const clashText = (files) => `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
387
424
  'Tu versión sigue aquí; hay que juntarlas a mano.';
388
425
 
@@ -517,6 +554,8 @@ export function applyIncoming(root, s) {
517
554
  .filter(isKnowledge).filter((f) => !onDefault(f, blob(root, tip, f))).slice(0, MAX_FILES);
518
555
  const closed = branch === def && trunkClosed(root);
519
556
  const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
557
+ const superseded = (f, mine) => touchedBy(f).some((c) => blob(root, c, f) === mine)
558
+ || touchedBy(f).some((c) => (line(root, ['log', '-1', '--format=%B', c]) || '').includes(`${CLOSES} ${mine} ${f}`));
520
559
 
521
560
  // A default branch closed for the agent is left exactly as the remote has it (team sim 2026-10-07,
522
561
  // D1). Nothing can be committed there, and anything written uncommitted blocks the person's next
@@ -546,7 +585,12 @@ export function applyIncoming(root, s) {
546
585
  const mine = worktreeBlob(root, f);
547
586
  if (mine === incoming) continue;
548
587
  const base = blob(root, seen, f);
549
- if (mine === base && blob(root, 'HEAD', f) === base) take.push(f);
588
+ const committed = blob(root, 'HEAD', f);
589
+ if (mine === base && committed === base) take.push(f);
590
+ // Untouched here, and a version the exchange branch itself went past — it passed through it, or a
591
+ // close on merge says it replaces it (`CLOSES`). The branch has the merged document as main has it;
592
+ // the close is its newer version, not somebody else's competing edit (field test 3.0.8).
593
+ else if (mine && mine === committed && superseded(f, mine)) take.push(f);
550
594
  // Touched here and changed only by your own uploads: your newer version, not a clash.
551
595
  else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
552
596
  }
@@ -567,6 +611,243 @@ export function applyIncoming(root, s) {
567
611
  return said;
568
612
  }
569
613
 
614
+ // ------------------------------------------------------------------ close on merge (FTD)
615
+
616
+ // Field test 3.0.8: a feature document written on a feature branch kept saying «pendiente: abrir PR»
617
+ // after its pull request was merged, and carried no author — «¿qué ha hecho cada uno?» got every
618
+ // feature reported as pending and nobody's name, and the session memory kept injecting the stale Next.
619
+ // So the close is not left to the agent remembering: when a branch LANDS on the default branch (the
620
+ // post-merge hook, `worktree-reaper.mjs`), the feature documents that branch wrote are marked done.
621
+ //
622
+ // Where the edit goes is the whole design. Open default branch: a local `📝 docs(auto)` commit, like
623
+ // any other doc commit there. Closed default branch: never on main — neither the agent nor rsc commits
624
+ // there — so it is built on a throwaway index and handed to `ship`, which replays it onto rsc/knowledge
625
+ // like any knowledge change. The team gets it from there, and it reaches main inside the next PR that
626
+ // carries docs. The checkout is not touched, so the closed main stays clean and the next pull works.
627
+
628
+ export const FTD_CLOSE_OPT_OUT = '.no-ftd-close';
629
+ const CLOSE_SUBJECT = '📝 docs(auto): cierre FTD';
630
+ /** Trailer of a close: `Cierra: <blob it replaces> <path>`, so a teammate's branch takes it (applyIncoming). */
631
+ const CLOSES = 'Cierra:';
632
+ const isFtdDoc = (p) => /^02-DOCS\/wiki\/ftd\/[^/]+\.md$/.test(p);
633
+ const MAX_BRANCHES = 50;
634
+
635
+ function splitFrontmatter(text) {
636
+ const t = String(text).replace(/\r\n/g, '\n');
637
+ const m = t.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
638
+ return m ? { lines: m[1].split('\n'), body: t.slice(m[0].length) } : { lines: null, body: t };
639
+ }
640
+
641
+ function fmValue(lines, key) {
642
+ for (const l of lines || []) {
643
+ const m = l.match(/^([A-Za-z_][\w-]*):[ \t]*(.*)$/);
644
+ if (m && m[1] === key) return m[2].trim();
645
+ }
646
+ return null;
647
+ }
648
+
649
+ /** The frontmatter of a feature document as plain fields (`author`, `branch`, `status`, …). */
650
+ export function frontmatter(text) {
651
+ const { lines } = splitFrontmatter(text);
652
+ const out = {};
653
+ for (const l of lines || []) {
654
+ const m = l.match(/^([A-Za-z_][\w-]*):[ \t]*(.*)$/);
655
+ if (m) out[m[1]] = m[2].trim();
656
+ }
657
+ return out;
658
+ }
659
+
660
+ const isDoneText = (text) => /^done$/i.test(frontmatter(text).status || '');
661
+
662
+ /**
663
+ * The closed version of a feature document, or null when it is already done (idempotent). Sets
664
+ * `status: done`, `merged`, and `merged_by`/`pr` when known; fills `author`/`branch` only when missing;
665
+ * replaces the Next section with one line saying where and when it landed. Nothing else is rewritten:
666
+ * the evidence is the record.
667
+ */
668
+ export function closeDoc(text, { trunk, date, sha, author, branch, mergedBy, pr } = {}) {
669
+ const { lines, body } = splitFrontmatter(text);
670
+ const fm = lines ? [...lines] : [];
671
+ if (/^done$/i.test(fmValue(fm, 'status') || '')) return null;
672
+ const set = (key, value, onlyIfMissing = false) => {
673
+ if (value == null || value === '') return;
674
+ const i = fm.findIndex((l) => l.startsWith(`${key}:`));
675
+ if (i < 0) fm.push(`${key}: ${value}`);
676
+ else if (!onlyIfMissing || !fmValue([fm[i]], key)) fm[i] = `${key}: ${value}`;
677
+ };
678
+ set('author', author, true);
679
+ set('branch', branch, true);
680
+ set('status', 'done');
681
+ set('merged', date);
682
+ set('merged_by', mergedBy);
683
+ set('pr', pr ? `#${String(pr).replace(/^#/, '')}` : null);
684
+ const landed = `Fusionado en ${trunk} el ${date} (${sha}).`;
685
+ const next = /^(##[ \t]*(?:Next|Siguiente|Próximo)[^\n]*\n)([\s\S]*?)(?=^##\s|(?![\s\S]))/mu;
686
+ const rest = next.test(body)
687
+ ? body.replace(next, (_, heading) => `${heading}${landed}\n\n`)
688
+ : `${body.replace(/\n*$/, '\n')}\n## Next\n${landed}\n`;
689
+ return `---\n${fm.join('\n')}\n---\n${lines ? '' : '\n'}${rest.replace(/\n+$/, '\n')}`;
690
+ }
691
+
692
+ const isAncestor = (root, a, b) => run(root, ['merge-base', '--is-ancestor', a, b]).ok;
693
+
694
+ /** Would merging `tip` into `into` change nothing? (squash/rebase merges rewrite identities.) */
695
+ function contains(root, into, tip) {
696
+ const merged = run(root, ['merge-tree', '--write-tree', into, tip]);
697
+ return merged.ok && merged.out.split('\n')[0].trim() === line(root, ['rev-parse', `${into}^{tree}`]);
698
+ }
699
+
700
+ /**
701
+ * The branches that landed between `orig` and `head`: in head and not before, by identity (merge,
702
+ * fast-forward) or by content (squash, rebase). Local branches and the remote-tracking ones, the most
703
+ * recent first and bounded — this runs inside somebody's `git pull`.
704
+ */
705
+ function landedBranches(root, orig, head, def) {
706
+ const refs = (line(root, ['for-each-ref', '--sort=-committerdate', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin']) || '')
707
+ .split('\n').filter(Boolean).slice(0, MAX_BRANCHES * 2);
708
+ const out = [];
709
+ const seen = new Set();
710
+ for (const ref of refs) {
711
+ const name = ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '');
712
+ if (!name || name === 'HEAD' || name === def || name === KNOWLEDGE_BRANCH || seen.has(name)) continue;
713
+ const tip = line(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
714
+ if (!tip || isAncestor(root, tip, orig)) continue; // in before, or nothing of its own
715
+ if (isAncestor(root, tip, head) || (contains(root, head, tip) && !contains(root, orig, tip))) {
716
+ seen.add(name);
717
+ out.push({ name, tip });
718
+ }
719
+ if (out.length >= MAX_BRANCHES) break;
720
+ }
721
+ return out;
722
+ }
723
+
724
+ /** Where and how a branch landed: the first commit of the default branch's own line that has it. */
725
+ function landingOf(root, orig, head, tip) {
726
+ const line1 = (line(root, ['rev-list', '--first-parent', '--reverse', `${orig}..${head}`]) || '').split('\n').filter(Boolean).slice(0, 20);
727
+ const at = line1.find((c) => isAncestor(root, tip, c)) || line1.find((c) => contains(root, c, tip)) || head;
728
+ const [parents = '', committer = '', date = '', subject = ''] = (line(root, ['log', '-1', '--format=%P%x00%cn%x00%cs%x00%s', at]) || '').split('\0');
729
+ const pr = subject.match(/Merge pull request #(\d+)/)?.[1] || subject.match(/\(#(\d+)\)\s*$/)?.[1] || null;
730
+ let mergedBy = null;
731
+ if (parents.trim().split(/\s+/).length > 1 || pr) mergedBy = committer && committer !== 'GitHub' ? committer : null;
732
+ else if (/^merge /.test(line(root, ['reflog', '-1', '--format=%gs', 'HEAD']) || '')) mergedBy = line(root, ['config', 'user.name']);
733
+ return { sha: line(root, ['rev-parse', '--short', at]) || at.slice(0, 7), date: date || new Date().toISOString().slice(0, 10), mergedBy, pr };
734
+ }
735
+
736
+ /**
737
+ * The documents a landed branch wrote, closed. A document says whose it is with `branch:`; one of
738
+ * another branch — brought along by a 📥 sync — is not this branch's to close. A document with no
739
+ * frontmatter (written before it existed) counts when a commit of the branch's own, not a sync,
740
+ * touched it; its author is then the git author of the commit that created it.
741
+ */
742
+ function closuresFor(root, orig, head, def, read) {
743
+ const out = new Map();
744
+ for (const { name, tip } of landedBranches(root, orig, head, def)) {
745
+ const base = line(root, ['merge-base', orig, tip]);
746
+ if (!base) continue;
747
+ const files = zlist(run(root, ['diff', '--name-only', '--no-renames', '-z', base, tip, '--', '02-DOCS/wiki/ftd/']).out)
748
+ .filter(isFtdDoc).filter((f) => !out.has(f) && blob(root, head, f));
749
+ if (!files.length) continue;
750
+ let landing = null;
751
+ for (const f of files) {
752
+ const text = read(f);
753
+ if (text == null || isDoneText(text)) continue;
754
+ const owner = frontmatter(text).branch;
755
+ if (owner && owner !== name) continue;
756
+ if (!owner) {
757
+ const own = (line(root, ['log', '--no-merges', '--format=%s', `${base}..${tip}`, '--', f]) || '').split('\n').filter(Boolean);
758
+ if (!own.some((s) => s !== SYNC_SUBJECT)) continue;
759
+ }
760
+ landing ||= landingOf(root, orig, head, tip);
761
+ const created = (line(root, ['log', '--diff-filter=A', '--format=%an', tip, '--', f]) || '').split('\n').filter(Boolean).pop();
762
+ const closed = closeDoc(text, { trunk: def, ...landing, author: created, branch: name });
763
+ if (closed) out.set(f, closed);
764
+ }
765
+ }
766
+ return out;
767
+ }
768
+
769
+ /**
770
+ * The post-merge entry point: close what just landed. Returns `{ closed, via }` — via `local` (open
771
+ * default branch, committed here) or `knowledge` (closed default branch, queued for rsc/knowledge).
772
+ * Never throws: it runs inside git, mid-pull, and a failed close must never become a failed merge.
773
+ */
774
+ export function closeLanded(root, { spawnShip = true } = {}) {
775
+ const res = { closed: [], via: null };
776
+ try {
777
+ if (existsSync(join(root, '.rsc', FTD_CLOSE_OPT_OUT)) || !existsSync(join(root, '.rsc.json'))) return res;
778
+ // Not `busy()`: git runs post-merge with MERGE_HEAD still on disk, so that check refused every
779
+ // landing (found end to end, through the real hook). A rebase or a pick in progress is no landing.
780
+ for (const p of ['rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
781
+ const path = line(root, ['rev-parse', '--git-path', p]);
782
+ if (path && existsSync(isAbsolute(path) ? path : join(root, path))) return res;
783
+ }
784
+ const def = defaultBranch(root);
785
+ const branch = line(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
786
+ if (!def || branch !== def) return res; // a landing is a landing on the default branch
787
+ const head = line(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
788
+ const orig = line(root, ['rev-parse', '--verify', '--quiet', 'ORIG_HEAD^{commit}']);
789
+ if (!head || !orig || orig === head || !isAncestor(root, orig, head)) return res;
790
+ const open = !trunkClosed(root);
791
+ if (!open && inactiveReason(root)) return res; // closed and no rsc/knowledge: nowhere to write
792
+
793
+ let read;
794
+ if (open) {
795
+ // The checkout's own copy, and only when nobody is editing it.
796
+ read = (f) => (worktreeBlob(root, f) === blob(root, 'HEAD', f) ? readFileSync(join(root, f), 'utf8') : null);
797
+ } else {
798
+ // Already closed on rsc/knowledge, or waiting in the queue to go there: not again.
799
+ const s = readState(root);
800
+ let pending = [];
801
+ try { pending = readFileSync(join(root, '.rsc', PENDING), 'utf8').split('\n').filter(Boolean); } catch { /* none */ }
802
+ const elsewhere = [`refs/remotes/origin/${KNOWLEDGE_BRANCH}`, ...s.queue, ...pending];
803
+ const closedElsewhere = (f) => elsewhere.some((rev) => {
804
+ const b = blob(root, rev, f);
805
+ return b && isDoneText(git(root, ['cat-file', 'blob', b]));
806
+ });
807
+ read = (f) => (closedElsewhere(f) ? null : git(root, ['show', `HEAD:${f}`]));
808
+ }
809
+ const docs = closuresFor(root, orig, head, def, read);
810
+ if (!docs.size) return res;
811
+ const files = [...docs.keys()];
812
+
813
+ // One commit on top of HEAD, built on a throwaway index: plumbing, because git runs this hook
814
+ // with the merge state still present and refuses a porcelain commit then.
815
+ const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.close.index') };
816
+ let sha;
817
+ try {
818
+ mkdirSync(join(root, '.rsc'), { recursive: true });
819
+ git(root, ['read-tree', 'HEAD'], { env });
820
+ const trailers = [];
821
+ for (const [f, text] of docs) {
822
+ const b = git(root, ['hash-object', '-w', '--stdin'], { input: text }).trim();
823
+ const mode = (line(root, ['ls-tree', 'HEAD', '--', f]) || '100644').split(' ')[0];
824
+ git(root, ['update-index', '--cacheinfo', `${mode},${b},${f}`], { env });
825
+ trailers.push(`${CLOSES} ${blob(root, 'HEAD', f)} ${f}`);
826
+ }
827
+ const tree = line(root, ['write-tree'], { env });
828
+ sha = git(root, ['commit-tree', tree, '-p', head, '-F', '-'], { input: `${CLOSE_SUBJECT}: ${summary(files)}\n\n${trailers.join('\n')}\n` }).trim();
829
+ } finally {
830
+ try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
831
+ }
832
+
833
+ if (open) {
834
+ // Open: it lands here like any docs(auto) commit — HEAD moves only if nobody moved it meanwhile,
835
+ // then the index and the files follow, for these paths only.
836
+ git(root, ['update-ref', '-m', `rsc: ${CLOSE_SUBJECT}`, 'HEAD', sha, head]);
837
+ for (const [f, text] of docs) writeFileSync(join(root, f), text);
838
+ git(root, ['--literal-pathspecs', 'update-index', '--', ...files]);
839
+ return { closed: files, via: 'local' };
840
+ }
841
+ // Closed: nothing here. Handed to `ship` without the lock, so a push in flight never makes the
842
+ // pull wait (`absorbPending`); it goes up to rsc/knowledge and reaches main in the next PR.
843
+ appendFileSync(join(root, '.rsc', PENDING), `${sha}\n`);
844
+ if (spawnShip) background(root, 'ship');
845
+ return { closed: files, via: 'knowledge' };
846
+ } catch {
847
+ return res;
848
+ }
849
+ }
850
+
570
851
  // ------------------------------------------------------------------ hook entry points
571
852
 
572
853
  // `RSC_KNOWLEDGE_SYNC_FOREGROUND=1` runs the network half inline: for tests, which would otherwise race
@@ -738,5 +1019,8 @@ function stdinJson() {
738
1019
  if (isMainModule(import.meta.url)) {
739
1020
  const [op, a, b] = process.argv.slice(2);
740
1021
  if (op === 'hook') process.stdout.write(`${JSON.stringify(hook(a, b, stdinJson()))}\n`);
741
- else work(a, op); // detached: `ship <root>` or `fetch <root>`
1022
+ else if (op === 'close') { // the post-merge hook: `close <root>`
1023
+ const r = closeLanded(resolve(a || process.cwd()));
1024
+ for (const f of r.closed) process.stdout.write(`rsc: FTD cerrado ${f}${r.via === 'knowledge' ? ` (vía ${KNOWLEDGE_BRANCH})` : ''}\n`);
1025
+ } else work(a, op); // detached: `ship <root>` or `fetch <root>`
742
1026
  }
@@ -22,6 +22,7 @@ export const PROJECT_OPT_OUTS = [
22
22
  'claudemd-check', // the root CLAUDE.md context budget
23
23
  'danger-guard', // foot-gun denial, keyed to the committed user profile
24
24
  'feature-gate', // per-turn re-injection of the SDD gate
25
+ 'ftd-close', // a landed branch's feature documents marked done by the post-merge hook
25
26
  'ftd-nudge', // once-per-session reminder: code changed and no feature document yet
26
27
  'gitmoji', // the commit-message convention
27
28
  'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
@@ -612,11 +612,19 @@ function subjects(cwd, commits) {
612
612
  }).filter((item) => item && !secretShaped(item));
613
613
  }
614
614
 
615
+ // A document whose frontmatter says `status: done` is finished work, not a next step. On a closed
616
+ // default branch the close travels by rsc/knowledge and is not in the checkout yet (knowledge-sync
617
+ // `closeLanded`), so the exchange branch's copy is asked too — a local ref, no network.
618
+ const isDone = (text) => /^status:[ \t]*done[ \t]*$/mu.test(String(text || '').replace(/\r\n/gu, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/u)?.[1] || '');
619
+
615
620
  function nextSteps(cwd, files) {
616
621
  const out = [];
617
622
  for (const rel of files.filter((f) => /^02-DOCS\/wiki\/(ftd|sdd\/(specs|plans|progress))\/[^/]+\.md$/u.test(f)).slice(0, 3)) {
618
623
  let body = '';
619
624
  try { body = readFileSync(join(cwd, rel), 'utf8'); } catch { continue; }
625
+ if (isDone(body)) continue;
626
+ const exchange = git(cwd, ['show', `refs/remotes/origin/rsc/knowledge:${rel}`]);
627
+ if (exchange.ok && isDone(exchange.out)) continue;
620
628
  const section = body.match(/^##\s*(Next|Siguiente|Próximo)[^\n]*\n+([\s\S]*?)(?=\n##\s|$)/imu)?.[2];
621
629
  const first = section?.split('\n').map((l) => l.replace(/^[-*]\s*/u, '').trim()).find(Boolean);
622
630
  const open = (body.match(/^- \[ \]/gmu) || []).length;
@@ -549,8 +549,12 @@ if [ -x "$hook_dir/post-merge.rsc-local" ]; then
549
549
  "$hook_dir/post-merge.rsc-local" "$@" || true
550
550
  fi
551
551
  root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
552
- [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
553
552
  command -v node >/dev/null 2>&1 || exit 0
553
+ # First, while the landed branch still exists: close its feature documents (off: .rsc/.no-ftd-close).
554
+ if [ -f "$root/.rsc/knowledge-sync.mjs" ]; then
555
+ node "$root/.rsc/knowledge-sync.mjs" close "$root" 2>/dev/null || true
556
+ fi
557
+ [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
554
558
  node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
555
559
  exit 0
556
560
  `;