@ericrisco/rsc 1.2.2 → 1.2.3

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.2",
2
+ "version": "1.2.3",
3
3
  "counts": {
4
4
  "skills": 272,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,8 +4,8 @@
4
4
  // Not wired into `prepublishOnly`, and that is deliberate: specs live under 02-DOCS, which is never
5
5
  // tracked (principle 9), so a publish gate depending on them would fail in any clean clone. This is
6
6
  // the phase's own instrument, and the author's.
7
- import { readFileSync, readdirSync } from 'node:fs';
8
- import { join, dirname, basename } from 'node:path';
7
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
8
+ import { join, dirname, basename, resolve, relative } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { specCompleteness, statusClaims, checkClaims } from './lib/spec-gate.js';
@@ -25,11 +25,19 @@ function defaultTargets() {
25
25
 
26
26
  // The repository, asked instead of assumed. Returns null when there is nothing to ask — a
27
27
  // report-only instrument that crashes where it cannot look is worse than one that says so.
28
+ function spawnSyncGit(cwd, args) {
29
+ try { return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return ''; }
30
+ }
31
+
28
32
  function gitProbe(root) {
29
33
  const git = (args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
30
34
  let main;
35
+ let top;
31
36
  try {
32
37
  git(['rev-parse', '--git-dir']);
38
+ // The repository, not the directory we happened to be handed: `children()` looks for sibling
39
+ // repos under this one, and asking a specs/ subdirectory for its children finds nothing.
40
+ top = git(['rev-parse', '--show-toplevel']) || root;
33
41
  // Prefer the remote tip — a stale local `main` reports merged work as missing. But CI checks out
34
42
  // detached with no `origin/main` and no local `main`, and falling through to "no repository to
35
43
  // ask" there means the check silently stops checking exactly where it runs unattended. HEAD is
@@ -44,12 +52,118 @@ function gitProbe(root) {
44
52
  return {
45
53
  hasCommit: (sha) => { try { git(['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } },
46
54
  isAncestor: (sha) => { try { git(['merge-base', '--is-ancestor', sha, main]); return true; } catch { return false; } },
47
- // The squash-merge subject carries `(#N)`; --fixed-strings so the parens are not a regex.
48
- hasPr: (n) => { try { return git(['log', main, '--grep', `(#${n})`, '--fixed-strings', '-1', '--format=%H']) !== ''; } catch { return false; } },
55
+ // Two ways a pull request lands, and only one of them was recognised. The squash subject carries
56
+ // `(#N)`; a plain merge writes `Merge pull request #N from …` and matched nothing, so half the
57
+ // ways of landing were invisible even when the right repository was being asked.
58
+ // Two literal searches rather than one regex: `--fixed-strings` is what keeps the parens from
59
+ // being read as a group, and swapping it for an alternation would put that back.
60
+ hasPr: (n) => {
61
+ for (const needle of [`(#${n})`, `Merge pull request #${n} `]) {
62
+ try {
63
+ if (git(['log', main, '--grep', needle, '--fixed-strings', '-1', '--format=%H']) !== '') return true;
64
+ } catch { /* try the other form */ }
65
+ }
66
+ return false;
67
+ },
49
68
  hasTag: (v) => { try { return git(['tag', '-l', `v${v}`]) !== ''; } catch { return false; } },
69
+ root: top,
50
70
  };
51
71
  }
52
72
 
73
+ const commonDir = (dir) => {
74
+ const r = spawnSyncGit(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
75
+ return r || dir;
76
+ };
77
+ const sameRepo = (a, b) => commonDir(a) === commonDir(b);
78
+
79
+ const isRepo = (dir) => { try { return statSync(join(dir, '.git')).isDirectory() || statSync(join(dir, '.git')).isFile(); } catch { return false; } };
80
+
81
+ /**
82
+ * Which repository is this spec's `status:` talking about?
83
+ *
84
+ * It used to be "the one holding the file", which is right only when docs and code live together.
85
+ * Where they deliberately do not — this workspace's own CLAUDE.md requires the children's working
86
+ * docs to live in the container — every true claim came back false, and a gate that flags everything
87
+ * distinguishes nothing.
88
+ *
89
+ * The subject is pinned ONCE per spec and every claim is then judged against it. Letting each claim
90
+ * find whichever repository happens to confirm it would read better and destroy the function: an
91
+ * unconfirmed claim would simply find no confirmer, and nothing would ever be reported stale again.
92
+ */
93
+ function resolveSubject(specPath, claims) {
94
+ const answers = (root) => {
95
+ const p = gitProbe(root);
96
+ if (!p) return null;
97
+ return claims.some((c) => (c.kind === 'sha' && p.hasCommit(c.value))
98
+ || (c.kind === 'version' && p.hasTag(c.value))
99
+ || (c.kind === 'pr' && p.hasPr(c.value))) ? p : null;
100
+ };
101
+
102
+ // The declaration is AUTHORITATIVE. Softening it to "wins when it can answer" fixed a wrong config
103
+ // and opened something worse: with sibling repos, a claim that is false where the spec belongs and
104
+ // true next door is handed to the neighbour, confirmed, and printed nowhere. A silent false negative
105
+ // is worse than the noisy false positive it replaced, and choosing the judge by who agrees with the
106
+ // claim is not a way to find out whether the claim is true. A wrong `project.root` is a wrong datum;
107
+ // fix the datum.
108
+ const declared = declaredRoot(specPath);
109
+ if (declared && isRepo(declared)) return { probe: gitProbe(declared), from: 'config' };
110
+
111
+ const here = gitProbe(dirname(specPath));
112
+ const home = here && here.root;
113
+
114
+ if (home) {
115
+ const kids = children(home).map((dir) => ({ dir, probe: answers(dir) })).filter((k) => k.probe);
116
+ // Two histories cannot both be the subject. Saying so is an answer; picking the first one that
117
+ // sorted alphabetically is a coin toss wearing a verdict's clothes.
118
+ if (kids.length > 1) {
119
+ return { probe: null, note: `several repositories here could answer it (${kids.map((k) => basename(k.dir)).join(', ')}) — say which one in project.root` };
120
+ }
121
+ if (kids.length === 1) return { probe: kids[0].probe, from: 'derived' };
122
+ // Nobody answered. Fall through to the floor rather than guessing: with no declaration and no
123
+ // recogniser, the spec's own repository is the only honest default.
124
+ }
125
+ // The floor is the old behaviour: a spec that lives beside its own code must keep the detection it
126
+ // already had, or fixing the split layout would downgrade every caught lie to "could not check".
127
+ const floor = declared && isRepo(declared) ? gitProbe(declared) : here;
128
+ return { probe: floor, from: declared ? 'config' : 'spec repo' };
129
+ }
130
+
131
+ /** `project.root` from the nearest SDD config, resolved against the directory that holds 02-DOCS. */
132
+ function declaredRoot(specPath) {
133
+ let dir = resolve(dirname(specPath));
134
+ for (let i = 0; i < 24; i += 1) {
135
+ const cfg = join(dir, '02-DOCS', 'wiki', 'sdd', 'config.yaml');
136
+ if (existsSync(cfg)) {
137
+ try {
138
+ const block = /^project:[ \t]*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(readFileSync(cfg, 'utf8'));
139
+ const m = block && /^[ \t]+root:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
140
+ // Relative to the directory that owns 02-DOCS, not to the config file and not to the cwd:
141
+ // the only reading where `.` keeps meaning what it means today and the answer does not change
142
+ // with where the gate was invoked from.
143
+ if (m) return resolve(dir, m[1].replace(/^['"]|['"]$/g, ''));
144
+ } catch { /* unreadable config → derive instead */ }
145
+ return null;
146
+ }
147
+ const up = dirname(dir);
148
+ if (up === dir) break;
149
+ dir = up;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** Git repositories directly inside `root` — far enough to find a child, near enough to still be this project. */
155
+ function children(root) {
156
+ try {
157
+ return readdirSync(root, { withFileTypes: true })
158
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
159
+ .map((e) => join(root, e.name))
160
+ .filter(isRepo)
161
+ // A linked worktree has a `.git` file and the same history, so without this a repo and its own
162
+ // worktree look like two candidates that both answer — an ambiguity invented out of one repo.
163
+ .filter((dir, i, all) => all.findIndex((d) => sameRepo(d, dir)) === i);
164
+ } catch { return []; }
165
+ }
166
+
53
167
  const statusOf = (text) => (/^status:[ \t]*(.*)$/m.exec(text) || [, ''])[1];
54
168
 
55
169
  function main() {
@@ -66,11 +180,17 @@ function main() {
66
180
  // the project the spec belongs to — checking them against wherever this script happens to live
67
181
  // gives the right answer only by coincidence, and the wrong one the moment the gate is pointed at
68
182
  // another checkout.
69
- const probes = new Map();
70
- const probeFor = (path) => {
183
+ const subjects = new Map();
184
+ const announced = new Set();
185
+ const subjectFor = (path, claims) => {
71
186
  const dir = dirname(path);
72
- if (!probes.has(dir)) probes.set(dir, gitProbe(dir));
73
- return probes.get(dir);
187
+ // Keyed by directory alone. It used to include the claims, which meant a fresh resolution per
188
+ // spec — the opposite of what its own comment claimed, and measured at 0.8s → 10.3s over a
189
+ // 36-spec corpus, because every miss re-walks the children and re-runs git. A corpus shares a
190
+ // subject; that is the whole premise.
191
+ const key = dir;
192
+ if (!subjects.has(key)) subjects.set(key, resolveSubject(path, claims));
193
+ return subjects.get(key);
74
194
  };
75
195
  let anyProbe = false;
76
196
  for (const path of targets) {
@@ -92,12 +212,24 @@ function main() {
92
212
  }
93
213
  // A status that still matches the repository prints nothing: the report grows only where there
94
214
  // is something to act on (P5).
95
- const probe = probeFor(path);
215
+ const claims = statusClaims(statusOf(text));
216
+ const subject = claims.length ? subjectFor(path, claims) : { probe: null };
217
+ // A subject nobody declared was inferred from the claims themselves, which is a guess. Say it
218
+ // once per run: silent inference is how a coincidence passes for a verdict.
219
+ if (subject.from === 'derived' && subject.probe && !announced.has(subject.probe.root)) {
220
+ announced.add(subject.probe.root);
221
+ console.log(` note: subject derived (not declared) — asking ${subject.probe.root}; set project.root to settle it`);
222
+ }
223
+ const probe = subject.probe;
96
224
  anyProbe = anyProbe || Boolean(probe);
97
- for (const v of checkClaims(statusClaims(statusOf(text)), probe)) {
225
+ for (const v of checkClaims(claims, probe)) {
98
226
  if (v.verdict === 'holds') continue;
99
227
  if (v.verdict === 'stale') drifted += 1;
100
- console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${v.reason}`);
228
+ // Every non-green line names where it looked, so the reader can go and check by hand (P6).
229
+ const rel = probe ? relative(process.cwd(), probe.root) : '';
230
+ const where = probe ? ` [asked ${!rel || rel.startsWith('..') ? probe.root : rel}]` : '';
231
+ const reason = !probe && subject.note ? subject.note : v.reason;
232
+ console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${reason}${where}`);
101
233
  }
102
234
  }
103
235
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { resolve, join } from 'node:path';
3
+ import { resolve, join, dirname } from 'node:path';
4
4
  import { capture, resume } from './session-memory-core.mjs';
5
5
 
6
6
  const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
@@ -15,6 +15,20 @@ function projectSettings(cwd) {
15
15
  }
16
16
  }
17
17
 
18
+ // The project is the nearest ancestor holding `.rsc.json` — the file that IS the harness's decision.
19
+ // The hook payload's `cwd` is only where the tool call happened to run; inside a container of child
20
+ // repos that is routinely a subdirectory with no harness of its own, and anchoring there scatters
21
+ // one session's journal across children. With no harness anywhere above, the cwd stays the project.
22
+ function nearestHarness(dir) {
23
+ let current = dir;
24
+ for (;;) {
25
+ if (existsSync(join(current, '.rsc.json'))) return current;
26
+ const parent = dirname(current);
27
+ if (parent === current) return dir;
28
+ current = parent;
29
+ }
30
+ }
31
+
18
32
  function sessionId(native, target) {
19
33
  return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
20
34
  || native?.thread_id || `${target}-${process.ppid}`;
@@ -63,7 +77,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
63
77
  try {
64
78
  if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
65
79
  if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
66
- const project = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
80
+ const project = nearestHarness(resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd()));
67
81
  if (!existsSync(project)) throw new Error('project directory unavailable');
68
82
  const config = settings || projectSettings(project);
69
83
  if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };