@ericrisco/rsc 3.0.8 → 3.0.9

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/README.md CHANGED
@@ -372,7 +372,11 @@ That verdict is not just about the receipt: it also requires the harness floor t
372
372
  `01-TOOLS/_TEMPLATE/`, `02-DOCS/wiki/harness/`, and the constitution when the plan selects SDD.
373
373
  If the floor is missing, the install applied but prints `RSC_ONBOARDING_INCOMPLETE` with each
374
374
  missing path and the action that creates it, because a plan that promised little used to be able to
375
- report success with three markdown files.
375
+ report success with three markdown files. When the plan selects SDD, onboarding writes the
376
+ constitution itself as a draft (`status: draft`, only the facts it knows, no principles), so a
377
+ non-interactive install still ends `RSC_ONBOARDING_READY`; the draft is listed as pending — under
378
+ READY and in `rsc doctor` — until the `constitution` phase completes it. An existing constitution is
379
+ never overwritten.
376
380
 
377
381
  ---
378
382
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.8",
2
+ "version": "3.0.9",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.8",
3
+ "version": "3.0.9",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -20,6 +20,7 @@ import { metricsSummary } from '../targets/session-memory-core.mjs';
20
20
  import { agentPath } from '../targets/agents.js';
21
21
  import { projectOptOuts } from '../targets/opt-outs.js';
22
22
  import { versionReport } from './lib/versions.js';
23
+ import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
23
24
 
24
25
  // Gates the committed manifest says the team disarmed, still armed here. Read through the same
25
26
  // partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
