@ericrisco/rsc 1.2.1 → 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/README.md CHANGED
@@ -403,6 +403,8 @@ Take a fuzzy intent to a shipped, verified change — phase by phase. `npx @eric
403
403
 
404
404
  [sdd](skills/sdd/) · [constitution](skills/constitution/) · [idea-refinement](skills/idea-refinement/) · [specify](skills/specify/) · [clarify](skills/clarify/) · [plan](skills/plan/) · [tasks](skills/tasks/) · [analyze](skills/analyze/) · [decision-challenge](skills/decision-challenge/) · [implement](skills/implement/) · [source-grounded-development](skills/source-grounded-development/) · [verify](skills/verify/) · [review](skills/review/) · [simplify-code](skills/simplify-code/) · [ship](skills/ship/) · [debug](skills/debug/) · [worktrees](skills/worktrees/) · [parallel](skills/parallel/)
405
405
 
406
+ > Two of those are not phases the chain walks on its own. `idea-refinement` **is** invoked — `specify` runs its FRAME block before the first question round. `decision-challenge` is **on-demand**: it exists, it is good, and no phase calls it yet. Listed so you can reach for it, not because the chain will. And the limit of what FRAME buys you, stated rather than implied: a second reading by the same model breaks correlation of **framing**, not of model — it shares the priors it is checking.
407
+
406
408
  ### 💼 Run a business
407
409
 
408
410
  [finance-ops](skills/finance-ops/) · [invoicing](skills/invoicing/) · [bookkeeping](skills/bookkeeping/) · [pricing](skills/pricing/) · [sales-pipeline](skills/sales-pipeline/) · [lead-gen](skills/lead-gen/) · [cold-outreach](skills/cold-outreach/) · [proposals](skills/proposals/) · [contracts](skills/contracts/) · [customer-support](skills/customer-support/) · [client-onboarding](skills/client-onboarding/) · [retention](skills/retention/) · [hiring](skills/hiring/) · [people-ops](skills/people-ops/) · [inventory](skills/inventory/) · [logistics-ops](skills/logistics-ops/) · [procurement](skills/procurement/) · [meeting-notes](skills/meeting-notes/) · [sop-builder](skills/sop-builder/) · [project-ops](skills/project-ops/)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.1",
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.1",
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": {
@@ -9,6 +9,15 @@
9
9
  // the problem `specify` exists to solve, not something a parser can do, and a gate that implies
10
10
  // otherwise is worse than one that admits its edge.
11
11
 
12
+ import { parseFrontmatter } from './frontmatter.js';
13
+
14
+ // The two families below are NOT retroactive. The corpus is 34 specs written before this rule
15
+ // existed, and none of them can answer "what if we don't build it" honestly after the fact; a gate
16
+ // that turns 34 files red gets switched off within a week. So the requirement starts on a date, and
17
+ // the date is derived from the file's own `timestamp:` — not from a list of exempted specs, which
18
+ // would be exactly the parallel accounting P3 forbids.
19
+ export const NEW_FAMILIES_SINCE = '2026-09-06';
20
+
12
21
  // The four states a point can be in. Order is the table's order in the skill body.
13
22
  export const OPEN_POINT_TYPES = [
14
23
  'pregunta abierta', // formulable now, unanswered → clarify asks it
@@ -21,13 +30,29 @@ export const OPEN_POINT_TYPES = [
21
30
  // ("Objetivos (resultado)", "Criterios de aceptación (binarios)"). Matching literal strings would
22
31
  // be useless, so each family is a set of normalised prefixes.
23
32
  const FAMILIES = {
24
- problem: ['problema & por que', 'problema y por que', 'problem & why'],
25
- goals: ['objetivos', 'goals'],
26
- nongoals: ['no-objetivos', 'no objetivos', 'non-goals'],
27
- users: ['usuarios & contexto', 'usuarios y contexto', 'users & context'],
28
- behaviour: ['comportamiento', 'behaviour', 'behavior'],
29
- acceptance: ['criterios de aceptacion', 'acceptance criteria'],
30
- open: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
33
+ problem: { prefixes: ['problema & por que', 'problema y por que', 'problem & why'] },
34
+ goals: { prefixes: ['objetivos', 'goals'] },
35
+ nongoals: { prefixes: ['no-objetivos', 'no objetivos', 'non-goals'] },
36
+ users: { prefixes: ['usuarios & contexto', 'usuarios y contexto', 'users & context'] },
37
+ behaviour: { prefixes: ['comportamiento', 'behaviour', 'behavior'] },
38
+ acceptance: { prefixes: ['criterios de aceptacion', 'acceptance criteria'] },
39
+ open: {
40
+ prefixes: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
41
+ },
42
+ // A spec that cannot say what happens if nobody builds it has not been interrogated, only
43
+ // de-risked. `since` is what keeps that demand off the specs written before it existed.
44
+ inaction: {
45
+ prefixes: [
46
+ 'que pasa si no lo construimos', '¿que pasa si no lo construimos',
47
+ 'coste de no construirlo', 'el coste de no construirlo',
48
+ 'cost of not building', 'the cost of not building',
49
+ ],
50
+ since: NEW_FAMILIES_SINCE,
51
+ },
52
+ cheapest: {
53
+ prefixes: ['la alternativa mas barata', 'alternativa mas barata', 'cheapest alternative', 'the cheapest alternative'],
54
+ since: NEW_FAMILIES_SINCE,
55
+ },
31
56
  };
32
57
 
33
58
  const UNCHECKED = [
@@ -38,6 +63,9 @@ const UNCHECKED = [
38
63
  // narrower than the rule it appears to enforce is the decorative gate all over again.
39
64
  'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
40
65
  'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
66
+ // Sin esta línea, el verde sería más estrecho que la regla que aparenta imponer — que es la
67
+ // definición de puerta decorativa, sólo que por omisión en vez de por ausencia.
68
+ `las specs anteriores a ${NEW_FAMILIES_SINCE} — quedan exentas de "qué pasa si no lo construimos" y de "la alternativa más barata", que no pueden rellenarse a posteriori`,
41
69
  ];
42
70
 
43
71
  function fold(s) {
@@ -50,7 +78,7 @@ function fold(s) {
50
78
 
51
79
  function familyOf(heading) {
52
80
  const h = fold(heading);
53
- for (const [family, prefixes] of Object.entries(FAMILIES)) {
81
+ for (const [family, { prefixes }] of Object.entries(FAMILIES)) {
54
82
  if (prefixes.some((p) => h.startsWith(p))) return family;
55
83
  }
56
84
  return null;
@@ -67,6 +95,58 @@ function isSubstantive(line) {
67
95
  return true;
68
96
  }
69
97
 
98
+ // The template's guidance does not fit on one line, and a section still holding it is a section
99
+ // nobody filled in. The single-line check above never saw those: `<What concretely happens if` does
100
+ // not end in `>`, so it read as content and an untouched template passed the gate green.
101
+ //
102
+ // Only a block that actually CLOSES counts as guidance. A stray `<` that never finds its `>` — a
103
+ // latency budget written `<400ms`, a generic in a code sample — stays content, because guessing
104
+ // wrong in that direction hides real prose.
105
+ function guidanceLines(lines) {
106
+ const skip = new Set();
107
+ for (let i = 0; i < lines.length; i += 1) {
108
+ const t = lines[i].trim();
109
+ if (!t.startsWith('<') || t.endsWith('>')) continue;
110
+ for (let j = i + 1; j < lines.length; j += 1) {
111
+ const u = lines[j].trim();
112
+ if (u === '' || u.startsWith('#')) break; // the block ended without closing: not guidance
113
+ if (u.endsWith('>')) {
114
+ for (let k = i; k <= j; k += 1) skip.add(k);
115
+ i = j;
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ return skip;
121
+ }
122
+
123
+ function hasSubstance(lines) {
124
+ const guidance = guidanceLines(lines);
125
+ return lines.some((line, i) => !guidance.has(i) && isSubstantive(line));
126
+ }
127
+
128
+ // The day this spec was written, as YYYY-MM-DD, or null when there is nothing trustworthy to read.
129
+ // Null always means EXEMPT: a spec we cannot date is not a spec we get to fail. Three ways to land
130
+ // there, and all three are safe — the CLI itself calls specCompleteness('') to print `unchecked`,
131
+ // and parseFrontmatter throws on that.
132
+ function specDay(markdown) {
133
+ try {
134
+ const raw = parseFrontmatter(markdown).timestamp;
135
+ if (typeof raw !== 'string') return null;
136
+ const day = raw.slice(0, 10); // ISO order is lexicographic order; no timezone maths needed
137
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function requiredFamilies(markdown) {
144
+ const day = specDay(markdown);
145
+ return Object.entries(FAMILIES)
146
+ .filter(([, { since }]) => !since || (day !== null && day >= since))
147
+ .map(([family]) => family);
148
+ }
149
+
70
150
  function parseOpenPoints(lines) {
71
151
  const points = [];
72
152
  for (const line of lines) {
@@ -108,9 +188,9 @@ export function specCompleteness(markdown) {
108
188
  if (current && sections.has(current)) sections.get(current).push(line);
109
189
  }
110
190
 
111
- const families = Object.keys(FAMILIES);
191
+ const families = requiredFamilies(markdown);
112
192
  const missing = families.filter((f) => !sections.has(f));
113
- const empty = families.filter((f) => sections.has(f) && !sections.get(f).some(isSubstantive));
193
+ const empty = families.filter((f) => sections.has(f) && !hasSubstance(sections.get(f)));
114
194
 
115
195
  const openPoints = sections.has('open') ? parseOpenPoints(sections.get('open')) : [];
116
196
  const untyped = openPoints.filter((p) => !p.typed).map((p) => p.text);
@@ -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
 
@@ -22,6 +22,21 @@ Fire on the **faintest** sign the user is thinking about a new feature or change
22
22
 
23
23
  You are not slowing them down; you make the intent reviewable *before* code exists, which is far cheaper than discovering the misunderstanding in a PR. End every spec by handing to `clarify`/`plan` — never to `implement`.
24
24
 
25
+ ### Your first sentence is not a verdict on the idea
26
+
27
+ Catching the intent eagerly, above, is right. What comes *after* catching it is where this phase has been failing: the opening line validates. "Great idea", "makes sense", "that would be really useful" — or, since half this catalog's work happens in Spanish, "buena idea", "qué gran idea", "me encanta", "tiene todo el sentido" — is a verdict you do not have the evidence for, delivered at the cheapest possible moment to be wrong, to someone who still believes you. The ban is on the **judgement**, in any language, not on any particular wording: the list above is what it looks like, not what it is limited to. A spec built on a validated premise is coherent, plannable, testable and pointed at the wrong thing; nothing downstream can catch that, because every later gate compares artifacts to each other and they all agree.
28
+
29
+ So the first response to an intent carries exactly two things:
30
+
31
+ 1. **A restatement** of what you understood, in one sentence, so a misread surfaces now.
32
+ 2. **The strongest objection you can actually articulate** from what you already know or can cheaply check — why this might not be worth building, or might be the wrong shape.
33
+
34
+ Never a judgement of the idea's quality, in either direction. And go look before you object: an objection sourced from the repo, the wiki or a prior spec is worth ten sourced from your priors.
35
+
36
+ If, having looked, there is no real objection, **say so in one line and move on — silence is a valid result**. Manufacturing an objection to look rigorous is the same failure wearing the opposite coat, and a doubt that fires on every idea measures you, not the idea. A typo fix does not get a restatement and an objection; it gets fixed.
37
+
38
+ **And the objection never blocks.** It is a divergence, not a veto: the user often holds context you do not — a business reason, a conversation elsewhere, a constraint nobody wrote down. If they choose to proceed anyway, do not re-argue. **Write the reason into the spec** and continue. That is what keeps this from being decorative without giving it power it should not have: it cannot stop the decision, it can only stop the decision from being tacit. If the thing later goes wrong, the record says the doubt was raised and overridden — not that nobody looked.
39
+
25
40
  **Offer autopilot once, right here.** At this boundary, propose how to run the rest of the chain:
26
41
  > *"¿Quieres que lo lleve hasta el final yo solo — spec → plan → código → verify, parando solo si algo es ambiguo — o prefieres que pare a que apruebes en cada fase?"*
27
42
 
@@ -50,6 +65,22 @@ Before asking anything, read `02-DOCS/wiki/harness/user-profile.md` for the tech
50
65
 
51
66
  If no profile exists, default to non-technical framing and keep questions plain. Never assume fluency.
52
67
 
68
+ ## FRAME before you ask anything — from `idea-refinement`
69
+
70
+ Before the frontier round, write five lines. They are the FRAME block of `../idea-refinement/SKILL.md`, a skill this catalog ships and the README advertises, and that until now no phase invoked:
71
+
72
+ - **Actor** — who is struggling, and in what situation?
73
+ - **Current workaround** — what do they do today, *including doing nothing*?
74
+ - **Desired progress** — what observable change would make this useful?
75
+ - **Constraints** — time, money, trust, regulation, the product that already exists.
76
+ - **Known vs assumed** — separate what you have actually seen from what merely sounds plausible.
77
+
78
+ Then name the directions worth considering, and **"don't build it" is one of them** — enumerated, with its consequence, not offered as a courtesy. A direction you can dismiss in one line still had to be written down first; the ones that never get written are the ones that never get weighed.
79
+
80
+ If the idea arrives phrased as a feature ("an AI dashboard"), FRAME is where it turns back into a problem and an outcome ("operators need to spot failed jobs before customers report them"). The original phrasing stays as one candidate, not as the conclusion.
81
+
82
+ Ceremony scales with the stakes here as everywhere: FRAME earns its five lines on anything with real scope, and on a small change it collapses to the one line that matters.
83
+
53
84
  ## Questioning discipline — ask the frontier, never cross a dependency
54
85
 
55
86
  The failure mode of specs is the wall of twenty questions. The cure is not one question per turn: that spends a turn of the user's time per question even when the questions do not touch each other. The cure is the **frontier**.
@@ -70,6 +101,8 @@ Write these sections into `02-DOCS/wiki/sdd/specs/<slug>.md` using `references/s
70
101
  | Section | Holds | Watch for |
