Skip to content

Commit ea18c17

Browse files
NagyViktNagyVikt
andauthored
feat(scaffold): default to minimal multiagent block; full contract opt-in via --contract (#610)
Adding gitguardex to a repo injected a 171-line multi-agent contract into AGENTS.md by default. Default install now ships a ~10-line minimal block (agent branch + worktree, claim files, finish via PR); the full contract is opt-in via --contract (alias --full). --minimal/--no-contract force minimal. ensureAgentsSnippet selects the template by options.contract and never downgrades an existing full block (line-count detector >40 non-blank lines). The flag is parsed in the shared parseCommonArgs and forwarded through both sandbox re-spawn builders (setup + doctor) and the recursive doctor child argv, so protected-base and nested runs keep the chosen variant. Tests: flip setup/doctor contract assertions to the new minimal default; add a CLI test (minimal default -> --contract upgrade -> no-downgrade) and a unit test guarding both sandbox argv builders forward --contract. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 1447e60 commit ea18c17

12 files changed

Lines changed: 188 additions & 43 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-03
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# agent-claude-lean-install-minimal-multiagent-block-op-2026-06-03-10-12 (minimal / T1)
2+
3+
Branch: `agent/claude/lean-install-minimal-multiagent-block-op-2026-06-03-10-12`
4+
5+
Make gitguardex's install lean: default `gx setup`/`gx doctor`/`gx install` inject a
6+
~10-line **minimal** multi-agent block into a target repo's `AGENTS.md`; the full
7+
171-line contract becomes opt-in via `--contract` (alias `--full`).
8+
9+
## What / Why
10+
11+
Adding gitguardex dumped a 171-line contract into `AGENTS.md` — heavy and noisy
12+
("don't want the contract"). Most repos want only the load-bearing rules (branch +
13+
worktree, claim files, finish via PR) without the full Colony/OpenSpec/token
14+
appendix. Full contract stays one flag away.
15+
16+
## How
17+
18+
- New `templates/AGENTS.multiagent-safety.min.md` — same
19+
`<!-- multiagent-safety:START/END -->` markers, ~8 non-blank lines.
20+
- `ensureAgentsSnippet(repoRoot, dryRun, options)` picks template by
21+
`options.contract`; **never downgrades** — an existing managed block over
22+
`FULL_BLOCK_LINE_THRESHOLD` (40 non-blank lines) keeps refreshing from the full
23+
template even without the flag.
24+
- `--contract`/`--full`/`--minimal`/`--no-contract` parsed in shared
25+
`parseCommonArgs`; forwarded through sandbox respawn + recursive-doctor argv.
26+
27+
## Verify
28+
29+
- `node --test test/setup.test.js test/doctor.test.js` — contract tests green. The
30+
7 remaining failures are pre-existing environment failures (gh/npm/worktree
31+
detection), identical on baseline, unaffected by this diff.
32+
- New test: "setup --contract opts into the full contract; default stays minimal
33+
and is never downgraded".
34+
35+
## Out of scope (follow-ups)
36+
37+
- `gx claude install --global` writer.
38+
- Real gx-native merge gate (`mergeStateStatus` / fail-closed review) — global
39+
`/autoship` already gates at the agent level.
40+
41+
## Handoff
42+
43+
- Handoff: change=`agent-claude-lean-install-minimal-multiagent-block-op-2026-06-03-10-12`; branch=`agent/claude/lean-install-minimal-multiagent-block-op-2026-06-03-10-12`; scope=`lean install minimal block + opt-in contract`; action=`commit, then finish via PR`.
44+
45+
## Cleanup
46+
47+
- [ ] Run: `gx branch finish --branch agent/claude/lean-install-minimal-multiagent-block-op-2026-06-03-10-12 --base main --via-pr --wait-for-merge --cleanup`
48+
- [ ] Record PR URL + `MERGED` state in the completion handoff.
49+
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).

src/cli/args.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ function parseCommonArgs(rawArgs, defaults) {
108108
options.allowProtectedBaseWrite = true;
109109
continue;
110110
}
111+
if (arg === '--contract' || arg === '--full') {
112+
options.contract = true;
113+
continue;
114+
}
115+
if (arg === '--minimal' || arg === '--no-contract') {
116+
options.contract = false;
117+
continue;
118+
}
111119
if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') {
112120
options.waitForMerge = true;
113121
continue;

