@ericrisco/rsc 3.0.3 → 3.0.5
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 +8 -5
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/scripts/lib/onboarding-apply.js +19 -1
- package/scripts/lib/onboarding.js +13 -0
- package/scripts/rsc.js +34 -3
- package/skills/ftd/SKILL.md +13 -8
- package/skills/implement/SKILL.md +4 -4
- package/skills/suggest/SKILL.md +3 -3
- package/skills/worktrees/SKILL.md +5 -5
- package/targets/branch-guard.mjs +9 -7
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/hook-once.mjs +2 -2
- package/targets/knowledge-sync.mjs +22 -13
- package/targets/opt-outs.js +1 -0
- package/targets/team-safe-start.mjs +3 -2
- package/targets/trunk-policy.mjs +18 -1
- package/targets/userprompt-gate.mjs +8 -1
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
70
70
|
|
|
71
71
|
| | What happens | Turn it off |
|
|
72
72
|
| --- | --- | --- |
|
|
73
|
-
| **
|
|
73
|
+
| **You choose how the project works** | The install asks once: straight on `main`, or branches and pull requests? Branches are recommended only for long-lived, complex code. With `main` the agent works on it and never asks about branches. With branches a commit on `main` is refused, and before each code change the agent asks: this branch, a new one, or unlock `main`? It never opens a branch on its own. | Say **«unlock main»** / **«lock main»**, or `rsc main unlock` / `lock` |
|
|
74
74
|
| **One workspace per agent** | If another assistant session is working in the same folder, new work goes to a worktree under `.worktrees/<branch>/`. It stays inside the project, is never committed, and is removed once that branch is merged. | `rsc isolation off` |
|
|
75
75
|
| **The agent picks the method** | A simple change takes Fast-Track (one document, proof for every task). Something big or complex takes the spec-driven chain. The agent decides and says why. You still approve *what* gets built: the spec and the clarifications, then step by step or autopilot. | Ask for the other lane |
|
|
76
76
|
| **Knowledge reaches everyone** | `01-TOOLS/` and `02-DOCS/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
|
|
@@ -99,12 +99,13 @@ rsc git-permissions on # turn it on, also for a project adopted before
|
|
|
99
99
|
**How does it know a project is "complex or in production"?** There are two layers. A hook counts
|
|
100
100
|
what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
|
|
101
101
|
at least two people in the last 50 commits. If any of those is there, a commit on the default branch
|
|
102
|
-
is refused before it runs
|
|
103
|
-
|
|
102
|
+
is refused before it runs, and the agent asks you whether to open a branch or unlock it. If none is,
|
|
103
|
+
`main` is open and the agent simply works on it. **The answer you gave at install always wins over
|
|
104
|
+
this detection**; it only decides for projects installed before the question existed. If the hook gets it wrong and the project really is simple, «unlock main» opens it for
|
|
104
105
|
the project. That decision is saved in `.rsc.json`, so the whole team gets it.
|
|
105
106
|
|
|
106
|
-
Every refusal says how to carry on
|
|
107
|
-
|
|
107
|
+
Every refusal says how to carry on, and the choice between a branch and unlocking goes back to you.
|
|
108
|
+
An agent is never left stuck. The rules only see what the **agent** runs; a person
|
|
108
109
|
committing in their own terminal is never touched.
|
|
109
110
|
|
|
110
111
|
**The first session after upgrading** does three things, once:
|
|
@@ -467,6 +468,8 @@ new · clones main has it all after the first message
|
|
|
467
468
|
only its `01-TOOLS/` and `02-DOCS/` part, under a `docs(auto) … [skip ci]` message.
|
|
468
469
|
- **Before each message**, what teammates sent is brought into **the branch you are on**, and you are
|
|
469
470
|
told in one line. Only the knowledge folders are touched.
|
|
471
|
+
A branch you open later catches up on its first message: everything on `rsc/knowledge` since it
|
|
472
|
+
left `main` comes down, including what you wrote yourself on another branch.
|
|
470
473
|
- **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
|
|
471
474
|
arrives in `main` inside the pull request you were going to open anyway. There is no extra pull
|
|
472
475
|
request from `rsc/knowledge`, and nobody has to merge it.
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.0.
|
|
2
|
+
"version": "3.0.5",
|
|
3
3
|
"counts": {
|
|
4
4
|
"skills": 270,
|
|
5
5
|
"agents": 33,
|
|
@@ -1911,7 +1911,7 @@
|
|
|
1911
1911
|
},
|
|
1912
1912
|
{
|
|
1913
1913
|
"id": "ftd",
|
|
1914
|
-
"description": "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and
|
|
1914
|
+
"description": "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and asks whether to branch before the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing.",
|
|
1915
1915
|
"tags": [
|
|
1916
1916
|
"ftd",
|
|
1917
1917
|
"lane",
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
|
|
@@ -96,6 +96,7 @@ function recoveryCommand(plan, planId) {
|
|
|
96
96
|
`--project-kind ${record.projectKind}`,
|
|
97
97
|
`--goal-base64 ${encodeGoal(record.goal)}`,
|
|
98
98
|
...(record.softwareScope ? [`--software-scope ${record.softwareScope}`] : []),
|
|
99
|
+
...(record.workflow ? [`--workflow ${record.workflow}`] : []),
|
|
99
100
|
`--target ${record.targets.join(',')}`,
|
|
100
101
|
`--accept-plan ${planId}`,
|
|
101
102
|
].join(' ');
|
|
@@ -226,6 +227,22 @@ export function verifyOnboarding(cwd, plan, planId) {
|
|
|
226
227
|
return differences;
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The install-time answer «main | branches» becomes the project's trunk decision, as the same two
|
|
232
|
+
* markers `rsc main unlock|lock` writes (both PROJECT opt-outs, recorded in .rsc.json by the install
|
|
233
|
+
* below, rebuilt in a clone): main → `.no-trunk-guard` (open), branches → `.no-trunk-open` (closed
|
|
234
|
+
* even where nothing in the project looks complex yet).
|
|
235
|
+
*/
|
|
236
|
+
export function applyWorkflowDecision(cwd, workflow) {
|
|
237
|
+
if (workflow !== 'main' && workflow !== 'branches') return;
|
|
238
|
+
const dir = join(cwd, '.rsc');
|
|
239
|
+
mkdirSync(dir, { recursive: true });
|
|
240
|
+
const open = join(dir, '.no-trunk-guard');
|
|
241
|
+
const closed = join(dir, '.no-trunk-open');
|
|
242
|
+
writeFileSync(workflow === 'main' ? open : closed, '');
|
|
243
|
+
rmSync(workflow === 'main' ? closed : open, { force: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planId, now = new Date(), apply = applyInstall }) {
|
|
230
247
|
if (identifyPlan(plan) !== planId) throw new Error('RSC_PLAN_CHANGED: regenerate the plan and ask the user to accept the new id');
|
|
231
248
|
const previousManifest = readManifest(cwd);
|
|
@@ -259,6 +276,7 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
|
|
|
259
276
|
},
|
|
260
277
|
};
|
|
261
278
|
try {
|
|
279
|
+
applyWorkflowDecision(cwd, plan.record.workflow);
|
|
262
280
|
for (const target of previousPlan?.policy?.targets || []) {
|
|
263
281
|
if (!plan.policy.targets.includes(target)) removeTargetInstall({ cwd, target });
|
|
264
282
|
}
|
|
@@ -13,6 +13,8 @@ export const ONBOARDING_VALUES = Object.freeze({
|
|
|
13
13
|
technicalLevel: ['non-technical', 'mixed', 'technical'],
|
|
14
14
|
projectKind: ['software', 'operations', 'research', 'content', 'mixed'],
|
|
15
15
|
softwareScope: ['small', 'growing', 'complex'],
|
|
16
|
+
// How code changes land: straight on the default branch, or on branches closed with a pull request.
|
|
17
|
+
workflow: ['main', 'branches'],
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
const ignored = new Set([
|
|
@@ -29,6 +31,9 @@ const oneOf = (field, value, values) => {
|
|
|
29
31
|
return value;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
/** The workflow to recommend at install: branches + PR only for complex code. */
|
|
35
|
+
export const recommendedWorkflow = (softwareScope) => (softwareScope === 'complex' ? 'branches' : 'main');
|
|
36
|
+
|
|
32
37
|
export function normalizeOnboarding(raw = {}) {
|
|
33
38
|
const schemaVersion = Number(raw.schemaVersion ?? ONBOARDING_SCHEMA_VERSION);
|
|
34
39
|
if (schemaVersion !== ONBOARDING_SCHEMA_VERSION) throw new Error(`invalid schemaVersion: expected ${ONBOARDING_SCHEMA_VERSION}`);
|
|
@@ -47,12 +52,18 @@ export function normalizeOnboarding(raw = {}) {
|
|
|
47
52
|
const needsScope = projectKind === 'software' || projectKind === 'mixed';
|
|
48
53
|
const softwareScope = clean(raw.softwareScope).toLowerCase();
|
|
49
54
|
if (needsScope) oneOf('software-scope', softwareScope, ONBOARDING_VALUES.softwareScope);
|
|
55
|
+
// Asked at install (2026-10-06, Eric): branches and PRs are for long-lived, complex code; research,
|
|
56
|
+
// content and simple scripts work on the default branch. Absent → the recommendation for the scope.
|
|
57
|
+
const workflow = needsScope
|
|
58
|
+
? (clean(raw.workflow) ? oneOf('workflow', clean(raw.workflow).toLowerCase(), ONBOARDING_VALUES.workflow) : recommendedWorkflow(softwareScope))
|
|
59
|
+
: 'main';
|
|
50
60
|
return {
|
|
51
61
|
schemaVersion,
|
|
52
62
|
technicalLevel,
|
|
53
63
|
projectKind,
|
|
54
64
|
goal,
|
|
55
65
|
...(needsScope ? { softwareScope } : {}),
|
|
66
|
+
workflow,
|
|
56
67
|
targets,
|
|
57
68
|
};
|
|
58
69
|
}
|
|
@@ -296,6 +307,8 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
296
307
|
'02-DOCS/wiki/harness/decisions.md',
|
|
297
308
|
'02-DOCS/wiki/harness/installation-plan.md',
|
|
298
309
|
'.rsc/.no-context7',
|
|
310
|
+
// The install-time workflow answer (applyWorkflowDecision): the one marker it writes.
|
|
311
|
+
...(normalized.workflow === 'branches' ? ['.rsc/.no-trunk-open'] : normalized.workflow === 'main' ? ['.rsc/.no-trunk-guard'] : []),
|
|
299
312
|
...normalized.targets.flatMap((target) => managedPathsForInstall({ skillIds: skills, target, cwd: root, policy })
|
|
300
313
|
.map((path) => relative(root, path).split(sep).join('/'))),
|
|
301
314
|
])].sort() : ['.rsc.json', '.rsc/', '02-DOCS/wiki/harness/'];
|
package/scripts/rsc.js
CHANGED
|
@@ -42,7 +42,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
|
|
|
42
42
|
// `--accompaniment` stays listed although the dial is retired: old scripts and agents still pass it,
|
|
43
43
|
// and its value must keep being skipped rather than read as a command or a skill id.
|
|
44
44
|
const GLOBAL_VALUE_FLAGS = new Set([
|
|
45
|
-
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--accept-plan',
|
|
45
|
+
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
|
|
46
46
|
]);
|
|
47
47
|
const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
|
|
48
48
|
function positionalTokens(input) {
|
|
@@ -84,6 +84,7 @@ function onboardingInput(targets) {
|
|
|
84
84
|
projectKind: value('project-kind'),
|
|
85
85
|
goal: value('goal') || (value('goal-base64') ? decodeGoal(value('goal-base64')) : undefined),
|
|
86
86
|
softwareScope: value('software-scope'),
|
|
87
|
+
workflow: value('workflow'),
|
|
87
88
|
targets,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
@@ -93,7 +94,17 @@ function onboardingRequired(raw, action = 'onboard') {
|
|
|
93
94
|
code: 'RSC_ONBOARDING_REQUIRED',
|
|
94
95
|
schemaVersion: 1,
|
|
95
96
|
missing: missingOnboardingFields(raw),
|
|
96
|
-
recovery: `Run npx @ericrisco/rsc@latest ${action} --technical-level <non-technical|mixed|technical> --project-kind <software|operations|research|content|mixed> --goal "<what you want>" --target <assistant
|
|
97
|
+
recovery: `Run npx @ericrisco/rsc@latest ${action} --technical-level <non-technical|mixed|technical> --project-kind <software|operations|research|content|mixed> --goal "<what you want>" --target <assistant> (software or mixed also: --software-scope <small|growing|complex> --workflow <main|branches>)`,
|
|
98
|
+
// Each value is the person's own answer, asked as a question. Never infer one from the code or
|
|
99
|
+
// from a nearby answer: a field test mapped «mixto» to technical and «growing» to small.
|
|
100
|
+
ask: {
|
|
101
|
+
'technical-level': 'Technical terms, or plain words with analogies? (non-technical | mixed | technical)',
|
|
102
|
+
'project-kind': 'What is this project? (software | operations | research | content | mixed)',
|
|
103
|
+
goal: 'What do you want this project to achieve?',
|
|
104
|
+
'software-scope': 'How much software work is expected? (small | growing | complex)',
|
|
105
|
+
workflow: 'Code changes: straight on main, or on branches closed with a pull request? (main | branches — recommended: branches only for complex, long-lived code)',
|
|
106
|
+
target: 'Which assistants? (claude, codex, gemini, cursor, opencode, …)',
|
|
107
|
+
},
|
|
97
108
|
};
|
|
98
109
|
console.error(`RSC_ONBOARDING_REQUIRED ${JSON.stringify(payload)}`);
|
|
99
110
|
process.exitCode = 2;
|
|
@@ -135,6 +146,10 @@ function renderPlan(plan, planId) {
|
|
|
135
146
|
say('RSC_ONBOARDING_PLAN');
|
|
136
147
|
say(`Plan id: ${planId}`);
|
|
137
148
|
say(`Project: ${plan.record.projectKind}${plan.record.softwareScope ? ` (${plan.record.softwareScope})` : ''}`);
|
|
149
|
+
say(`Technical level: ${plan.record.technicalLevel}`);
|
|
150
|
+
say(`Workflow: ${plan.record.workflow === 'branches' ? 'branches + pull request (the default branch is closed for the agent; it asks before each code change where it goes)' : 'straight on the default branch (no branch questions)'}`);
|
|
151
|
+
if (plan.record.softwareScope) say('AGENT: ask the person «¿Los cambios de código van directos a main, o con ramas y pull request?» (main | branches; branches is for long-lived, complex code). If their answer differs from the line above, generate the plan again with --workflow <answer>.');
|
|
152
|
+
say('AGENT: before accepting, read these values back to the person and check each one is what they answered.');
|
|
138
153
|
say(`Targets: ${plan.policy.targets.join(', ')}`);
|
|
139
154
|
say('Selected:');
|
|
140
155
|
for (const decision of plan.decisions.filter((d) => d.state === 'selected')) say(` + ${decision.kind}/${decision.id} — ${decision.reason}`);
|
|
@@ -157,6 +172,7 @@ function renderPlan(plan, planId) {
|
|
|
157
172
|
`--project-kind ${plan.record.projectKind}`,
|
|
158
173
|
`--goal-base64 ${encodeGoal(plan.record.goal)}`,
|
|
159
174
|
...(plan.record.softwareScope ? [`--software-scope ${plan.record.softwareScope}`] : []),
|
|
175
|
+
...(plan.record.workflow ? [`--workflow ${plan.record.workflow}`] : []),
|
|
160
176
|
`--target ${plan.record.targets.join(',')}`,
|
|
161
177
|
`--accept-plan ${planId}`,
|
|
162
178
|
];
|
|
@@ -189,6 +205,14 @@ async function runOnboarding(targets) {
|
|
|
189
205
|
{ key: 'complex', label: 'Complex — multiple systems, teams or critical behavior' },
|
|
190
206
|
]);
|
|
191
207
|
}
|
|
208
|
+
if ((raw.projectKind === 'software' || raw.projectKind === 'mixed') && !raw.workflow) {
|
|
209
|
+
const branchesFirst = raw.softwareScope === 'complex';
|
|
210
|
+
const options = [
|
|
211
|
+
{ key: 'main', label: 'Straight on main — research, content, scripts, simple projects' },
|
|
212
|
+
{ key: 'branches', label: 'Branches + pull request — long-lived, complex code' },
|
|
213
|
+
];
|
|
214
|
+
raw.workflow = await select('How should code changes land?', branchesFirst ? options.reverse() : options);
|
|
215
|
+
}
|
|
192
216
|
if (!raw.targets?.length) raw.targets = await selectAgents();
|
|
193
217
|
if (!raw.technicalLevel || !raw.projectKind || !raw.goal || !raw.targets?.length) {
|
|
194
218
|
say('Cancelled — nothing was touched.');
|
|
@@ -288,6 +312,7 @@ function runReassessment() {
|
|
|
288
312
|
`--project-kind ${record.projectKind}`,
|
|
289
313
|
`--goal-base64 ${encodeGoal(record.goal)}`,
|
|
290
314
|
...(record.softwareScope ? [`--software-scope ${record.softwareScope}`] : []),
|
|
315
|
+
...(record.workflow ? [`--workflow ${record.workflow}`] : []),
|
|
291
316
|
`--target ${record.targets.join(',')}`,
|
|
292
317
|
].join(' '));
|
|
293
318
|
say('Show the new plan and ask the user to accept its id before applying it.');
|
|
@@ -784,11 +809,17 @@ async function main() {
|
|
|
784
809
|
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
785
810
|
if (sub === OFF) writeFileSync(marker, '');
|
|
786
811
|
else rmSync(marker, { force: true });
|
|
812
|
+
// main: lock and unlock are the two answers to «main or branches?», so each clears the other.
|
|
813
|
+
// A lock is a decision, not a return to guessing: closed even where nothing looks complex.
|
|
814
|
+
if (isMain) {
|
|
815
|
+
const lock = join(root, '.rsc', '.no-trunk-open');
|
|
816
|
+
if (sub === ON) writeFileSync(lock, ''); else rmSync(lock, { force: true });
|
|
817
|
+
}
|
|
787
818
|
for (const t of targets) await syncInstalled({ target: t, cwd: root });
|
|
788
819
|
say(isMain
|
|
789
820
|
? (sub === OFF
|
|
790
821
|
? 'rsc main unlock: the agent may now work on the default branch in this project. Commit .rsc.json so the team gets the same decision.'
|
|
791
|
-
: 'rsc main lock: the default branch is closed for the agent
|
|
822
|
+
: 'rsc main lock: branches and pull requests for this project — the default branch is closed for the agent, and it asks before each code change where it goes. Commit .rsc.json so the team gets the same decision.')
|
|
792
823
|
: (sub === OFF
|
|
793
824
|
? 'rsc isolation off: sessions may share this folder without a worktree. Commit .rsc.json so the team gets the same decision.'
|
|
794
825
|
: 'rsc isolation on: with another session active in this folder, new work goes to .worktrees/<branch>/. Commit .rsc.json so the team gets the same decision.'));
|
package/skills/ftd/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ftd
|
|
3
|
-
description: "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and
|
|
3
|
+
description: "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and asks whether to branch before the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing."
|
|
4
4
|
tags: [ftd, lane, default, feature-document, evidence]
|
|
5
5
|
recommends: [worktrees, debug, verify]
|
|
6
6
|
profiles: [minimal, core, full]
|
|
@@ -53,14 +53,19 @@ the chain: a result asserted without evidence is treated as not done.
|
|
|
53
53
|
|
|
54
54
|
## Isolation
|
|
55
55
|
|
|
56
|
-
Where the work runs
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- **
|
|
56
|
+
Where the work runs follows the project's decision (asked at install: «main» or «ramas y PR»; rsc
|
|
57
|
+
says which on every turn) — never a branch opened on your own:
|
|
58
|
+
|
|
59
|
+
- **Default branch open** (research, content, scripts, simple projects) → work straight on it. Do not
|
|
60
|
+
ask about branches.
|
|
61
|
+
- **Default branch closed for the agent** (long-lived code: «ramas y PR» chosen at install, CI, a
|
|
62
|
+
deployment or several people) → before EACH change that writes code, ask in one line: «¿Lo hago en
|
|
63
|
+
esta rama (`<current>`), en una nueva, o desbloqueo `<default branch>`?». Already on another branch
|
|
64
|
+
is no exception: an unrelated change does not go into it without asking. A new branch is named by
|
|
65
|
+
intent and closes with a pull request. Unlocking is `rsc main unlock`; never without that answer.
|
|
66
|
+
- **Docs, wiki or config only** → no question, no branch.
|
|
62
67
|
- **Another session working in this same folder** → a worktree in `.worktrees/<branch>/`, never a
|
|
63
|
-
branch switch under the other session. rsc tells you when that is the case.
|
|
68
|
+
branch switch under the other session. Not a question: rsc tells you when that is the case.
|
|
64
69
|
|
|
65
70
|
A worktree only when another session is there: alone, setting one up costs more than it protects.
|
|
66
71
|
|
|
@@ -45,9 +45,9 @@ back — you do not write feature code anyway.
|
|
|
45
45
|
4. **Read the register.** Open `02-DOCS/wiki/harness/user-profile.md` and read `technical_level`.
|
|
46
46
|
It picks technical terms or plain words with analogies at each checkpoint (see "Checkpoints"
|
|
47
47
|
below). No profile yet → use analogies, and ask before any irreversible step.
|
|
48
|
-
5. **Confirm isolation.**
|
|
49
|
-
the
|
|
50
|
-
|
|
48
|
+
5. **Confirm isolation.** Default branch open for this project → work on it. Closed → stop before
|
|
49
|
+
the first commit and ask: this branch, a new one, or unlock (`worktrees`)? Never branch on your
|
|
50
|
+
own; another session in this folder makes the worktree mandatory.
|
|
51
51
|
6. **Scan the tasks for independence.** Mark which tasks share files/state and which are disjoint.
|
|
52
52
|
Disjoint clusters are candidates for `parallel`; everything else runs in order.
|
|
53
53
|
|
|
@@ -314,7 +314,7 @@ outranks the plan, and the plan outranks your in-the-moment preference.
|
|
|
314
314
|
- **Unresolved `analyze` findings** (contradiction between constitution ↔ spec ↔ plan ↔ tasks) →
|
|
315
315
|
resolve them before any code; they will only get more expensive after the diff exists.
|
|
316
316
|
- **A task forces a constitution violation** → surface it, stop, kick it back to `analyze`/`plan`.
|
|
317
|
-
- **You're on the default branch** → stop before the first commit
|
|
317
|
+
- **You're on the default branch** → stop before the first commit and ask: branch or default branch (`worktrees`).
|
|
318
318
|
- **A test won't go red** (wrong reason — import error, syntax) → fix the test before trusting it.
|
|
319
319
|
- **The combined suite is red after a parallel merge** → do not checkpoint as done; debug with
|
|
320
320
|
`debug` before continuing.
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -32,9 +32,9 @@ Big, or decisions that affect each other → **SDD**: enter the chain; the perso
|
|
|
32
32
|
clarify, then picks manual or autopilot. Say which and why in one line; switch if asked.
|
|
33
33
|
→ `../ftd/SKILL.md` · `../sdd/SKILL.md`.
|
|
34
34
|
|
|
35
|
-
**Where.**
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
**Where.** Default branch open (chosen at install, or nothing complex) → work on it, no branch
|
|
36
|
+
question. Closed («ramas y PR», CI, team) → before each code change ask: this branch, a new one, or
|
|
37
|
+
`rsc main unlock`? Never branch alone. Another session here → `.worktrees/<branch>`.
|
|
38
38
|
|
|
39
39
|
Judge the **meaning**, not the wording: the trigger is semantic in any language. A bug fix restoring
|
|
40
40
|
intended behaviour is `debug`. Autopilot consent covers a whole run — advance without re-asking.
|
|
@@ -27,11 +27,11 @@ constitution → specify → clarify → plan → tasks → analyze → [ worktr
|
|
|
27
27
|
|
|
28
28
|
## The one rule that defines this step
|
|
29
29
|
|
|
30
|
-
**Never
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
**Never open a branch on your own; follow the project's decision.** Where the default branch is open
|
|
31
|
+
(chosen at install, or nothing complex), `implement` runs on it. Where it is closed («ramas y PR»,
|
|
32
|
+
CI, team), stop before the first edit and ask «¿esta rama, una nueva, o desbloqueo `main`?» — not
|
|
33
|
+
after the diff already exists. The one exception is another session in this folder: then the
|
|
34
|
+
worktree is mandatory, not a question.
|
|
35
35
|
|
|
36
36
|
## The decision: branch vs. worktree
|
|
37
37
|
|
package/targets/branch-guard.mjs
CHANGED
|
@@ -152,14 +152,16 @@ async function othersHere({ root, here, sessionId, self }) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
|
|
155
|
-
// Where
|
|
155
|
+
// Where a branch would go depends on rule B: alone → this same folder; with company → .worktrees/.
|
|
156
|
+
// Whether to branch at all is the person's call: the agent asks, it never branches on its own.
|
|
156
157
|
const others = existsSync(join(root, '.rsc', '.no-worktree-isolation')) ? [] : await othersHere({ root, here, sessionId, self });
|
|
157
|
-
const
|
|
158
|
-
? '
|
|
159
|
-
: '
|
|
160
|
-
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}). ` +
|
|
161
|
-
`Do
|
|
162
|
-
|
|
158
|
+
const branch = others.length
|
|
159
|
+
? '`git worktree add .worktrees/<branch> -b feat/<what>` and commit inside `.worktrees/<branch>/` (another session is working in this folder)'
|
|
160
|
+
: '`git switch -c feat/<what>` in this same folder, no worktree';
|
|
161
|
+
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so this commit was not made. ` +
|
|
162
|
+
`Do not choose for the person: ask them in one line whether to open a branch for this change or to unlock "${trunk}". ` +
|
|
163
|
+
`Branch → ${branch}; it reaches "${trunk}" through a pull request. ` +
|
|
164
|
+
'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again.';
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
async function isolationDenial({ root, here, sessionId, self }) {
|
|
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
|
43
43
|
* nothing checks is just drift with a head start.
|
|
44
44
|
*/
|
|
45
45
|
export const PROJECT_OPT_OUTS = [
|
|
46
|
-
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'worktree-cleanup', 'worktree-isolation',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|
package/targets/hook-once.mjs
CHANGED
|
@@ -97,8 +97,8 @@ Classify this turn before acting, and name the lane in one line.
|
|
|
97
97
|
- Authorises a change? YOU pick the lane, say it and why: simple -> FTD (one
|
|
98
98
|
feature document, tasks checked off against observed proof); big or
|
|
99
99
|
interlocking decisions -> SDD (spec + clarify approved, then autopilot or not).
|
|
100
|
-
-
|
|
101
|
-
|
|
100
|
+
- Code change: the line below says where it goes in this project. Never open
|
|
101
|
+
a branch on your own. Another session here: .worktrees/<branch>.
|
|
102
102
|
Method: \`ftd\` · chain: \`sdd\` · full decisor in the always-on \`suggest\` body.
|
|
103
103
|
=================================================
|
|
104
104
|
`;
|
|
@@ -104,7 +104,7 @@ const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root,
|
|
|
104
104
|
function readState(root) {
|
|
105
105
|
let s = {};
|
|
106
106
|
try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
|
|
107
|
-
return { announced: false,
|
|
107
|
+
return { announced: false, seenBy: {}, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, head: null, ...s };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
function writeState(root, s) {
|
|
@@ -375,30 +375,35 @@ export function applyIncoming(root, s) {
|
|
|
375
375
|
if (!branch || branch === 'HEAD') return said;
|
|
376
376
|
const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
|
|
377
377
|
if (!tip) return said;
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
// Per branch, not per repo: a branch opened from main after the last sync starts behind what
|
|
379
|
+
// rsc/knowledge already has, and a single "last seen" made it look up to date (2026-10-06). A branch
|
|
380
|
+
// seen for the first time catches up from where it left rsc/knowledge.
|
|
381
|
+
s.seenBy = s.seenBy && typeof s.seenBy === 'object' ? s.seenBy : {};
|
|
382
|
+
let seen = s.seenBy[branch];
|
|
383
|
+
if (!seen || !line(root, ['rev-parse', '--verify', '--quiet', `${seen}^{commit}`])) {
|
|
384
|
+
seen = line(root, ['merge-base', 'HEAD', tip]) || tip;
|
|
380
385
|
}
|
|
381
|
-
if (
|
|
386
|
+
if (seen === tip) { s.seenBy[branch] = tip; return said; }
|
|
382
387
|
const paths = [...KNOWLEDGE];
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (!theirs.length) { s.seen = tip; return said; }
|
|
388
|
+
const commits = git(root, ['rev-list', `${seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean);
|
|
389
|
+
if (!commits.length) { s.seenBy[branch] = tip; return said; }
|
|
386
390
|
|
|
387
|
-
const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z',
|
|
391
|
+
const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z', seen, tip, '--', ...paths]))
|
|
388
392
|
.filter(isKnowledge).slice(0, MAX_FILES);
|
|
389
|
-
const who = authorsOf(root, theirs);
|
|
390
393
|
const closed = branch === defaultBranch(root) && trunkClosed(root);
|
|
394
|
+
const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
|
|
391
395
|
const take = [];
|
|
392
396
|
const clash = [];
|
|
393
397
|
for (const f of know) {
|
|
394
398
|
const incoming = blob(root, tip, f);
|
|
395
399
|
const mine = worktreeBlob(root, f);
|
|
396
400
|
if (mine === incoming) continue;
|
|
397
|
-
const base = blob(root,
|
|
401
|
+
const base = blob(root, seen, f);
|
|
398
402
|
// On a closed trunk HEAD lags behind on purpose (what came in was not committed), so only the
|
|
399
403
|
// working tree says whether you touched it (review H2).
|
|
400
404
|
if (mine === base && (closed || blob(root, 'HEAD', f) === base)) take.push(f);
|
|
401
|
-
|
|
405
|
+
// Touched here and changed only by your own uploads: your newer version, not a clash.
|
|
406
|
+
else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
if (take.length) {
|
|
@@ -414,12 +419,16 @@ export function applyIncoming(root, s) {
|
|
|
414
419
|
} else {
|
|
415
420
|
git(root, ['--literal-pathspecs', 'reset', '--quiet', '--', ...take]); // updated, not staged
|
|
416
421
|
}
|
|
417
|
-
|
|
422
|
+
const from = [...new Set(take.flatMap(touchedBy))];
|
|
423
|
+
const theirs = from.filter((c) => !s.ours.includes(c));
|
|
424
|
+
const who = theirs.length ? authorsOf(root, theirs) : 'ti, desde otra rama';
|
|
425
|
+
said.push(`📥 Traídos ${from.length} cambio(s) de conocimiento de ${who}: ${listed(take)}.`);
|
|
418
426
|
}
|
|
419
427
|
if (clash.length) {
|
|
428
|
+
const who = authorsOf(root, [...new Set(clash.flatMap(touchedBy))].filter((c) => !s.ours.includes(c)));
|
|
420
429
|
said.push(`${who} cambió ${listed(clash)}, que tú también has tocado. No lo traigo solo: tu versión sigue intacta.`);
|
|
421
430
|
}
|
|
422
|
-
s.
|
|
431
|
+
s.seenBy[branch] = tip;
|
|
423
432
|
return said;
|
|
424
433
|
}
|
|
425
434
|
|
package/targets/opt-outs.js
CHANGED
|
@@ -26,6 +26,7 @@ export const PROJECT_OPT_OUTS = [
|
|
|
26
26
|
'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
|
|
27
27
|
'ship-guard', // branch/trunk discipline
|
|
28
28
|
'trunk-guard', // «desbloquea main»: the default branch open for the agent, for the whole team
|
|
29
|
+
'trunk-open', // «ramas y PR» chosen at install / `rsc main lock`: closed even with nothing that looks complex
|
|
29
30
|
'worktree-cleanup', // the reaper's notice (it only ever names, never acts)
|
|
30
31
|
'worktree-isolation', // «no uses worktrees»: same-folder sessions allowed to share a checkout
|
|
31
32
|
];
|
|
@@ -101,8 +101,9 @@ export function teamSafeAnnouncement(root) {
|
|
|
101
101
|
return `
|
|
102
102
|
===== rsc 3.0 · equipo seguro por defecto =====
|
|
103
103
|
ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
|
|
104
|
-
1.
|
|
105
|
-
|
|
104
|
+
1. Si la rama principal está abierta (proyectos sencillos), el agente trabaja en ella sin preguntar.
|
|
105
|
+
Si está cerrada (código a largo plazo, CI o equipo), pregunta antes de cada cambio: ¿esta rama,
|
|
106
|
+
una nueva, o desbloquear? Nunca abre una rama por su cuenta. Cambiarlo: «desbloquea main» o «bloquea main».
|
|
106
107
|
2. Si otra sesión de un asistente está trabajando en esta misma carpeta, el trabajo nuevo va a un
|
|
107
108
|
worktree en .worktrees/<rama>/. Para no hacerlo: «no uses worktrees».
|
|
108
109
|
3. El agente elige solo entre FTD (lo sencillo) y SDD (lo grande o complejo) y lo dice; se puede
|
package/targets/trunk-policy.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
|
|
18
18
|
export const UNLOCK = '.no-trunk-guard';
|
|
19
|
+
/** «ramas y PR», chosen at install or with `rsc main lock`: closed whatever the signals say. */
|
|
20
|
+
export const LOCK = '.no-trunk-open';
|
|
19
21
|
|
|
20
22
|
const CI = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile',
|
|
21
23
|
'bitbucket-pipelines.yml', '.buildkite', '.travis.yml', '.woodpecker.yml', '.drone.yml'];
|
|
@@ -59,11 +61,26 @@ export function defaultBranchName(root) {
|
|
|
59
61
|
return null;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/** `{ closed, reason, signals }` — reason: 'unlocked' | 'simple' | 'signals'. */
|
|
64
|
+
/** `{ closed, reason, signals }` — reason: 'unlocked' | 'locked' | 'simple' | 'signals'. */
|
|
63
65
|
export function trunkPolicy(root) {
|
|
64
66
|
if (existsSync(join(root, '.rsc', UNLOCK))) return { closed: false, reason: 'unlocked', signals: [] };
|
|
67
|
+
if (existsSync(join(root, '.rsc', LOCK))) return { closed: true, reason: 'locked', signals: ['ramas y PR, decidido para este proyecto'] };
|
|
65
68
|
const signals = trunkSignals(root);
|
|
66
69
|
return signals.length ? { closed: true, reason: 'signals', signals } : { closed: false, reason: 'simple', signals };
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
export const trunkClosed = (root) => trunkPolicy(root).closed;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The branch rule for THIS project, said every turn (2026-10-06, Eric): where the default branch is
|
|
76
|
+
* open the agent works on it and never asks; where it is closed it asks before each code change —
|
|
77
|
+
* this branch, a new one, or unlocking — and never branches on its own.
|
|
78
|
+
*/
|
|
79
|
+
export function branchRuleLine(root) {
|
|
80
|
+
const trunk = defaultBranchName(root) || 'main';
|
|
81
|
+
const policy = trunkPolicy(root);
|
|
82
|
+
if (!policy.closed) return `- Here "${trunk}" is OPEN: code changes go straight on it. Do not ask about branches.\n`;
|
|
83
|
+
const here = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
84
|
+
const options = here && here !== trunk ? `this branch ("${here}"), a new branch, or unlock "${trunk}"` : `a new branch, or unlock "${trunk}"`;
|
|
85
|
+
return `- Here "${trunk}" is CLOSED (${policy.signals.join(', ')}): before EACH code change ask: ${options}? Never choose alone.\n`;
|
|
86
|
+
}
|
|
@@ -28,4 +28,11 @@ const hook = readHookInput();
|
|
|
28
28
|
const turnKey = hook.prompt_id || (hook.prompt ? `p${hook.prompt.length}:${hook.prompt.slice(0, 64)}` : null);
|
|
29
29
|
if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)) process.exit(0);
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// The branch rule depends on THIS project (open or closed default branch), so it is computed here and
|
|
32
|
+
// said right after the gate. trunk-policy.mjs is a sibling under .rsc/; without it, the safe default.
|
|
33
|
+
let rule = '- Default branch state unknown: ask before a code change where it goes.\n';
|
|
34
|
+
try {
|
|
35
|
+
const { branchRuleLine } = await import(new URL('./trunk-policy.mjs', import.meta.url));
|
|
36
|
+
rule = branchRuleLine(root);
|
|
37
|
+
} catch { /* fail-open */ }
|
|
38
|
+
process.stdout.write(SDD_GATE_TEXT + rule);
|