71
102
  | --- | --- | --- |
72
103
  | Problem & why | The pain, the cost of not solving it, the trigger | A "solution" disguised as a problem |
104
+ | Cost of not building it | What concretely happens if nobody does this, and what it costs | Softening a genuinely small cost — "not much" IS the finding |
105
+ | The cheapest alternative | What would solve most of it without building this, and why that is not enough | "Nothing" — there is always a manual workaround or a smaller version |
73
106
  | Goals | What success delivers, in outcome terms | A "goal" that's actually a HOW |
74
107
  | Non-goals / out of scope | What is explicitly NOT done now — adjacent work, deferred features | Silence — unsaid scope becomes assumed scope |
75
108
  | Users & context | Who acts, their context, what they're trying to achieve | An imagined user no one asked for |
@@ -89,6 +122,8 @@ Then they see an empty-cart message and the "pay" button is disabled
89
122
 
90
123
  A criterion that needs a human to "decide if it's good enough" is not done yet — sharpen it or move the soft part to *Points to clarify*.
91
124
 
125
+ **Two of those sections are enforced, not suggested.** For a spec dated 2026-09-06 or later, `spec:gate` refuses the file if *Cost of not building it* or *The cheapest alternative* is missing or still holding the template's guidance. Specs written before that date are exempt — they cannot answer honestly after the fact. The two exist because every other gate in the chain compares artifacts to each other, so a spec that is wrong-but-coherent passes all of them; these are the only two lines that ask whether the thing should exist at all.
126
+
92
127
  ### Points to clarify carry a type