src/cli/commands/doctor.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function doctor(rawArgs) {
8181
...(options.skipAgents ? ['--skip-agents'] : []),
8282
...(options.skipPackageJson ? ['--skip-package-json'] : []),
8383
...(options.skipGitignore ? ['--no-gitignore'] : []),
84+
...(options.contract ? ['--contract'] : []),
8485
...(options.dryRun ? ['--dry-run'] : []),
8586
// Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
8687
'--no-wait-for-merge',

src/cli/shared/sandbox.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function runSetupBootstrapInternal(options) {
162162
skipPackageJson: options.skipPackageJson,
163163
skipGitignore: options.skipGitignore,
164164
allowProtectedBaseWrite: options.allowProtectedBaseWrite,
165+
contract: options.contract,
165166
});
166167

167168
return {
@@ -200,6 +201,7 @@ function buildSandboxSetupArgs(options, sandboxTarget) {
200201
if (options.skipPackageJson) args.push('--skip-package-json');
201202
if (options.skipGitignore) args.push('--no-gitignore');
202203
if (options.dryRun) args.push('--dry-run');
204+
if (options.contract) args.push('--contract');
203205
return args;
204206
}
205207

src/cli/shared/scaffolding.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ function runInstallInternal(options) {
162162
operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
163163

164164
if (!options.skipAgents) {
165-
operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
165+
operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force), contract: Boolean(options.contract) }));
166166
operations.push(ensureMonorepoAppsSnippet(repoRoot, Boolean(options.dryRun)));
167167
operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun)));
168168
}
@@ -247,7 +247,7 @@ function runFixInternal(options) {
247247
}
248248

249249
if (!options.skipAgents) {
250-
operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
250+
operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force), contract: Boolean(options.contract) }));
251251
operations.push(ensureMonorepoAppsSnippet(repoRoot, Boolean(options.dryRun)));
252252
operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun)));
253253
}

src/doctor/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
119119
if (options.skipAgents) args.push('--skip-agents');
120120
if (options.skipPackageJson) args.push('--skip-package-json');
121121
if (options.skipGitignore) args.push('--no-gitignore');
122+
if (options.contract) args.push('--contract');
122123
if (!options.dropStaleLocks) args.push('--keep-stale-locks');
123124
args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
124125
if (options.verboseAutoFinish) args.push('--verbose-auto-finish');

src/scaffold/index.js

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -502,31 +502,54 @@ function removeLegacyManagedRepoFile(repoRoot, relativePath, options = {}) {
502502
return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
503503
}
504504