@@ -310,6 +311,11 @@ export function doctor({ target, home, cwd }) {
310
311
  for (const [id, e] of Object.entries(state.skills)) {
311
312
  for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
312
313
  }
314
+ // Pending is not unhealthy: work the harness expects the user to finish, named with what does it.
315
+ // The draft constitution `rsc onboard` writes is the first entry; it never touches `healthy`.
316
+ report.pending = constitutionIsDraft(root)
317
+ ? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
318
+ : [];
313
319
  report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
314
320
  && !report.opencodePluginsOutdated.length;
315
321
  return report;
@@ -0,0 +1,156 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // La constitución como BORRADOR.
6
+ //
7
+ // El suelo del arnés exige `02-DOCS/wiki/sdd/constitution.md` cuando el plan elige SDD, y sólo una
8
+ // fase agéntica la escribía. Así que una instalación no interactiva de software complejo —terminal,
9
+ // CI, nadie con un agente delante— terminaba en RSC_ONBOARDING_INCOMPLETE: medio instalada. En la
10
+ // prueba de campo una sesión entera ($3.59, 67 turnos) se gastó sólo en escribirla.
11
+ //
12
+ // Decisión del PO (2026-10-07): el onboarding escribe un BORRADOR y no falla. El borrador lleva
13
+ // `status: draft`, los encabezados reales de la plantilla de la fase `constitution`, y sólo los
14
+ // hechos que el onboarding conoce — objetivo, tipo, alcance, flujo, evidencia de stack. Ningún
15
+ // principio: ratificar es de la fase `constitution`, con el usuario delante (P4). El suelo lo acepta
16
+ // porque sólo mira existencia; la cadena SDD lo detecta por `status: draft` y lo completa antes del
17
+ // primer spec; `doctor` lo lista como pendiente, nunca como fallo.
18
+
19
+ export const CONSTITUTION_PATH = '02-DOCS/wiki/sdd/constitution.md';
20
+ export const DRAFT_MARK = 'borrador — complétalo con la fase `constitution` antes del primer SDD';
21
+
22
+ const WORKFLOW_FACT = {
23
+ branches: 'branches closed with a pull request; the default branch is closed to direct work',
24
+ main: 'straight on the default branch',
25
+ };
26
+
27
+ // El objetivo es texto del usuario y va a un documento que leerá un agente: una línea, acotada.
28
+ const oneLine = (value, limit = 200) => String(value ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ')
29
+ .replace(/\s+/g, ' ').trim().slice(0, limit);
30
+
31
+ /** Puro: el texto del borrador a partir del plan aceptado. Sin reloj ni disco, salvo lo que se le pase. */
32
+ export function renderConstitutionDraft(plan, { project = 'Project', date = new Date() } = {}) {
33
+ const record = plan?.record || {};
34
+ const evidence = plan?.evidence || {};
35
+ const stacks = (evidence.stacks || []).map((s) => oneLine(s, 40)).filter(Boolean);
36
+ const manifests = (evidence.signals || []).filter((s) => String(s).startsWith('manifest:'))
37
+ .map((s) => oneLine(String(s).slice('manifest:'.length), 60));
38
+ const day = date.toISOString().slice(0, 10);
39
+ const name = oneLine(project, 80) || 'Project';
40
+ const stackLine = stacks.length
41
+ ? `Detected at install: ${stacks.join(', ')}${manifests.length ? ` (from ${manifests.join(', ')})` : ''}. Not ratified yet.`
42
+ : 'No stack evidence was detected at install. To be decided in the `constitution` phase.';
43
+ const workflow = WORKFLOW_FACT[record.workflow];
44
+ const pending = '_Pending — to be completed in the `constitution` phase._';
45
+ return `---
46
+ type: constitution
47
+ title: ${name} — Constitution
48
+ description: Draft written by rsc onboard from the accepted plan; not ratified.
49
+ tags: [sdd, constitution]
50
+ timestamp: ${date.toISOString().replace(/\.\d{3}Z$/, 'Z')}
51
+ topic: sdd
52
+ status: draft
53
+ version: draft
54
+ ---
55
+
56
+ # ${name} — Constitution
57
+
58
+ > **${DRAFT_MARK}.**
59
+ > Draft: complete it with the \`constitution\` phase before the first SDD feature. It holds only what
60
+ > onboarding knew; it ratifies no principle. Ratifying it removes \`status: draft\` and starts at v1.0.0.
61
+
62
+ ## Known facts (from onboarding)
63
+
64
+ - Goal: ${oneLine(record.goal)}
65
+ - Project kind: ${oneLine(record.projectKind, 40)}${record.softwareScope ? ` · software scope: ${oneLine(record.softwareScope, 40)}` : ''}
66
+ ${workflow ? `- How code changes land: ${workflow} (\`--workflow ${record.workflow}\`).\n` : ''}- Stack evidence: ${stackLine}
67
+
68
+ ## 1. Stack canon
69
+
70
+ ${stackLine}
71
+
72
+ ## 2. Quality bar
73
+
74
+ ${pending}
75
+
76
+ ## 3. Conventions
77
+
78
+ ${pending}
79
+
80
+ ## 4. Branching & shipping
81
+
82
+ ${workflow ? `Chosen at install: ${workflow}. ` : ''}${pending}
83
+
84
+ ## 5. Security & privacy floor
85
+
86
+ ${pending}
87
+
88
+ ## 6. UX / accessibility floor (if there is a UI)
89
+
90
+ ${pending}
91
+
92
+ ## 7. Performance budgets (where they matter)
93
+
94
+ ${pending}
95
+
96
+ ## 8. Knowledge & decisions
97
+
98
+ ${pending}
99
+
100
+ ## Definition of Done (the merge bar \`verify\` runs against)
101
+
102
+ ${pending}
103
+
104
+ ## Amendment log (append-only)
105
+
106
+ | Date | Version | Change | Why |
107
+ |------|---------|--------|-----|
108
+ | ${day} | draft | Draft generated by \`rsc onboard\`. | The SDD floor needs a constitution; ratification is the \`constitution\` phase's. |
109
+ `;
110
+ }
111
+
112
+ /** ¿La constitución existe y está en borrador? Sólo lee el frontmatter; ilegible ⇒ no es borrador. */
113
+ export function constitutionIsDraft(cwd = process.cwd()) {
114
+ const path = join(cwd, CONSTITUTION_PATH);
115
+ try {
116
+ if (!existsSync(path) || !lstatSync(path).isFile()) return false;
117
+ const text = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
118
+ const block = /^---\n([\s\S]*?)\n---(\n|$)/.exec(text);
119
+ return Boolean(block && /^status:\s*["']?draft["']?\s*$/m.test(block[1]));
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ const isLink = (path) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
126
+
127
+ /**
128
+ * Escribe el borrador si el plan exige la constitución y no hay ninguna. Nunca sobrescribe: ni una
129
+ * ratificada ni un borrador que el usuario ya empezó. Un enlace (aunque cuelgue) cuenta como
130
+ * existente, y `wx` cierra la carrera entre comprobar y escribir.
131
+ */
132
+ export function ensureConstitutionDraft(cwd, plan, { now = new Date() } = {}) {
133
+ if (!(plan?.floorPaths || []).includes(CONSTITUTION_PATH)) return { created: false };
134
+ // Sin importar `onboarding-apply.js` (doctor lee este módulo y no debe arrastrar el instalador):
135
+ // un directorio intermedio que sea enlace podría sacar la escritura de la raíz, y git versiona los
136
+ // enlaces, así que el vector viaja en un clone. Más estricto que la guarda general: no se sigue.
137
+ let cursor = cwd;
138
+ for (const segment of ['02-DOCS', 'wiki', 'sdd']) {
139
+ cursor = join(cursor, segment);
140
+ if (existsSync(cursor) || isLink(cursor)) {
141
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error(`RSC_ROOT_AMBIGUOUS: ${CONSTITUTION_PATH} would be written through a symlink`);
142
+ }
143
+ }
144
+ const target = join(cwd, CONSTITUTION_PATH);
145
+ let present = false;
146
+ try { lstatSync(target); present = true; } catch { /* ausente */ }
147
+ if (present) return { created: false };
148
+ mkdirSync(join(cwd, '02-DOCS', 'wiki', 'sdd'), { recursive: true });
149
+ try {
150
+ writeFileSync(target, renderConstitutionDraft(plan, { project: basename(cwd), date: now }), { flag: 'wx' });
151
+ } catch (error) {
152
+ if (error.code === 'EEXIST') return { created: false };
153
+ throw error;
154
+ }
155
+ return { created: true };
156
+ }
@@ -10,6 +10,7 @@ import { readManifest, writeManifest } from './manifest-file.js';
10
10
  import { encodeGoal, identifyPlan } from './onboarding.js';
11
11
  import { createBackup, restoreBackup } from './backups.js';
12
12
  import { RETIRED_SKILLS, replaceRetired } from './retired-skills.js';
13
+ import { CONSTITUTION_PATH } from './constitution-draft.js';
13
14
  import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
14
15
 
15
16
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
@@ -370,7 +371,9 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
370
371
  const TEMPLATE_FLOOR = '01-TOOLS/_TEMPLATE/';
371
372
 
372
373
  export const HARNESS_FLOOR_MINIMUM = [TEMPLATE_FLOOR, '02-DOCS/wiki/harness/'];
373
- export const HARNESS_FLOOR_CONSTITUTION = '02-DOCS/wiki/sdd/constitution.md';
374
+ // El onboarding escribe ahora un BORRADOR (`constitution-draft.js`) y el suelo lo acepta: sólo
375
+ // mira existencia, y completar el borrador es de la cadena SDD, no del instalador.
376
+ export const HARNESS_FLOOR_CONSTITUTION = CONSTITUTION_PATH;
374
377
 
375
378
  // La plantilla, sus nombres con punto y el `.gitignore` de la capa viven en `tools-skeleton.js`:
376
379
  // lo que se copia aquí es exactamente lo que `purge` puede recuperar, y sólo si sigue intacto.
@@ -117,6 +117,13 @@ export function scanProject(root = process.cwd()) {
117
117
  // Codex stores the always-on layer in AGENTS.md. Ignore a file that contains
118
118
  // only our managed block, while retaining a user's surrounding instructions
119
119
  // as real project evidence across repair/re-onboarding.
120
+ // El `CLAUDE.md` raíz es el Knowledge map, y lo escriben las propias fases de rsc: la
121
+ // `constitution` añade su fila «create CLAUDE.md if absent», y el instalador puede dejar ahí
122
+ // la sombra de AGENTS.md. Contarlo como evidencia movía la identidad del plan justo después de
123
+ // completar el suelo, y el `--accept-plan` del mismo id moría con RSC_PLAN_CHANGED sin que
124
+ // nada se hubiera elegido distinto (reproducido con 3.0.8). La cuenta de markdown no decide
125
+ // nada del plan —sólo su identidad—, así que no se pierde ninguna decisión.
126
+ if (rel === 'CLAUDE.md') isSignal = false;
120
127
  if (entry.name === 'AGENTS.md') {
121
128
  const human = readFileSync(path, 'utf8')
122
129
  .replace(/\n*<!-- rsc-suggest:start -->[\s\S]*?<!-- rsc-suggest:end -->\n*/g, '')
package/scripts/rsc.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
26
26
  } from './lib/onboarding.js';
27
27
  import { applyAcceptedOnboarding, verifyOnboarding, ensureHarnessSkeleton, harnessReadiness } from './lib/onboarding-apply.js';
28
+ import { CONSTITUTION_PATH, constitutionIsDraft, ensureConstitutionDraft } from './lib/constitution-draft.js';
28
29
 
29
30
  const rawArgv = process.argv.slice(2);
30
31
 
@@ -298,6 +299,14 @@ async function runOnboarding(targets) {
298
299
  } catch (error) {
299
300
  say(`RSC_SKELETON_FAILED ${error.message}`);
300
301
  }
302
+ // La constitución, como BORRADOR, cuando el plan eligió SDD y no hay ninguna. Sin esto una
303
+ // instalación no interactiva de software complejo acababa INCOMPLETE: sólo una fase agéntica la
304
+ // escribía. Mismo sitio y mismo motivo que el esqueleto: fuera de la transacción, sin residuo.
305
+ try {
306
+ ensureConstitutionDraft(process.cwd(), plan);
307
+ } catch (error) {
308
+ say(`RSC_SKELETON_FAILED ${error.message}`);
309
+ }
301
310
  // The audit cadence starts at install. Without a baseline the very first session after onboarding
302
311
  // announced "a skill audit is due" about a set chosen minutes earlier. Never overwrites a real run.
303
312
  try { recordAuditBaseline(process.cwd()); } catch { /* a missing baseline only means an early nudge */ }
@@ -313,7 +322,14 @@ async function runOnboarding(targets) {
313
322
  // poco se cumplía entero. Spec: 02-DOCS/wiki/sdd/specs/install-completion-floor.md
314
323
  function emitHarnessReadiness(plan, planId) {
315
324
  const readiness = harnessReadiness(process.cwd(), plan);
316
- if (readiness.ready) return void say(`RSC_ONBOARDING_READY ${planId}`);
325
+ if (readiness.ready) {
326
+ say(`RSC_ONBOARDING_READY ${planId}`);
327
+ // Listo no es «nada pendiente»: el borrador se nombra para que la cadena SDD lo complete primero.
328
+ if ((plan.floorPaths || []).includes(CONSTITUTION_PATH) && constitutionIsDraft(process.cwd())) {
329
+ say(` Pending: ${CONSTITUTION_PATH} is a draft — complete it with the \`constitution\` phase before the first SDD feature.`);
330
+ }
331
+ return;
332
+ }
317
333
  say(`RSC_ONBOARDING_INCOMPLETE ${planId}`);
318
334
  for (const missing of safeLines(readiness.missing)) say(` ${missing}`);
319
335
  say(` Next: ${readiness.action}`);
@@ -86,6 +86,8 @@ Write `02-DOCS/wiki/sdd/constitution.md` from the template in `references/consti
86
86
 
87
87
  Create `02-DOCS/wiki/sdd/` if it does not exist. Do not overwrite an existing constitution — amend it.
88
88
 
89
+ **A draft is "complete me", not "amend me".** `rsc onboard` writes a skeleton with `status: draft` in the frontmatter: the template's headings, the facts onboarding knew (goal, kind, scope, workflow, detected stack) and no principle. Keep those facts, run the interview, fill the sections, then on ratification drop `status: draft`, set `version: v1.0.0` and log v1.0.0 in the amendment log.
90
+
89
91
  ## Versioning & amendment protocol
90
92
 
91
93
  The constitution is versioned so `analyze` and `review` can cite "constitution v1.2.0, principle 4".
@@ -34,6 +34,11 @@ repository**: whoever clones it should get code, not somebody else's notes in pr
34
34
  It is written **before the first change**, not after, and it holds exactly this:
35
35
 
36
36
  ```markdown
37
+ ---
38
+ author: <who asked for it — git user.name>
39
+ branch: <the branch the work runs on>
40
+ status: in-progress
41
+ ---
37
42
  # <feature>
38
43
 
39
44
  ## Intent — what changes, and why it is worth doing
@@ -43,6 +48,10 @@ It is written **before the first change**, not after, and it holds exactly this:
43
48
  ## Next — the single next step, so an interruption costs nothing
44
49
  ```
45
50
 
51
+ The frontmatter says whose work it is, so «¿qué ha hecho cada uno?» has an answer. You never close
52
+ it by hand: when the branch lands on the default branch, rsc's post-merge hook sets `status: done`
53
+ and replaces Next with where and when it merged (on a closed default branch, via `rsc/knowledge`).
54
+
46
55
  No decision journal, no phase records, no approvals. If a decision was meaningful, one line of
47
56
  rationale goes next to the task it belongs to.
48
57
 
@@ -77,7 +77,7 @@ When a request lands, do not start typing code. Place it on the map first, then
77
77
  > **The new-feature gate (hard).** The moment the user is thinking about a new feature or change — "add…", "build…", "it should also…", "quiero añadir…" — it goes to `specify` first, *even if a stack skill (nextjs/fastapi/flutter/react…) also fired and could just build it*. **No feature code is written — by any skill — until a spec AND a plan exist and the user has approved them.** A stack skill about to build an unspec'd, non-trivial feature must stop and route here. The only exception is a genuinely one-line, low-risk change; name it and skip.
78
78
 
79
79
  1. **No `02-DOCS/wiki/sdd/config.yaml` and this is non-trivial?** → `sdd-init`.
80
- 2. **No constitution yet AND this project will grow?** → `constitution` once, then come back to the chain.
80
+ 2. **No constitution yet AND this project will grow — or its frontmatter says `status: draft` (the one `rsc onboard` writes)?** → `constitution` once, then come back to the chain.
81
81
  3. **Ambiguous / architectural / risky change before spec?** → optional proposal artifact via `specify`.
82
82
  4. **A new idea, fuzzy, no spec on disk?** → `specify`.
83
83
  5. **A spec exists but feels risky / has open questions?** → `clarify`.
@@ -93,7 +93,7 @@ If you genuinely cannot tell which phase you are in, ask the user one question:
93
93
  ### Skip rules (be honest about them)
94
94
 
95
95
  - A **one-line, low-risk change** (typo, copy tweak, config bump) does not need the full chain. Say so, do it, and verify. The method serves shipping, not ceremony.
96
- - `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on.
96
+ - `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on — unless it is still `status: draft`: then complete it first.
97
97
  - `clarify` and `analyze` are **gates, not paperwork**. If a spec is genuinely unambiguous and tiny, name that out loud and pass through — but the bias is to run them, because skipped gates are where drift hides.
98
98
 
99
99
  ## Autopilot mode — run the whole chain on one up-front yes
@@ -341,7 +341,7 @@ repeated here as prohibitions: naming a behaviour makes it more available, not l
341
341
  ## Project grounding (02-DOCS + CLAUDE.md)
342
342
 
343
343
  - Read `02-DOCS/wiki/sdd/config.yaml` if present. If it is missing and the change is non-trivial, recommend `sdd-init` before proceeding; if the user asks to continue, record the missing config as a risk.
344
- - Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence).
344
+ - Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence). If its frontmatter says `status: draft` (written by `rsc onboard`, unratified), route to `constitution` first and come back: a draft has no principles to inherit.
345
345
  - **No constitution yet?** Still write the spec, but inherit nothing — lean harder on the wiki and the user's answers, and record every constraint you would have inherited as a *point to clarify* instead of assuming it.
346
346
  - Write the spec to `02-DOCS/wiki/sdd/specs/<slug>.md`. Create the directory if absent.
347
347
  - Add a row in `02-DOCS/wiki/index.md` (the Knowledge map; root `CLAUDE.md` keeps only a short pointer) linking the new spec under the `sdd/specs` topic (additive only — never delete existing rows). Create the index if absent.
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
43
43
  * nothing checks is just drift with a head start.
44
44
  */
45
45
  export const PROJECT_OPT_OUTS = [
46
- 'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
46
+ 'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
47
47
  ];
48
48
 
49
49
  /**
@@ -23,7 +23,7 @@ const KEEP = 50;
23
23
 
24
24
  export const NUDGE = 'rsc · lane: this session is changing code and has no feature document yet. ' +
25
25
  'Before going on, decide the lane and say it in one line: simple → write 02-DOCS/wiki/ftd/<slug>.md now ' +
26
- '(Intent, Scope, Checklist with how each item is proven, Evidence, Next) — one document per feature, ' +
26
+ '(frontmatter author/branch/status: in-progress; Intent, Scope, Checklist with how each item is proven, Evidence, Next) — one document per feature, ' +
27
27
  'a box ticked only with its observed proof under Evidence, Next kept current; big or ' +
28
28
  'interlocking → SDD (spec first). A one-line, zero-risk change needs no document: say so instead.';
29
29
 
@@ -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
  `;