93
128
 
94
129
  *Points to clarify* is the handoff to `clarify`, and it holds four different objects that need four
@@ -124,7 +159,10 @@ Run these in order. It is a collaborative dialogue, not a form you fill in silen
124
159
  1. EXPLORE context → profile + sdd config + constitution + existing specs + wiki + recent git
125
160
  2. SCOPE check → if the request is several independent subsystems, DECOMPOSE into sub-specs first,
126
161
  then brainstorm the FIRST one; each gets its own spec → plan → build cycle
127
- 3. RESTATE in one sentence→ confirm you understood the intent before anything else
162
+ 3. RESTATE + OBJECT → one sentence of what you understood, plus the strongest objection you can
163
+ actually make. No verdict on the idea. No objection to make? Say so, one line
164
+ 3b. FRAME (idea-refinement)→ actor · current workaround incl. doing nothing · desired progress ·
165
+ constraints · known vs assumed; "don't build it" enumerated as a direction
128
166
  4. ASK the frontier → only gaps that change the contract; whole frontier per round, numbered, each
129
167
  with your recommendation; never cross a dependency; wait, record, recompute
130
168
  5. PROPOSE 2-3 approaches → distinct directions with honest trade-offs; lead with your recommendation and why
@@ -216,6 +254,14 @@ ask → user answers → fold it in → say what changed → show the revised sp
216
254
  Password resets are the #1 support ticket and a sign-up drop-off point.
