@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.
- package/README.md +62 -6
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +21 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +61 -4
- package/scripts/lib/onboarding.js +7 -0
- package/scripts/rsc.js +146 -13
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- package/targets/agents.js +144 -18
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/ftd-nudge.mjs +1 -1
- package/targets/git-permissions.js +254 -16
- package/targets/knowledge-sync.mjs +288 -4
- package/targets/opt-outs.js +1 -0
- package/targets/session-memory-core.mjs +8 -0
- package/targets/worktree-reaper.mjs +5 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/targets/opt-outs.js
CHANGED
|
@@ -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
|
`;
|