505-
function ensureAgentsSnippet(repoRoot, dryRun) {
505+
// A managed block longer than this many non-blank lines is treated as the full
506+
// contract. The minimal block is ~8 lines and the full contract is ~171, so any
507+
// threshold between the two is safe; 40 leaves a wide margin on both sides.
508+
const FULL_BLOCK_LINE_THRESHOLD = 40;
509+
510+
function countNonBlankLines(text) {
511+
return text.split('\n').filter((line) => line.trim().length > 0).length;
512+
}
513+
514+
// Default install ships the minimal block; the full 171-line contract is opt-in
515+
// via `options.contract` (--contract / --full). Once a repo has the full block,
516+
// it is never silently downgraded: an existing managed block over the line
517+
// threshold keeps refreshing from the full template even without the flag.
518+
function ensureAgentsSnippet(repoRoot, dryRun, options = {}) {
506519
const agentsPath = path.join(repoRoot, 'AGENTS.md');
507-
const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
508520
const managedRegex = new RegExp(
509521
`${AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
510522
'm',
511523
);
512524

513-
if (!fs.existsSync(agentsPath)) {
525+
const existing = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, 'utf8') : null;
526+
const existingBlock = existing ? existing.match(managedRegex) : null;
527+
const existingIsFull = Boolean(existingBlock)
528+
&& countNonBlankLines(existingBlock[0]) > FULL_BLOCK_LINE_THRESHOLD;
529+
const wantFull = Boolean(options.contract) || existingIsFull;
530+
531+
const templateFile = wantFull
532+
? 'AGENTS.multiagent-safety.md'
533+
: 'AGENTS.multiagent-safety.min.md';
534+
const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, templateFile), 'utf8').trimEnd();
535+
const variant = wantFull ? 'full contract block' : 'minimal block';
536+
537+
if (existing == null) {
514538
if (!dryRun) {
515539
fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
516540
}
517-
return { status: 'created', file: 'AGENTS.md' };
541+
return { status: 'created', file: 'AGENTS.md', note: variant };
518542
}
519543

520-
const existing = fs.readFileSync(agentsPath, 'utf8');
521-
if (managedRegex.test(existing)) {
522-
const next = existing.replace(managedRegex, snippet);
544+
if (existingBlock) {
545+
const next = existing.replace(managedRegex, () => snippet);
523546
if (next === existing) {
524-
return { status: 'unchanged', file: 'AGENTS.md' };
547+
return { status: 'unchanged', file: 'AGENTS.md', note: variant };
525548
}
526549
if (!dryRun) {
527550
fs.writeFileSync(agentsPath, next, 'utf8');
528551
}
529-
return { status: 'updated', file: 'AGENTS.md', note: 'refreshed gitguardex-managed block' };
552+
return { status: 'updated', file: 'AGENTS.md', note: `refreshed gitguardex-managed block (${variant})` };
530553
}
531554

532555
if (existing.includes(AGENTS_MARKER_START)) {
@@ -538,7 +561,7 @@ function ensureAgentsSnippet(repoRoot, dryRun) {
538561
fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
539562
}
540563

541-
return { status: 'updated', file: 'AGENTS.md' };
564+
return { status: 'updated', file: 'AGENTS.md', note: variant };
542565
}
543566

544567
function ensureClaudeAgentsLink(repoRoot, dryRun) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- multiagent-safety:START -->
2+
## Multi-Agent Safety (minimal)
3+
4+
Guardex is enabled by default. Disable via repo-root `.env` with `GUARDEX_ON=0`.
5+
6+
- Work from an `agent/*` branch + worktree — never edit the protected base (`main`/`dev`) directly. Start with `gx branch start "<task>" "<agent-name>"`, then `cd` into the printed worktree.
7+
- Claim files before editing: `gx locks claim --branch "<agent-branch>" <file...>`.
8+
- Finish completed work via PR + cleanup: `gx branch finish --branch "<agent-branch>" --via-pr --wait-for-merge --cleanup` (or `gx finish --all`).
9+
10+
Want the full multi-agent contract (Colony coordination, OpenSpec, token discipline, recovery)? Run `gx setup --contract`.
11+
<!-- multiagent-safety:END -->

test/doctor.test.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,42 @@ Trailing project notes after managed block.
128128
assert.match(currentAgents, /Trailing project notes after managed block\./);
129129
assert.match(currentAgents, /Guardex is enabled by default/);
130130
assert.match(currentAgents, /GUARDEX_ON=0/);
131-
assert.match(currentAgents, /GUARDEX_ON=1/);
132-
assert.match(currentAgents, /Small tasks stay direct and caveman-only\./);
133-
assert.match(currentAgents, /Promote to full Guardex \/ OMX orchestration only when scope grows into:/);
134-
assert.match(currentAgents, /final completion\/cleanup section/);
135-
assert.match(currentAgents, /PR URL \+ final `MERGED` evidence/);
131+
// Default refresh installs the minimal block (full contract is opt-in via --contract).
132+
assert.match(currentAgents, /## Multi-Agent Safety \(minimal\)/);
133+
assert.match(currentAgents, /gx branch finish .*--via-pr --wait-for-merge --cleanup/);
134+
assert.match(currentAgents, /Run `gx setup --contract`/);
135+
assert.doesNotMatch(currentAgents, /## Multi-Agent Execution Contract/);
136136
assert.doesNotMatch(currentAgents, /legacy managed clause/);
137137
assert.match(result.stdout, /refreshed gitguardex-managed block/);
138138
});
139139

140140

141+
test('setup --contract opts into the full contract; default stays minimal and is never downgraded', () => {
142+
const repoDir = initRepo();
143+
144+
// Default install ships the minimal block, not the 171-line contract.
145+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
146+
assert.equal(result.status, 0, result.stderr || result.stdout);
147+
let agents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
148+
assert.match(agents, /## Multi-Agent Safety \(minimal\)/);
149+
assert.doesNotMatch(agents, /## Multi-Agent Execution Contract/);
150+
151+
// Opt in: --contract upgrades the managed block to the full contract.
152+
result = runNode(['setup', '--target', repoDir, '--no-global-install', '--contract'], repoDir);
153+
assert.equal(result.status, 0, result.stderr || result.stdout);
154+
agents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
155+
assert.match(agents, /## Multi-Agent Execution Contract/);
156+
assert.doesNotMatch(agents, /## Multi-Agent Safety \(minimal\)/);
157+
158+
// No downgrade: a later default run keeps the full contract in place.
159+
result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
160+
assert.equal(result.status, 0, result.stderr || result.stdout);
161+
agents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
162+
assert.match(agents, /## Multi-Agent Execution Contract/);
163+
assert.doesNotMatch(agents, /## Multi-Agent Safety \(minimal\)/);
164+
});
165+
166+
141167
test('doctor on protected main auto-runs in a sandbox branch/worktree', () => {
142168
const repoDir = initRepoOnBranch('main');
143169
seedCommit(repoDir);

0 commit comments

Comments
 (0)