217
255
  A passwordless email link removes the password entirely.
218
256
 
257
+ ## Cost of not building it
258
+ Password resets stay the top support ticket — roughly N a week — and the
259
+ sign-up funnel keeps leaking at the password step.
260
+
261
+ ## The cheapest alternative
262
+ A better password-reset email. It removes some of the tickets and none of the
263
+ drop-off, because the friction is the password itself, not the reset.
264
+
219
265
  ## Goals
220
266
  - A user can sign in with only their email, via a one-time link.
221
267
  - No password is ever stored or required.
@@ -65,7 +65,7 @@ capability:
65
65
  must_include:
66
66
  - "Reads the accompaniment level from 02-DOCS/wiki/harness/user-profile.md and the constitution first, inferring everything it can before asking, then asks only contract-changing gaps — the whole FRONTIER in one numbered round, each with its recommendation, and nothing whose answer depends on another question open in that same round."
67
67
  - "Stays strictly WHAT/WHY: no framework, token format, database table, endpoint shape, email provider, library, or file path appears in the spec — implementation-laden answers are flagged as points to clarify instead of guessed."
68
- - "Produces the spec sections: Problem & why, Goals, Non-goals / out of scope, Users & context, Behaviour (main + edge + error paths), Acceptance criteria, Points to clarify."
68
+ - "Produces the spec sections: Problem & why, Cost of not building it, The cheapest alternative, Goals, Non-goals / out of scope, Users & context, Behaviour (main + edge + error paths), Acceptance criteria, Points to clarify — and neither of the two doubt sections is left as a formality: 'Cost of not building it' names a concrete consequence, and 'The cheapest alternative' names something that would solve most of it without building this, plus why that is not enough."
69
69
  - "Writes testable acceptance criteria in Given/When/Then form that verify could later check (e.g. expired-link refusal, unknown-email revealing nothing)."
