@ericrisco/rsc 3.0.9 → 3.0.11
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 +67 -6
- package/manifest.json +10 -2
- package/package.json +1 -1
- package/scripts/doctor.js +15 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +58 -4
- package/scripts/lib/review-findings.js +175 -0
- package/scripts/rsc.js +160 -12
- package/skills/code-review/SKILL.md +2 -0
- package/skills/review/SKILL.md +24 -3
- package/targets/agents.js +181 -19
- package/targets/git-permissions.js +254 -16
package/README.md
CHANGED
|
@@ -98,8 +98,8 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
98
98
|
|
|
99
99
|
**Commit, push and pull request without a prompt each time.** A harness installed from scratch lets
|
|
100
100
|
the agent run `git commit`, `git push` and `gh pr create` without asking, because those are the steps
|
|
101
|
-
that close every lane. A force-push still asks.
|
|
102
|
-
refused whatever the permission says. It is a project decision, saved as `gitPermissions` in
|
|
101
|
+
that close every lane. A force-push still asks. In Claude Code, rsc's guards still decide: a commit on a
|
|
102
|
+
closed `main` is refused whatever the permission says. No other assistant runs those guards. It is a project decision, saved as `gitPermissions` in
|
|
103
103
|
`.rsc.json`. A clone gets the same setting. A project adopted before this is left as it was until
|
|
104
104
|
someone runs `rsc git-permissions on`.
|
|
105
105
|
|
|
@@ -108,16 +108,32 @@ someone runs `rsc git-permissions on`.
|
|
|
108
108
|
| Claude Code | `.claude/settings.json` → `permissions.allow` (force-push under `ask`). Claude Code applies it once the folder is trusted. |
|
|
109
109
|
| Codex | `.codex/rules/rsc-git.rules` (`prefix_rule`; force-push → `prompt`). Loaded when the project is trusted. |
|
|
110
110
|
| Gemini | `.gemini/settings.json` → `tools.allowed` |
|
|
111
|
-
| OpenCode | `opencode.json`
|
|
111
|
+
| OpenCode | `opencode.json`. With OpenCode 2 installed: the top-level `permissions` list (`shell` rules; force-push → `ask`). rsc's rules go before yours, so any rule of yours that matches later still wins; only a leading `"*"` allow/ask default stays in front. With OpenCode 1.x, or when `opencode --version` cannot be read: `permission.bash`, which both versions read. Moving to 2 moves rsc's entries over. An `opencode.jsonc` is read but never rewritten (its comments would be lost): rsc prints the rules to paste. |
|
|
112
112
|
| Cursor | Not covered: its CLI matches only the first word, so allowing a push would allow every git command. |
|
|
113
113
|
| DeepSeek Harness | Not covered: dsh has no per-command allow list. Its own Permissions selector (`/permission`) sets approval for every command at once. |
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
rsc git-permissions status #
|
|
117
|
-
rsc git-permissions off #
|
|
116
|
+
rsc git-permissions status # per assistant: wired or not, and in which format (--json for scripts)
|
|
117
|
+
rsc git-permissions off # remove rsc's rules (saved in .rsc.json)
|
|
118
118
|
rsc git-permissions on # turn it on, also for a project adopted before
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
**What `off` turns off.** Only rsc's allow rules. Each command says, per assistant, what rsc controls
|
|
122
|
+
there, what stops when it is off, and what is left to the assistant. `off` does not hand rsc's
|
|
123
|
+
protections to the assistant. Claude Code keeps rsc's guards (branch-guard, danger-guard), because
|
|
124
|
+
they do not depend on this switch. OpenCode, Codex and Gemini run no rsc guard. There, your own
|
|
125
|
+
settings decide. With no rule matching, OpenCode 2 asks and OpenCode 1.x runs the command. To refuse a
|
|
126
|
+
force-push in OpenCode 2, add this at the end of `permissions` (use `"ask"` to get a human approval):
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "action": "shell", "resource": "git push --force*", "effect": "deny" },
|
|
130
|
+
{ "action": "shell", "resource": "git push -f*", "effect": "deny" },
|
|
131
|
+
{ "action": "shell", "resource": "git push * --force*", "effect": "deny" },
|
|
132
|
+
{ "action": "shell", "resource": "git push * -f*", "effect": "deny" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
In OpenCode 1.x the same patterns go at the end of `permission.bash` (`"git push --force*": "deny"`, …).
|
|
136
|
+
|
|
121
137
|
**How does it know a project is "complex or in production"?** There are two layers. A hook counts
|
|
122
138
|
what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
|
|
123
139
|
at least two people in the last 50 commits. If any of those is there, a commit on the default branch
|
|
@@ -216,6 +232,14 @@ Every turn takes exactly one lane, and your agent names the one it took in a lin
|
|
|
216
232
|
|
|
217
233
|
The agent says which lane it took and why, in one line. Ask for the other one and it switches.
|
|
218
234
|
|
|
235
|
+
**The review only brings you what is real.** In SDD, `review` sends three adversarial reviewers at
|
|
236
|
+
the diff (correctness, security, tests). They often find the same defect in different words, and a
|
|
237
|
+
reviewer hunting for problems can overstate one. So before anything reaches you,
|
|
238
|
+
`rsc review consolidate` merges the duplicates (same place, or the same quoted line), and every
|
|
239
|
+
serious finding left goes to a fresh `finding-verifier` agent whose starting position is that the
|
|
240
|
+
finding is false. It blocks the merge only if the verifier reproduces it or traces it. You get
|
|
241
|
+
"11 reported → 4 unique → 2 confirmed", and the refuted ones are listed with the reason.
|
|
242
|
+
|
|
219
243
|
**Isolation cleans itself up.** When a lane opens a branch or a worktree, you no longer have to
|
|
220
244
|
remember to retire it: a `post-merge` hook does it the moment the work lands on the trunk, on both
|
|
221
245
|
landing paths — a local merge and the pull after a forge merge. It removes only what is provably
|
|
@@ -403,6 +427,11 @@ rsc memory resume # print this branch/worktree continuation
|
|
|
403
427
|
rsc memory learn --text "…" --evidence "…" --confidence 0.8 --approve
|
|
404
428
|
rsc memory off # disable hooks, commands and injection project-wide
|
|
405
429
|
rsc sync --target claude,codex # refresh managed skills/hooks from the current package version
|
|
430
|
+
rsc agent-model opencode openai/gpt-5 # pin the model the generated agents carry (saved in .rsc.json)
|
|
431
|
+
rsc agent-model opencode inherit # back to the session's model (OpenCode's default)
|
|
432
|
+
rsc agents status # installed agents, and which ones you edited
|
|
433
|
+
rsc agents reset developer # take rsc's version of an agent you edited (yours is backed up first)
|
|
434
|
+
rsc review consolidate c.md s.md t.md # merge the review lenses' findings; serious ones go to finding-verifier
|
|
406
435
|
rsc backups # list project-local snapshots
|
|
407
436
|
rsc restore latest --dry-run # preview restoring the newest snapshot
|
|
408
437
|
rsc restore <snapshot-id> # restore a project-local snapshot
|
|
@@ -410,6 +439,29 @@ rsc upgrade --dry-run # show npm upgrade + sync commands
|
|
|
410
439
|
rsc uninstall postgresdb --dry-run # preview a removal
|
|
411
440
|
```
|
|
412
441
|
|
|
442
|
+
### `rsc doctor`: harness health vs onboarding readiness
|
|
443
|
+
|
|
444
|
+
`doctor` answers two different questions, and prints both first, on separate lines:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
Harness health: healthy
|
|
448
|
+
Onboarding readiness: pending
|
|
449
|
+
Pending: 02-DOCS/wiki/sdd/constitution.md (draft)
|
|
450
|
+
Next: Complete 02-DOCS/wiki/sdd/constitution.md with the `constitution` phase …
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- **Harness health** (`healthy` in `--json`) says whether what is installed works: skills, agents,
|
|
454
|
+
commands and hook scripts are on disk. It is the only thing the exit code follows (1 when
|
|
455
|
+
unhealthy), so CI and editor extensions can rely on it.
|
|
456
|
+
- **Onboarding readiness** (`onboarding` in `--json`: `status`, `missing`, `pending`, `action`) says
|
|
457
|
+
whether onboarding is finished. It checks the accepted plan's floor, the same check that prints
|
|
458
|
+
`RSC_ONBOARDING_READY`, plus drafts. The status is `ready`; `pending` when a draft such as the
|
|
459
|
+
onboarding constitution still has to be completed; `incomplete` when part of the floor is missing,
|
|
460
|
+
for example a deleted constitution in an SDD plan, with the exact phase or command that fixes it;
|
|
461
|
+
or `not onboarded` when there is no onboarding record, as in a manual `rsc add` install. Readiness
|
|
462
|
+
never changes `healthy` or the exit code. `Missing:` and `Pending:` appear only when they have
|
|
463
|
+
entries.
|
|
464
|
+
|
|
413
465
|
---
|
|
414
466
|
|
|
415
467
|
|
|
@@ -841,11 +893,20 @@ The richer surfaces are intentionally narrower than skill support:
|
|
|
841
893
|
| Windsurf, Cline, Roo | unsupported | yes | unsupported |
|
|
842
894
|
| Antigravity, Zed, Continue, Amp, Jules, Aider | unsupported | unsupported | unsupported |
|
|
843
895
|
|
|
844
|
-
`manifest.json` is the generated public inventory:
|
|
896
|
+
`manifest.json` is the generated public inventory: 34 agents (5 base + 29
|
|
845
897
|
selective specialists) and 53 command entries (20 fixed + 33 stack aliases).
|
|
846
898
|
Unsupported means rsc writes nothing for that surface; it does not emulate a
|
|
847
899
|
provider feature with an unverified file.
|
|
848
900
|
|
|
901
|
+
**Generated agents follow your project, not rsc's defaults.** OpenCode agents carry no `model:`, so
|
|
902
|
+
they use whatever model the session runs (a local one, OpenAI, Anthropic). To pin one for the whole
|
|
903
|
+
team, run `rsc agent-model opencode <model>`: it is saved as `agentModels` in `.rsc.json` and works
|
|
904
|
+
for any target with agents (`inherit` drops the pin). OpenCode agents never grant a tool either: a
|
|
905
|
+
read-only reviewer only gets `edit`, `write`, `patch` and `bash` set to `false`, and an agent that may
|
|
906
|
+
edit or run commands gets no `tools` block, so your project's permission policy decides. If you edit
|
|
907
|
+
a generated agent, `rsc sync` keeps your file and says so. `rsc agents reset <name>` (or `--all`)
|
|
908
|
+
takes rsc's version back after a backup in `.rsc/backups/`.
|
|
909
|
+
|
|
849
910
|
---
|
|
850
911
|
|
|
851
912
|
## Skill format
|
package/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.0.
|
|
2
|
+
"version": "3.0.11",
|
|
3
3
|
"counts": {
|
|
4
4
|
"skills": 270,
|
|
5
|
-
"agents":
|
|
5
|
+
"agents": 34,
|
|
6
6
|
"commands": 53,
|
|
7
7
|
"fixedCommands": 20
|
|
8
8
|
},
|
|
@@ -5715,6 +5715,14 @@
|
|
|
5715
5715
|
"fastapi"
|
|
5716
5716
|
]
|
|
5717
5717
|
},
|
|
5718
|
+
{
|
|
5719
|
+
"id": "finding-verifier",
|
|
5720
|
+
"description": "Adversarial verifier of review findings: takes ONE consolidated finding and tries to prove it false against the code. Default stance: the finding is false; only a reproduced or traced failure is CONFIRMED. Fresh context, edits nothing.",
|
|
5721
|
+
"role": "refuter",
|
|
5722
|
+
"tier": "balanced",
|
|
5723
|
+
"tools": null,
|
|
5724
|
+
"skills": []
|
|
5725
|
+
},
|
|
5718
5726
|
{
|
|
5719
5727
|
"id": "flutter-build-resolver",
|
|
5720
5728
|
"description": "Flutter build resolver: reproduces a failing build and applies the minimum verified repair. NOT widget review or package modernization.",
|
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -17,10 +17,11 @@ import { countGaps, listSkills, listAgents } from './lib/capabilities.js';
|
|
|
17
17
|
import { resolveCommands, commandPath, targetHasCommands } from '../targets/commands.js';
|
|
18
18
|
import { inspectMemoryWiring } from '../targets/memory.js';
|
|
19
19
|
import { metricsSummary } from '../targets/session-memory-core.mjs';
|
|
20
|
-
import { agentPath } from '../targets/agents.js';
|
|
20
|
+
import { agentPath, targetHasAgents, effectiveAgentModel } from '../targets/agents.js';
|
|
21
21
|
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
22
22
|
import { versionReport } from './lib/versions.js';
|
|
23
23
|
import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
|
|
24
|
+
import { onboardingReadiness } from './lib/onboarding-apply.js';
|
|
24
25
|
|
|
25
26
|
// Gates the committed manifest says the team disarmed, still armed here. Read through the same
|
|
26
27
|
// partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
|
|
@@ -298,6 +299,11 @@ export function doctor({ target, home, cwd }) {
|
|
|
298
299
|
memory,
|
|
299
300
|
missingAgents,
|
|
300
301
|
agentCollisions: state.agentCollisions || [],
|
|
302
|
+
// #298 — generated agents the user edited and sync kept as they are. Not unhealthy: theirs on
|
|
303
|
+
// purpose. Named with the way back, so a stale edit is one command from rsc's version.
|
|
304
|
+
agentsEditedByYou: (state.agentsKept || []).map((k) => ({ ...k, action: `\`npx @ericrisco/rsc agents reset ${k.name}\` takes rsc's version (yours is backed up first).` })),
|
|
305
|
+
// Which model the agents carry: the session's (inherit) or a project pin (`rsc agent-model`).
|
|
306
|
+
agentModel: targetHasAgents(target) ? (effectiveAgentModel(target, root) || 'inherit') : null,
|
|
301
307
|
missingCommands,
|
|
302
308
|
commandOrphans,
|
|
303
309
|
commandCollisions: state.commandCollisions || [],
|
|
@@ -316,6 +322,14 @@ export function doctor({ target, home, cwd }) {
|
|
|
316
322
|
report.pending = constitutionIsDraft(root)
|
|
317
323
|
? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
|
|
318
324
|
: [];
|
|
325
|
+
// #298 — onboarding readiness is a SEPARATE question from health: is the accepted plan's floor
|
|
326
|
+
// on disk (the same check that prints RSC_ONBOARDING_READY), and is anything still a draft. It is
|
|
327
|
+
// reported, never folded into `healthy`: `healthy` and the exit code keep meaning "the installed
|
|
328
|
+
// harness works", which is what CI and editor extensions built on #277 read. Wrapped because a
|
|
329
|
+
// report must never fail for asking an extra question.
|
|
330
|
+
try { report.onboarding = onboardingReadiness(root); } catch (error) {
|
|
331
|
+
report.onboarding = { status: 'incomplete', missing: ['<onboarding readiness could not be read>'], pending: [], action: String(error?.message || error).slice(0, 200) };
|
|
332
|
+
}
|
|
319
333
|
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
|
|
320
334
|
&& !report.opencodePluginsOutdated.length;
|
|
321
335
|
return report;
|
package/scripts/install-apply.js
CHANGED
|
@@ -7,7 +7,7 @@ import { planInstall } from './install-plan.js';
|
|
|
7
7
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
8
8
|
import { shadowTarget } from '../targets/agents-md-shadow.js';
|
|
9
9
|
import {
|
|
10
|
-
targetHasAgents, reconcileAgents, agentPath, agentNames,
|
|
10
|
+
targetHasAgents, reconcileAgents, agentPath, agentNames, writeAgentsDetailed,
|
|
11
11
|
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
|
|
12
12
|
} from '../targets/agents.js';
|
|
13
13
|
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
@@ -223,6 +223,7 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
223
223
|
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
224
224
|
memory: prev.memory,
|
|
225
225
|
gitPermissions: gitPermissions ?? prev.gitPermissions,
|
|
226
|
+
agentModels: prev.agentModels,
|
|
226
227
|
onboarding: onboarding ?? prev.onboarding,
|
|
227
228
|
});
|
|
228
229
|
}
|
|
@@ -306,13 +307,19 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
306
307
|
? explicit
|
|
307
308
|
: resolveAgentNames(Object.keys(state.skills || {}), explicit);
|
|
308
309
|
const previousAgents = state.agents || [];
|
|
310
|
+
let keptAgents = [];
|
|
309
311
|
if (targetHasAgents(target)) {
|
|
310
312
|
const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), previousAgents, desiredAgents);
|
|
311
313
|
state.agents = agentResult.names;
|
|
312
314
|
state.agentCollisions = agentResult.collisions;
|
|
315
|
+
// #298 — agents the user edited and rsc left alone. Recorded so `doctor` can say it on any run,
|
|
316
|
+
// and returned so the command that just ran says it now.
|
|
317
|
+
keptAgents = agentResult.kept;
|
|
318
|
+
state.agentsKept = keptAgents.map(({ name, path }) => ({ name, path }));
|
|
313
319
|
} else {
|
|
314
320
|
state.agents = [];
|
|
315
321
|
state.agentCollisions = [];
|
|
322
|
+
state.agentsKept = [];
|
|
316
323
|
}
|
|
317
324
|
state.explicitAgents = explicit;
|
|
318
325
|
const memoryResult = policy?.memory === false
|
|
@@ -381,7 +388,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
381
388
|
if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
|
|
382
389
|
} catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
|
|
383
390
|
ignoreLocalState(cwd, target);
|
|
384
|
-
return { ...state, backup, mergeHook };
|
|
391
|
+
return { ...state, backup, mergeHook, keptAgents };
|
|
385
392
|
}
|
|
386
393
|
|
|
387
394
|
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
@@ -551,9 +558,16 @@ export async function uninstall({ skillIds = [], agentIds = [], target, home, cw
|
|
|
551
558
|
delete state.skills[id];
|
|
552
559
|
}
|
|
553
560
|
const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), state.agents || [], desiredAgents);
|
|
554
|
-
|
|
561
|
+
// An edited agent no longer wanted stays on disk (#298) and stays recorded as rsc-managed, so a
|
|
562
|
+
// later reset or uninstall can still find it.
|
|
563
|
+
state.agents = [...new Set([...agentResult.names, ...agentResult.kept.map((k) => k.name)])];
|
|
555
564
|
state.agentCollisions = agentResult.collisions;
|
|
565
|
+
state.agentsKept = agentResult.kept.map(({ name, path }) => ({ name, path }));
|
|
556
566
|
state.explicitAgents = nextExplicit;
|
|
567
|
+
for (const k of agentResult.kept) {
|
|
568
|
+
const i = removed.indexOf(k.path);
|
|
569
|
+
if (i >= 0) removed.splice(i, 1);
|
|
570
|
+
}
|
|
557
571
|
const desiredCommands = resolveCommands({
|
|
558
572
|
target,
|
|
559
573
|
skills: remainingSkillIds,
|
|
@@ -712,7 +726,30 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
712
726
|
// question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
|
|
713
727
|
// clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
|
|
714
728
|
try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
|
|
715
|
-
return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
|
|
729
|
+
return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup, keptAgents: nextState.keptAgents || [] };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* #298 — `rsc agents reset <name|--all>`: take rsc's version of agents the user edited. The user's
|
|
734
|
+
* file is snapshotted first (a backup under .rsc/backups/), and the answer says where, so taking rsc's
|
|
735
|
+
* version is never the loss the protection exists to prevent.
|
|
736
|
+
*/
|
|
737
|
+
export function resetAgents({ target, home, cwd = process.cwd(), names = [], all = false }) {
|
|
738
|
+
if (!targetHasAgents(target)) return { reset: [], backup: null, kept: [] };
|
|
739
|
+
const paths = targetPaths(target, home, cwd);
|
|
740
|
+
const state = readState(paths.stateFile);
|
|
741
|
+
const installed = state.agents || [];
|
|
742
|
+
const chosen = all ? installed : names;
|
|
743
|
+
const unknown = chosen.filter((name) => !installed.includes(name));
|
|
744
|
+
if (unknown.length) throw new Error(`${unknown.join(', ')}: not an agent rsc installed for ${target} (installed: ${installed.join(', ') || 'none'})`);
|
|
745
|
+
if (!chosen.length) return { reset: [], backup: null, kept: [] };
|
|
746
|
+
const files = chosen.map((name) => agentPath(target, cwd, name));
|
|
747
|
+
const backup = createBackup({ cwd, operation: 'agents-reset', target, paths: files.filter((f) => existsSync(f)), cliVersion: CLI_VERSION });
|
|
748
|
+
const { written } = writeAgentsDetailed(target, cwd, readDeveloperTier(cwd), chosen, { force: true });
|
|
749
|
+
state.agentsKept = (state.agentsKept || []).filter((k) => !chosen.includes(k.name));
|
|
750
|
+
writeState(paths.stateFile, state);
|
|
751
|
+
const rel = (p) => relative(cwd, p).split(sep).join('/');
|
|
752
|
+
return { reset: chosen, written, backup: backup ? rel(join(cwd, '.rsc', 'backups', backup.id)) : null };
|
|
716
753
|
}
|
|
717
754
|
|
|
718
755
|
// Remove EVERYTHING rsc put in this project: installed skills across all targets,
|
|
@@ -15,7 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
export const MANIFEST = '.rsc.json';
|
|
16
16
|
export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
|
|
17
17
|
|
|
18
|
-
const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'onboarding'];
|
|
18
|
+
const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'agentModels', 'onboarding'];
|
|
19
19
|
|
|
20
20
|
export function readManifest(cwd = process.cwd()) {
|
|
21
21
|
const file = manifestPath(cwd);
|
|
@@ -40,10 +40,19 @@ export function readManifest(cwd = process.cwd()) {
|
|
|
40
40
|
memory: raw.memory ?? undefined,
|
|
41
41
|
// true on a project installed from scratch since 3.0.3; absent on older ones; false once turned off.
|
|
42
42
|
gitPermissions: typeof raw.gitPermissions === 'boolean' ? raw.gitPermissions : undefined,
|
|
43
|
+
// #298 — per-target agent model: { opencode: 'openai/gpt-5' | 'inherit' }. Only plain objects of
|
|
44
|
+
// strings survive the read; what each value may contain is checked where it is rendered.
|
|
45
|
+
agentModels: agentModelsFrom(raw.agentModels),
|
|
43
46
|
onboarding: raw.onboarding ?? undefined,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
function agentModelsFrom(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
52
|
+
const entries = Object.entries(value).filter(([, v]) => typeof v === 'string').sort(([a], [b]) => a.localeCompare(b));
|
|
53
|
+
return entries.length ? Object.fromEntries(entries) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
// Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
|
|
48
57
|
// unchanged manifest must produce identical bytes, or every install shows up as a diff.
|
|
49
58
|
export function writeManifest(cwd, manifest) {
|
|
@@ -10,7 +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
|
+
import { CONSTITUTION_PATH, constitutionIsDraft } from './constitution-draft.js';
|
|
14
14
|
import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
|
|
15
15
|
|
|
16
16
|
const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
@@ -445,7 +445,10 @@ function floorSatisfied(cwd, path) {
|
|
|
445
445
|
// conflictos de merge, y que un repo clonado puede traer preparado. Así que se valida la forma antes
|
|
446
446
|
// de mirar el disco: un `..` daba el suelo por satisfecho MIRANDO FUERA de la raíz, y un `null` o una
|
|
447
447
|
// cadena en lugar de un array tumbaban `rsc install` después de haber instalado.
|
|
448
|
-
|
|
448
|
+
//
|
|
449
|
+
// Devuelve los CAMINOS que faltan (como se muestran). `missingHarnessFloor` los redacta para el
|
|
450
|
+
// instalador y `onboardingReadiness` los entrega tal cual a `doctor`: una comprobación, dos lectores.
|
|
451
|
+
export function harnessFloorGaps(cwd = process.cwd(), plan = {}) {
|
|
449
452
|
const declared = Array.isArray(plan?.floorPaths) ? plan.floorPaths : [];
|
|
450
453
|
const missing = [];
|
|
451
454
|
for (const path of declared) {
|
|
@@ -453,15 +456,19 @@ export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
|
|
|
453
456
|
const usable = segments.length > 0 && !segments.includes('..') && !String(path).startsWith('/');
|
|
454
457
|
// Un camino que no se puede usar se reporta como NO satisfecho, nunca como satisfecho, y sin
|
|
455
458
|
// repetir su contenido: es dato ajeno y va a un canal que lee un agente.
|
|
456
|
-
if (!usable) { missing.push('
|
|
459
|
+
if (!usable) { missing.push('<invalid declaration>'); continue; }
|
|
457
460
|
// La barra sólo si el camino declarado la traía: el suelo condicional es un FICHERO, y decir
|
|
458
461
|
// `constitution.md/` invita a buscar un directorio que no existe.
|
|
459
462
|
const shown = String(path).endsWith('/') ? `${floorKey(path)}/` : floorKey(path);
|
|
460
|
-
if (!floorSatisfied(cwd, path)) missing.push(
|
|
463
|
+
if (!floorSatisfied(cwd, path)) missing.push(shown);
|
|
461
464
|
}
|
|
462
465
|
return missing;
|
|
463
466
|
}
|
|
464
467
|
|
|
468
|
+
export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
|
|
469
|
+
return harnessFloorGaps(cwd, plan).map((shown) => `missing harness floor ${shown}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
465
472
|
/**
|
|
466
473
|
* Lo que el instalador puede afirmar honestamente, y qué hacer si no.
|
|
467
474
|
*
|
|
@@ -479,3 +486,50 @@ export function harnessReadiness(cwd = process.cwd(), plan = {}) {
|
|
|
479
486
|
: '',
|
|
480
487
|
};
|
|
481
488
|
}
|
|
489
|
+
|
|
490
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
// Preparación del onboarding, como pregunta PROPIA (#298 punto 5).
|
|
492
|
+
//
|
|
493
|
+
// `doctor` respondía sólo «¿el arnés instalado funciona?» (`healthy`, y el exit code con él) y un
|
|
494
|
+
// usuario leía ese `true` como «el onboarding está terminado» con la constitución sin escribir. Son
|
|
495
|
+
// dos preguntas: ésta se calcula con el MISMO suelo que decide RSC_ONBOARDING_READY/INCOMPLETE
|
|
496
|
+
// (`harnessFloorGaps`) más los borradores, y nunca toca `healthy`.
|
|
497
|
+
//
|
|
498
|
+
// ready suelo completo, nada en borrador
|
|
499
|
+
// pending suelo completo, pero algo es un borrador que el usuario debe completar
|
|
500
|
+
// incomplete falta parte del suelo del plan aceptado (o el recibo no cuadra con su id)
|
|
501
|
+
// not-onboarded no hay recibo: instalación manual (`rsc add`) o anterior al onboarding
|
|
502
|
+
//
|
|
503
|
+
// Un recibo de una versión que no declaraba suelo queda exento por construcción (P3), igual que en
|
|
504
|
+
// el instalador: aquí no se inventa un suelo que el plan aceptado no traía.
|
|
505
|
+
const ONBOARD_ACTION = 'Run `npx @ericrisco/rsc@latest onboard` to record a plan for this harness (optional: what is installed keeps working).';
|
|
506
|
+
const draftAction = (path) => `Complete ${path} with the \`constitution\` phase (tell your agent "constitution") before the first SDD feature; ratifying removes \`status: draft\`.`;
|
|
507
|
+
|
|
508
|
+
export function onboardingReadiness(cwd = process.cwd(), manifest = readManifest(cwd)) {
|
|
509
|
+
const pending = constitutionIsDraft(cwd) ? [CONSTITUTION_PATH] : [];
|
|
510
|
+
const receipt = manifest?.onboarding;
|
|
511
|
+
if (!receipt?.plan) {
|
|
512
|
+
return { status: 'not-onboarded', missing: [], pending, action: ONBOARD_ACTION };
|
|
513
|
+
}
|
|
514
|
+
let identical = false;
|
|
515
|
+
try { identical = identifyPlan(receipt.plan) === receipt.acceptedPlanId; } catch { /* ilegible: no cuadra */ }
|
|
516
|
+
if (!identical) {
|
|
517
|
+
return {
|
|
518
|
+
status: 'incomplete', missing: ['<onboarding receipt does not match its accepted plan id>'], pending,
|
|
519
|
+
action: 'Do not trust the receipt. Re-run `npx @ericrisco/rsc@latest onboard` and accept a fresh plan.',
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const missing = harnessFloorGaps(cwd, receipt.plan);
|
|
523
|
+
if (missing.length) {
|
|
524
|
+
const actions = [];
|
|
525
|
+
if (missing.includes(CONSTITUTION_PATH)) {
|
|
526
|
+
actions.push(`Write ${CONSTITUTION_PATH} with the \`constitution\` phase (tell your agent "constitution"); the SDD plan you accepted requires it.`);
|
|
527
|
+
}
|
|
528
|
+
if (missing.some((path) => path !== CONSTITUTION_PATH)) {
|
|
529
|
+
actions.push('Invoke the `harness` skill to scaffold 01-TOOLS/ + 02-DOCS/.');
|
|
530
|
+
}
|
|
531
|
+
return { status: 'incomplete', missing, pending, action: actions.join(' ') };
|
|
532
|
+
}
|
|
533
|
+
if (pending.length) return { status: 'pending', missing, pending, action: draftAction(CONSTITUTION_PATH) };
|
|
534
|
+
return { status: 'ready', missing, pending, action: '' };
|
|
535
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Consolidation of the refuter panel's findings — the deterministic half of `review`.
|
|
2
|
+
//
|
|
3
|
+
// Three fresh-context lenses attack the same diff, so they often report the SAME defect in three
|
|
4
|
+
// different sentences, and the author spends triage time discovering that eleven findings are four.
|
|
5
|
+
// Grouping them is an algorithm, not a judgement (P1), so it lives here and not in the skill's prose.
|
|
6
|
+
// What survives grouping and is serious enough to block goes to `finding-verifier` — that half IS
|
|
7
|
+
// judgement, and stays in the agent.
|
|
8
|
+
//
|
|
9
|
+
// Input: what the lenses emit — fenced ```json findings blocks inside their reports, or plain JSON.
|
|
10
|
+
// Output: unique findings, highest severity first, each with the lenses that reported it and a
|
|
11
|
+
// `verify` flag on the ones the verifier must try to refute before they may block.
|
|
12
|
+
|
|
13
|
+
export const SEVERITIES = ['blocker', 'should-fix', 'nit', 'question'];
|
|
14
|
+
const ALIASES = {
|
|
15
|
+
critical: 'blocker', high: 'blocker', blocking: 'blocker',
|
|
16
|
+
medium: 'should-fix', shouldfix: 'should-fix', 'should_fix': 'should-fix', major: 'should-fix',
|
|
17
|
+
low: 'nit', info: 'nit', minor: 'nit',
|
|
18
|
+
};
|
|
19
|
+
const rank = (s) => SEVERITIES.indexOf(s);
|
|
20
|
+
export const DEFAULT_WINDOW = 3;
|
|
21
|
+
|
|
22
|
+
export function normalizeSeverity(raw) {
|
|
23
|
+
const s = String(raw || '').trim().toLowerCase();
|
|
24
|
+
if (SEVERITIES.includes(s)) return s;
|
|
25
|
+
return ALIASES[s] || ALIASES[s.replace(/[-\s]/g, '')] || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const text = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
29
|
+
const normFile = (f) => text(f).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
30
|
+
const normEvidence = (e) => text(e).replace(/\s+/g, ' ').toLowerCase();
|
|
31
|
+
|
|
32
|
+
// Pull every finding out of one source. A source is a lens report (markdown with fenced blocks) or a
|
|
33
|
+
// JSON file. Anything unparseable is reported, never silently dropped: a lost finding is the one
|
|
34
|
+
// failure a consolidation step must not add.
|
|
35
|
+
export function parseFindings(raw, source = 'stdin') {
|
|
36
|
+
const findings = [];
|
|
37
|
+
const errors = [];
|
|
38
|
+
const take = (value, where) => {
|
|
39
|
+
const list = Array.isArray(value) ? value : (Array.isArray(value?.findings) ? value.findings : [value]);
|
|
40
|
+
for (const item of list) {
|
|
41
|
+
if (!item || typeof item !== 'object') { errors.push(`${where}: a finding must be an object`); continue; }
|
|
42
|
+
findings.push({ ...item, source });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const blocks = [...String(raw).matchAll(/```json[ \t]+findings[^\n]*\n([\s\S]*?)```/g)];
|
|
46
|
+
if (blocks.length) {
|
|
47
|
+
blocks.forEach((m, i) => {
|
|
48
|
+
try { take(JSON.parse(m[1]), `${source} block ${i + 1}`); }
|
|
49
|
+
catch (e) { errors.push(`${source} block ${i + 1}: not valid JSON (${e.message})`); }
|
|
50
|
+
});
|
|
51
|
+
return { findings, errors };
|
|
52
|
+
}
|
|
53
|
+
const trimmed = String(raw).trim();
|
|
54
|
+
if (!trimmed) return { findings, errors };
|
|
55
|
+
try { take(JSON.parse(trimmed), source); }
|
|
56
|
+
catch { errors.push(`${source}: no \`\`\`json findings block and not plain JSON`); }
|
|
57
|
+
return { findings, errors };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The pre-report gate, checked instead of trusted: a blocker or should-fix with no location or no
|
|
61
|
+
// concrete failure scenario is a suspicion, and a suspicion is a question. The downgrade is visible.
|
|
62
|
+
export function normalizeFinding(f) {
|
|
63
|
+
const out = {
|
|
64
|
+
lens: text(f.lens) || text(f.source) || 'unknown',
|
|
65
|
+
severity: normalizeSeverity(f.severity),
|
|
66
|
+
file: normFile(f.file),
|
|
67
|
+
line: Number.isInteger(Number(f.line)) && Number(f.line) > 0 ? Number(f.line) : null,
|
|
68
|
+
claim: text(f.claim) || text(f.title) || text(f.summary),
|
|
69
|
+
failure_scenario: text(f.failure_scenario) || text(f.repro),
|
|
70
|
+
evidence: text(f.evidence),
|
|
71
|
+
notes: [],
|
|
72
|
+
};
|
|
73
|
+
if (!out.severity) {
|
|
74
|
+
out.notes.push(`severity "${f.severity ?? ''}" unknown → question`);
|
|
75
|
+
out.severity = 'question';
|
|
76
|
+
}
|
|
77
|
+
if (rank(out.severity) <= rank('should-fix')) {
|
|
78
|
+
const missing = [];
|
|
79
|
+
if (!out.file || !out.line) missing.push('file:line');
|
|
80
|
+
if (!out.failure_scenario) missing.push('failure_scenario');
|
|
81
|
+
if (missing.length) {
|
|
82
|
+
out.notes.push(`${out.severity} without ${missing.join(' and ')} → question`);
|
|
83
|
+
out.severity = 'question';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Two findings are the same defect when they quote the same evidence, or when they point at the same
|
|
90
|
+
// place (same file, lines within the window) AND say the same thing. Place alone is not enough: the
|
|
91
|
+
// first real panel run put two different defects on adjacent lines (a discount applied to one unit,
|
|
92
|
+
// and a missing 0..100 check), and merging them buried the second one where no verifier looked.
|
|
93
|
+
// A missed merge costs one extra verification; a wrong merge hides a defect — so the bar is
|
|
94
|
+
// conservative. "Say the same thing" is word overlap (Jaccard) of claim, or claim + failure scenario.
|
|
95
|
+
const STOP = new Set('the and for with not that this its are was from any only one but has have into than then when which will can its'.split(' '));
|
|
96
|
+
const words = (s) => new Set(String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)));
|
|
97
|
+
function overlap(a, b) {
|
|
98
|
+
const A = words(a); const B = words(b);
|
|
99
|
+
let shared = 0;
|
|
100
|
+
for (const w of A) if (B.has(w)) shared += 1;
|
|
101
|
+
const union = A.size + B.size - shared;
|
|
102
|
+
return union ? shared / union : 0;
|
|
103
|
+
}
|
|
104
|
+
export const SAME_CLAIM = 0.2;
|
|
105
|
+
export function similarity(a, b) {
|
|
106
|
+
return Math.max(overlap(a.claim, b.claim),
|
|
107
|
+
overlap(`${a.claim} ${a.failure_scenario}`, `${b.claim} ${b.failure_scenario}`));
|
|
108
|
+
}
|
|
109
|
+
function sameDefect(a, b, window) {
|
|
110
|
+
const ea = normEvidence(a.evidence);
|
|
111
|
+
if (ea.length >= 12 && ea === normEvidence(b.evidence)) return true;
|
|
112
|
+
const near = a.file && a.file === b.file && a.line && b.line && Math.abs(a.line - b.line) <= window;
|
|
113
|
+
return Boolean(near) && similarity(a, b) >= SAME_CLAIM;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function consolidate(rawFindings, { window = DEFAULT_WINDOW } = {}) {
|
|
117
|
+
const items = rawFindings.map(normalizeFinding);
|
|
118
|
+
const parent = items.map((_, i) => i);
|
|
119
|
+
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
|
|
120
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
121
|
+
for (let j = i + 1; j < items.length; j += 1) {
|
|
122
|
+
if (sameDefect(items[i], items[j], window)) parent[find(j)] = find(i);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const groups = new Map();
|
|
126
|
+
items.forEach((it, i) => {
|
|
127
|
+
const root = find(i);
|
|
128
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
129
|
+
groups.get(root).push(it);
|
|
130
|
+
});
|
|
131
|
+
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
132
|
+
const merged = [...groups.values()].map((members) => {
|
|
133
|
+
const severity = SEVERITIES[Math.min(...members.map((m) => rank(m.severity)))];
|
|
134
|
+
const lines = members.map((m) => m.line).filter(Boolean);
|
|
135
|
+
return {
|
|
136
|
+
severity,
|
|
137
|
+
file: uniq(members.map((m) => m.file))[0] || '',
|
|
138
|
+
lines: lines.length ? [Math.min(...lines), Math.max(...lines)] : [],
|
|
139
|
+
lenses: uniq(members.map((m) => m.lens)).sort(),
|
|
140
|
+
claims: uniq(members.map((m) => m.claim)),
|
|
141
|
+
failure_scenarios: uniq(members.map((m) => m.failure_scenario)),
|
|
142
|
+
evidence: uniq(members.map((m) => m.evidence)),
|
|
143
|
+
notes: uniq(members.flatMap((m) => m.notes)),
|
|
144
|
+
reported: members.length,
|
|
145
|
+
verify: rank(severity) <= rank('should-fix'),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
merged.sort((a, b) => rank(a.severity) - rank(b.severity)
|
|
149
|
+
|| a.file.localeCompare(b.file) || (a.lines[0] || 0) - (b.lines[0] || 0));
|
|
150
|
+
merged.forEach((m, i) => { m.id = `F${i + 1}`; });
|
|
151
|
+
return {
|
|
152
|
+
reported: items.length,
|
|
153
|
+
unique: merged.length,
|
|
154
|
+
toVerify: merged.filter((m) => m.verify).length,
|
|
155
|
+
findings: merged,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const where = (f) => (f.file ? `${f.file}${f.lines.length ? `:${f.lines[0]}${f.lines[1] !== f.lines[0] ? `-${f.lines[1]}` : ''}` : ''}` : '(no location)');
|
|
160
|
+
|
|
161
|
+
export function renderConsolidation(result, errors = []) {
|
|
162
|
+
const out = [
|
|
163
|
+
`${result.reported} finding(s) reported → ${result.unique} unique · ${result.toVerify} go to finding-verifier`,
|
|
164
|
+
];
|
|
165
|
+
for (const f of result.findings) {
|
|
166
|
+
out.push('', `${f.id} [${f.severity}] ${where(f)} — ${f.claims[0] || '(no claim)'}`);
|
|
167
|
+
out.push(` lenses: ${f.lenses.join(', ')}${f.reported > 1 ? ` (${f.reported} reports merged)` : ''}`);
|
|
168
|
+
for (const c of f.claims.slice(1)) out.push(` also: ${c}`);
|
|
169
|
+
for (const s of f.failure_scenarios) out.push(` failure: ${s}`);
|
|
170
|
+
for (const n of f.notes) out.push(` note: ${n}`);
|
|
171
|
+
if (f.verify) out.push(' → verify: dispatch finding-verifier; it blocks only if CONFIRMED');
|
|
172
|
+
}
|
|
173
|
+
for (const e of errors) out.push('', `⚠️ ${e}`);
|
|
174
|
+
return out.join('\n');
|
|
175
|
+
}
|