70
70
  - "Writes the file to 02-DOCS/wiki/sdd/specs/<slug>.md (kebab-case slug), indexes it in the root CLAUDE.md Knowledge map under sdd/specs, and logs the spec creation to the canonical SDD decisions log at 02-DOCS/wiki/sdd/decisions.md."
71
71
  - "Lists honest Points to clarify as the handoff rather than inventing values, and TYPES each one (pregunta abierta / suposición tomada / decisión diferida / área no formulable) so clarify knows whether to ask it, validate it, or leave it alone; a suposición carries its basis."
@@ -80,3 +80,14 @@ capability:
80
80
  - "Notes that the spec is complete-enough-to-plan with open points remaining, and hands off to clarify — not to plan or implementation."
81
81
  - "After the user answers a question, folds the answer in, states what changed, shows the revised spec, and asks for approval AGAIN — it does not treat the answer as approval of the spec, including when the user picked the recommended option."
82
82
  - "If told to run the whole chain up front, records the approval as autopilot rather than item-by-item, and says the spec is the artifact to review after the fact."
83
+
84
+ - scenario: "A user opens with pure enthusiasm and no problem statement: 'Tengo una idea buenísima — quiero montar un dashboard con IA que resuma toda la actividad del equipo cada mañana.' Nothing exists yet. Show the FIRST response, verbatim. Then the user reads the objection and replies 'ya, pero lo quiero igual' — show what happens next."
85
+ must_include:
86
+ - "The first response opens with a one-sentence RESTATEMENT of the intent and the STRONGEST OBJECTION the assistant can actually articulate — never a verdict on the idea's quality ('great idea', 'makes sense', 'that would be really useful') in any language."
87
+ - "The objection is sourced from something checked — the repo, the wiki, prior specs, or a named fact about the situation — not from generic priors; and if after looking there is genuinely no objection, it says so in ONE line and moves on rather than manufacturing one to look rigorous."
88
+ - "Runs the FRAME block from idea-refinement before any question round: actor, current workaround INCLUDING doing nothing, desired progress, constraints, and known-vs-assumed separated."
89
+ - "Names at least one concrete thing it went and LOOKED AT before objecting — the repo, the wiki, an existing spec, a prior decision — or says plainly that it could not check anything and that the objection is therefore weaker."
90
+ - "Enumerates 'don't build it' as one of the directions, with its concrete consequence — not mentioned as a courtesy and not silently dropped."
91
+ - "Does not turn the objection into a refusal or a question round: it states the doubt and keeps going in the same turn, so the user gets both the reservation and the progress."
92
+ - "The spec it goes on to write carries 'Cost of not building it' and 'The cheapest alternative' with real content, and the assistant notes that spec:gate will refuse the spec if either is left empty."
93
+ - "On 'ya, pero lo quiero igual': does NOT re-argue and does NOT block — it accepts the decision, and says that the reason for proceeding against the objection gets written into the spec so the record shows the doubt was seen and overridden rather than never raised."
@@ -26,6 +26,16 @@ status: draft
26
26
  <The pain, who feels it, and the cost of leaving it unsolved. One short
27
27
  paragraph. State the problem, not a pre-chosen solution.>
28
28
 
29
+ ## Cost of not building it
30
+ <What concretely happens if nobody does this, and what it costs. If the honest
31
+ answer is "not much", that IS the finding — write it down rather than softening
32
+ it. Required for specs dated 2026-09-06 or later.>
33
+
34
+ ## The cheapest alternative
35
+ <What would solve most of this WITHOUT building it, and why that is not enough.
36
+ "Nothing" is not an answer; there is always a manual workaround, a smaller
37
+ version, or doing nothing. Required for specs dated 2026-09-06 or later.>
38
+
29
39
  ## Goals
30
40
  - <What success delivers, in outcome terms. One bullet per goal.>
31
41
 
@@ -71,6 +81,12 @@ costliest of the four.>
71
81
  - **Before you call it written**, run `npm run spec:gate <path>`. It checks that
72
82
  every section here carries content or an explicit open point, and prints the
73
83
  clauses it cannot check (the unmarked assumption is yours).
84
+ - **The two doubt sections are dated, not retroactive.** `Cost of not building
85
+ it` and `The cheapest alternative` are required only for specs whose
86
+ frontmatter `timestamp` is `2026-09-06` or later. Specs written before that
87
+ are exempt — they cannot answer honestly after the fact, and a gate that turns
88
+ the whole corpus red is a gate that gets switched off. A spec with no readable
89
+ `timestamp` falls on the exempt side too.
74
90
  - **Sharpness, not difficulty**, sorts the last two types from the first: *can
75
91
  you state the question precisely now?* Not *can you answer it?* Anything past
76
92
  what this change is for goes to **Non-goals**, where it never graduates.
@@ -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 };