clean-room-skill 0.1.5 → 0.1.6
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +40 -26
- package/agents/contaminated-manager-verifier.md +7 -0
- package/bin/install.js +286 -25
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/REFERENCE.md +2 -1
- package/lib/bootstrap.cjs +7 -1
- package/lib/doctor.cjs +10 -1
- package/lib/preflight.cjs +34 -0
- package/lib/runtime-layout.cjs +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/init/SKILL.md +2 -2
- package/skills/preflight/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -45,6 +45,10 @@ npx clean-room-skill@latest --claude --global --yes
|
|
|
45
45
|
npx clean-room-skill@latest --all --global --yes
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
For edge cases such as ccsilo variants or modified Claude directories, add `--config-dir <path-to-claude-config-root>` to target that Claude config root explicitly.
|
|
49
|
+
|
|
50
|
+
Claude global installs use Claude's plugin system for skills and agents, so entry points are namespaced as `/clean-room:init`, `/clean-room:preflight`, and `/clean-room`. The installer still manages hook files and migrates older standalone Claude skill copies out of the config root on reinstall or update.
|
|
51
|
+
|
|
48
52
|
Hook modes:
|
|
49
53
|
|
|
50
54
|
- `--hooks=safe`: default. Hooks are installed but enforce only during clean-room role sessions with the required environment.
|
|
@@ -76,14 +80,14 @@ Optionally create neutral external run folders and a clean-safe repository stub:
|
|
|
76
80
|
npx clean-room-skill@latest init
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
The default
|
|
83
|
+
The default task root is `~/Documents/CleanRoom/<task-id>/` with `contaminated/`, `clean/`, `implementation/`, and `quarantine/` children. Keep active contaminated artifacts, clean artifacts, and clean implementation roots separate.
|
|
80
84
|
|
|
81
85
|
In Claude Code, invoke skills with the plugin namespace:
|
|
82
86
|
|
|
83
87
|
```text
|
|
84
|
-
/clean-room
|
|
85
|
-
/clean-room:preflight
|
|
86
88
|
/clean-room:init
|
|
89
|
+
/clean-room:preflight
|
|
90
|
+
/clean-room
|
|
87
91
|
/clean-room:attended
|
|
88
92
|
/clean-room:unattended
|
|
89
93
|
/clean-room:resume
|
|
@@ -108,11 +112,11 @@ The `run` command executes one bounded inner clean-room loop for an already appr
|
|
|
108
112
|
|
|
109
113
|

|
|
110
114
|
|
|
111
|
-
1.
|
|
112
|
-
Use
|
|
115
|
+
1. Initialize or bootstrap the run.
|
|
116
|
+
Use `npx clean-room-skill@latest init` to create neutral external run folders and a clean-safe repository stub, or use `/clean-room:init` for skill-driven run preferences. The active `init-config.json` stays out of the clean implementation repository.
|
|
113
117
|
|
|
114
|
-
2.
|
|
115
|
-
Use `/clean-room:
|
|
118
|
+
2. Record the goal contract.
|
|
119
|
+
Use `npx clean-room-skill@latest preflight` or `/clean-room:preflight` before source discovery, attended mode, or unattended mode. This creates or validates `preflight-goal.json` on the contaminated/controller side.
|
|
116
120
|
|
|
117
121
|
3. Start the controller.
|
|
118
122
|
Use `/clean-room` or `/clean-room:attended` for human review gates. Use `/clean-room:unattended` only after preflight allows bounded unattended work with finite iteration limits and no open questions.
|
|
@@ -128,28 +132,38 @@ The `run` command executes one bounded inner clean-room loop for an already appr
|
|
|
128
132
|
|
|
129
133
|
Use recovery skills instead of chat history:
|
|
130
134
|
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
135
|
+
- `/clean-room:resume`: continue from durable artifacts.
|
|
136
|
+
- `/clean-room:start-over`: archive or quarantine current artifacts without deletion, then restart with a fresh neutral task id.
|
|
137
|
+
- `/clean-room:refocus`: audit current artifacts against declared scope without expanding scope.
|
|
138
|
+
|
|
139
|
+
## Workflow Entry Points
|
|
140
|
+
|
|
141
|
+
| Order | Entry point | Type | Use it for |
|
|
142
|
+
| --- | --- | --- | --- |
|
|
143
|
+
| 1 | `npx clean-room-skill@latest init` | CLI command | Create neutral external run folders and a clean-safe `.clean-room/README.md` stub. |
|
|
144
|
+
| 1 | `/clean-room:init` | Skill | Record run preferences, separated roots, schema profile, and model policy. |
|
|
145
|
+
| 2 | `npx clean-room-skill@latest preflight` | CLI command | Create or validate the Stage 0 goal contract. |
|
|
146
|
+
| 2 | `/clean-room:preflight` | Skill | Record the required goal, policy, output, and controller-mode contract. |
|
|
147
|
+
| 3 | `/clean-room` | Skill | Start the setup wizard for authorized clean-room work. |
|
|
148
|
+
| 3 | `/clean-room:attended` | Skill | Start the wizard in attended mode with human review gates. |
|
|
149
|
+
| 3 | `/clean-room:unattended` | Skill | Start the wizard in bounded unattended mode with finite loop limits. |
|
|
150
|
+
| 4 | `npx clean-room-skill@latest run` | CLI command | Execute the bounded inner clean-room runner for one approved spec slice. |
|
|
151
|
+
|
|
152
|
+
### Maintenance CLI Commands
|
|
153
|
+
|
|
154
|
+
| Command | Use it for |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `npx clean-room-skill@latest doctor` | Smoke test generated Codex or Claude hook registration. |
|
|
157
|
+
| `npx clean-room-skill@latest status` | Report installed runtime version, drift, and hook state. |
|
|
158
|
+
| `npx clean-room-skill@latest update` | Refresh installed runtime files without onboarding. |
|
|
134
159
|
|
|
135
|
-
|
|
160
|
+
### Recovery Skills
|
|
136
161
|
|
|
137
|
-
|
|
|
162
|
+
| Skill | Use it for |
|
|
138
163
|
| --- | --- |
|
|
139
|
-
|
|
|
140
|
-
|
|
|
141
|
-
|
|
|
142
|
-
| `clean-room-skill doctor` | Smoke test generated Codex or Claude hook registration. |
|
|
143
|
-
| `clean-room-skill status` | Report installed runtime version, drift, and hook state. |
|
|
144
|
-
| `clean-room-skill update` | Refresh installed runtime files without onboarding. |
|
|
145
|
-
| `clean-room` | Start the setup wizard for authorized clean-room work. |
|
|
146
|
-
| `preflight` | Record the required goal, policy, output, and controller-mode contract. |
|
|
147
|
-
| `init` | Record run preferences, separated roots, schema profile, and model policy. |
|
|
148
|
-
| `attended` | Start the wizard in attended mode with human review gates. |
|
|
149
|
-
| `unattended` | Start the wizard in bounded unattended mode with finite loop limits. |
|
|
150
|
-
| `resume` | Continue an existing run from durable artifacts. |
|
|
151
|
-
| `start-over` | Non-destructively archive or quarantine current artifacts and restart. |
|
|
152
|
-
| `refocus` | Audit a run and route it back to missed gates without adding scope. |
|
|
164
|
+
| `/clean-room:resume` | Continue an existing run from durable artifacts. |
|
|
165
|
+
| `/clean-room:start-over` | Non-destructively archive or quarantine current artifacts and restart. |
|
|
166
|
+
| `/clean-room:refocus` | Audit a run and route it back to missed gates without adding scope. |
|
|
153
167
|
|
|
154
168
|
Reference files:
|
|
155
169
|
|
|
@@ -39,6 +39,13 @@ Responsibilities:
|
|
|
39
39
|
- Convert terminal implementation gaps into abstract delta tickets for the next clean run. Do not steer an in-progress Agent 3 loop.
|
|
40
40
|
- Send only `clean-run-context.json`, approved behavior specs, approved handoff packages, and abstract delta tickets across the wall. Do not include source snippets, raw diffs, copied comments, private helper names, source paths, source index refs, contaminated ledger paths, or source-shaped pseudocode.
|
|
41
41
|
|
|
42
|
+
Use this file map when a CLI bootstrap is present:
|
|
43
|
+
|
|
44
|
+
- Contaminated artifact root: write `preflight-goal.json`, `init-config.json`, `task-manifest.json`, `source-index.json`, `coverage-ledger.json`, `evidence-ledger.json`, private identifier denylist artifacts, and `clean-room-result.json`.
|
|
45
|
+
- Clean artifact root: only sanitized handoff artifacts, `clean-run-context.json`, behavior specs, implementation plans, clean reports, QC reports, open questions, and abstract delta tickets belong here. Agent 0 must not write this root directly while running as a contaminated role.
|
|
46
|
+
- Implementation root: Agent 3 writes destination code, tests, fixtures, and destination project files here. Agent 0 must not write this root.
|
|
47
|
+
- Quarantine root: rejected, contaminated, or incident artifacts that must not cross into the clean domain.
|
|
48
|
+
|
|
42
49
|
Every new role session must receive `CLEAN_ROOM_ROLE`, `CLEAN_ROOM_SOURCE_ROOTS`, `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, `CLEAN_ROOM_CLEAN_ROOTS`, `CLEAN_ROOM_IMPLEMENTATION_ROOTS`, `CLEAN_ROOM_SCHEMA_DIR`, and, for clean or source-denied roles, `CLEAN_ROOM_ALLOWED_READ_ROOTS`. Do not assume environment variables persist across sessions.
|
|
43
50
|
|
|
44
51
|
In unattended mode, reload durable artifacts before each iteration, select at most one pending or gap unit inside `loop_context.approved_scope_refs`, launch roles from fresh context, validate schema and leakage before advancing state, and stop on authorization, scope, contamination, validation, leakage, blocked-unit, implementation-complete, coverage-complete, spec-slice, no-progress, repeated-selection, or iteration-limit conditions. Do not use prior chat history as task state.
|
package/bin/install.js
CHANGED
|
@@ -40,6 +40,12 @@ const INSTALL_LOCK_NAME = '.clean-room-install.lock';
|
|
|
40
40
|
const INSTALL_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_LOCK_WAIT_MS', 30_000);
|
|
41
41
|
const INSTALL_LOCK_POLL_MS = 100;
|
|
42
42
|
const PYTHON_PROBE_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_PYTHON_TIMEOUT_MS', 10_000);
|
|
43
|
+
const CLAUDE_PLUGIN_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_CLAUDE_PLUGIN_TIMEOUT_MS', 120_000);
|
|
44
|
+
const CLAUDE_PLUGIN_MARKETPLACE_NAME = 'clean-room-skill';
|
|
45
|
+
const CLAUDE_PLUGIN_NAME = 'clean-room';
|
|
46
|
+
const CLAUDE_PLUGIN_ID = `${CLAUDE_PLUGIN_NAME}@${CLAUDE_PLUGIN_MARKETPLACE_NAME}`;
|
|
47
|
+
const CLAUDE_PLUGIN_SOURCE_URL = 'https://github.com/whit3rabbit/clean-room-skill.git';
|
|
48
|
+
const CLAUDE_PLUGIN_SCOPE = 'user';
|
|
43
49
|
|
|
44
50
|
function envPositiveInteger(name, fallback) {
|
|
45
51
|
const value = process.env[name];
|
|
@@ -448,7 +454,7 @@ function defaultRuntimeSelections(statuses, action = 'install') {
|
|
|
448
454
|
return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
|
|
449
455
|
}
|
|
450
456
|
if (action === 'update') {
|
|
451
|
-
return statuses
|
|
457
|
+
return selectableRuntimeSelections(statuses, action);
|
|
452
458
|
}
|
|
453
459
|
if (action === 'status') {
|
|
454
460
|
return statuses.map((status) => status.runtime);
|
|
@@ -458,11 +464,57 @@ function defaultRuntimeSelections(statuses, action = 'install') {
|
|
|
458
464
|
|
|
459
465
|
function detectedRuntimeSelections(statuses, action = 'install') {
|
|
460
466
|
if (action === 'update') {
|
|
461
|
-
return statuses
|
|
467
|
+
return selectableRuntimeSelections(statuses, action);
|
|
462
468
|
}
|
|
463
469
|
return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
|
|
464
470
|
}
|
|
465
471
|
|
|
472
|
+
function isUpdateTargetStatus(status) {
|
|
473
|
+
return status?.state === 'installed' || status?.state === 'update-available';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isSelectableRuntimeStatus(status, action = 'install') {
|
|
477
|
+
if (action === 'update') {
|
|
478
|
+
return isUpdateTargetStatus(status);
|
|
479
|
+
}
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function selectableRuntimeSelections(statuses, action = 'install') {
|
|
484
|
+
return statuses
|
|
485
|
+
.filter((status) => isSelectableRuntimeStatus(status, action))
|
|
486
|
+
.map((status) => status.runtime);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function statusForRuntime(statuses, runtime) {
|
|
490
|
+
return statuses.find((status) => status.runtime === runtime) || {
|
|
491
|
+
runtime,
|
|
492
|
+
state: 'not-installed',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function unavailableRuntimeSelectionMessage(status, action) {
|
|
497
|
+
if (action === 'update') {
|
|
498
|
+
return `${status.runtime} is not installed in this scope; choose Install to add it.`;
|
|
499
|
+
}
|
|
500
|
+
return `${status.runtime} cannot be selected for ${action}.`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function emptyRuntimeSelectionMessage(statuses, action) {
|
|
504
|
+
if (action === 'update' && selectableRuntimeSelections(statuses, action).length === 0) {
|
|
505
|
+
return 'No installed runtimes detected for update. Choose Install instead.';
|
|
506
|
+
}
|
|
507
|
+
return 'Select at least one runtime.';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function addRuntimeSelection(selected, runtime, statuses, action) {
|
|
511
|
+
const status = statusForRuntime(statuses, runtime);
|
|
512
|
+
if (!isSelectableRuntimeStatus(status, action)) {
|
|
513
|
+
throw new Error(unavailableRuntimeSelectionMessage(status, action));
|
|
514
|
+
}
|
|
515
|
+
selected.push(runtime);
|
|
516
|
+
}
|
|
517
|
+
|
|
466
518
|
function parseRuntimeSelection(answer, statuses, action = 'install') {
|
|
467
519
|
const text = answer.trim().toLowerCase();
|
|
468
520
|
if (text === '') {
|
|
@@ -480,7 +532,7 @@ function parseRuntimeSelection(answer, statuses, action = 'install') {
|
|
|
480
532
|
const tokens = text.split(/[,\s]+/).filter(Boolean);
|
|
481
533
|
for (const token of tokens) {
|
|
482
534
|
if (token === 'all') {
|
|
483
|
-
selected.push(...RUNTIMES);
|
|
535
|
+
selected.push(...(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
|
|
484
536
|
continue;
|
|
485
537
|
}
|
|
486
538
|
if (token === 'installed') {
|
|
@@ -495,16 +547,16 @@ function parseRuntimeSelection(answer, statuses, action = 'install') {
|
|
|
495
547
|
throw new Error(`invalid runtime range: ${token}`);
|
|
496
548
|
}
|
|
497
549
|
for (let index = start; index <= end; index += 1) {
|
|
498
|
-
selected
|
|
550
|
+
addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, index), statuses, action);
|
|
499
551
|
}
|
|
500
552
|
continue;
|
|
501
553
|
}
|
|
502
554
|
if (/^\d+$/.test(token)) {
|
|
503
|
-
selected
|
|
555
|
+
addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, Number(token)), statuses, action);
|
|
504
556
|
continue;
|
|
505
557
|
}
|
|
506
558
|
if (RUNTIMES.includes(token)) {
|
|
507
|
-
selected
|
|
559
|
+
addRuntimeSelection(selected, token, statuses, action);
|
|
508
560
|
continue;
|
|
509
561
|
}
|
|
510
562
|
throw new Error(`unsupported runtime selection: ${token}`);
|
|
@@ -528,12 +580,12 @@ function isInstalledStatus(status) {
|
|
|
528
580
|
}
|
|
529
581
|
|
|
530
582
|
function nextTuiStage(options, flags) {
|
|
531
|
-
if (!flags.actionResolved) {
|
|
532
|
-
return 'action';
|
|
533
|
-
}
|
|
534
583
|
if (!options.scope) {
|
|
535
584
|
return 'scope';
|
|
536
585
|
}
|
|
586
|
+
if (!flags.actionResolved) {
|
|
587
|
+
return 'action';
|
|
588
|
+
}
|
|
537
589
|
if (operationForOptions(options) === 'status') {
|
|
538
590
|
return 'complete';
|
|
539
591
|
}
|
|
@@ -580,14 +632,18 @@ function RuntimeMultiSelect({ React, Box, Text, useInput, h, action, statuses, o
|
|
|
580
632
|
const [selected, setSelected] = React.useState(initialSelected);
|
|
581
633
|
const [error, setError] = React.useState('');
|
|
582
634
|
|
|
583
|
-
function toggle(
|
|
635
|
+
function toggle(status) {
|
|
584
636
|
setError('');
|
|
637
|
+
if (!isSelectableRuntimeStatus(status, action)) {
|
|
638
|
+
setError(unavailableRuntimeSelectionMessage(status, action));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
585
641
|
setSelected((current) => {
|
|
586
642
|
const next = new Set(current);
|
|
587
|
-
if (next.has(runtime)) {
|
|
588
|
-
next.delete(runtime);
|
|
643
|
+
if (next.has(status.runtime)) {
|
|
644
|
+
next.delete(status.runtime);
|
|
589
645
|
} else {
|
|
590
|
-
next.add(runtime);
|
|
646
|
+
next.add(status.runtime);
|
|
591
647
|
}
|
|
592
648
|
return next;
|
|
593
649
|
});
|
|
@@ -603,17 +659,17 @@ function RuntimeMultiSelect({ React, Box, Text, useInput, h, action, statuses, o
|
|
|
603
659
|
} else if (key.end) {
|
|
604
660
|
setIndex(statuses.length - 1);
|
|
605
661
|
} else if (input === ' ') {
|
|
606
|
-
toggle(statuses[index]
|
|
662
|
+
toggle(statuses[index]);
|
|
607
663
|
} else if (input === 'a') {
|
|
608
664
|
setError('');
|
|
609
|
-
setSelected(new Set(RUNTIMES));
|
|
665
|
+
setSelected(new Set(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
|
|
610
666
|
} else if (input === 'i') {
|
|
611
667
|
setError('');
|
|
612
668
|
setSelected(new Set(detectedRuntimeSelections(statuses, action)));
|
|
613
669
|
} else if (key.return || /[\r\n]/.test(input)) {
|
|
614
670
|
const runtimes = RUNTIMES.filter((runtime) => selected.has(runtime));
|
|
615
671
|
if (runtimes.length === 0) {
|
|
616
|
-
setError(
|
|
672
|
+
setError(emptyRuntimeSelectionMessage(statuses, action));
|
|
617
673
|
return;
|
|
618
674
|
}
|
|
619
675
|
onSubmit(runtimes);
|
|
@@ -622,7 +678,7 @@ function RuntimeMultiSelect({ React, Box, Text, useInput, h, action, statuses, o
|
|
|
622
678
|
|
|
623
679
|
return h(Box, { flexDirection: 'column' },
|
|
624
680
|
h(Text, { bold: true }, `Runtimes to ${action}`),
|
|
625
|
-
h(Text, { dimColor: true }, 'Space toggles. a selects all. i selects detected installs. Enter continues
|
|
681
|
+
h(Text, { dimColor: true }, `${action === 'update' ? 'Space toggles installed runtimes. a selects installed runtimes.' : 'Space toggles. a selects all.'} i selects detected installs. Enter continues.`),
|
|
626
682
|
...statuses.map((status, itemIndex) => {
|
|
627
683
|
const checked = selected.has(status.runtime) ? '[x]' : '[ ]';
|
|
628
684
|
const cursor = itemIndex === index ? '>' : ' ';
|
|
@@ -681,6 +737,200 @@ function displayPath(filePath) {
|
|
|
681
737
|
return filePath;
|
|
682
738
|
}
|
|
683
739
|
|
|
740
|
+
function usesClaudeGlobalPlugin(layout) {
|
|
741
|
+
return layout.runtime === 'claude' && layout.scope === 'global';
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function claudePluginSource() {
|
|
745
|
+
return `${CLAUDE_PLUGIN_SOURCE_URL}#v${packageVersion()}`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function truncateCommandOutput(value) {
|
|
749
|
+
const text = String(value || '').trim();
|
|
750
|
+
if (text.length <= 2000) return text;
|
|
751
|
+
return `${text.slice(0, 2000)}...`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function claudePluginEnv(layout) {
|
|
755
|
+
return {
|
|
756
|
+
...process.env,
|
|
757
|
+
CLAUDE_CONFIG_DIR: layout.targetRoot,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function claudeCommandLabel(args) {
|
|
762
|
+
return ['claude', ...args].join(' ');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function claudePluginCommandFailure(args, result) {
|
|
766
|
+
const parts = [`Claude plugin command failed: ${claudeCommandLabel(args)}`];
|
|
767
|
+
if (result.error) {
|
|
768
|
+
parts.push(result.error.message);
|
|
769
|
+
}
|
|
770
|
+
if (result.status !== null && result.status !== undefined) {
|
|
771
|
+
parts.push(`status ${result.status}`);
|
|
772
|
+
}
|
|
773
|
+
if (result.signal) {
|
|
774
|
+
parts.push(`signal ${result.signal}`);
|
|
775
|
+
}
|
|
776
|
+
const stdout = truncateCommandOutput(result.stdout);
|
|
777
|
+
const stderr = truncateCommandOutput(result.stderr);
|
|
778
|
+
if (stdout) parts.push(`stdout: ${stdout}`);
|
|
779
|
+
if (stderr) parts.push(`stderr: ${stderr}`);
|
|
780
|
+
return parts.join('; ');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function runClaudePluginCommand(layout, args, options = {}) {
|
|
784
|
+
const result = spawnSync('claude', args, {
|
|
785
|
+
encoding: 'utf8',
|
|
786
|
+
env: claudePluginEnv(layout),
|
|
787
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
788
|
+
timeout: CLAUDE_PLUGIN_TIMEOUT_MS,
|
|
789
|
+
});
|
|
790
|
+
if (result.error || result.status !== 0) {
|
|
791
|
+
throw new Error(claudePluginCommandFailure(args, result));
|
|
792
|
+
}
|
|
793
|
+
if (!options.silent) {
|
|
794
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
795
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function readClaudePluginJson(layout, args) {
|
|
801
|
+
const result = runClaudePluginCommand(layout, args, { silent: true });
|
|
802
|
+
try {
|
|
803
|
+
const parsed = JSON.parse(result.stdout || '[]');
|
|
804
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
805
|
+
} catch (err) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`Claude plugin command returned invalid JSON: ${claudeCommandLabel(args)}; ` +
|
|
808
|
+
`stdout: ${truncateCommandOutput(result.stdout)}; ${err.message}`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function claudeMarketplaceExists(layout) {
|
|
814
|
+
return readClaudePluginJson(layout, ['plugin', 'marketplace', 'list', '--json'])
|
|
815
|
+
.some((entry) => entry && entry.name === CLAUDE_PLUGIN_MARKETPLACE_NAME);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function claudePluginEntry(layout) {
|
|
819
|
+
return readClaudePluginJson(layout, ['plugin', 'list', '--json'])
|
|
820
|
+
.find((entry) => entry && entry.id === CLAUDE_PLUGIN_ID) || null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function claudePluginExists(layout) {
|
|
824
|
+
return Boolean(claudePluginEntry(layout));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function claudePluginMetadata(manifest, state = {}) {
|
|
828
|
+
const previous = manifest?.claude_plugin || {};
|
|
829
|
+
const metadata = {
|
|
830
|
+
plugin_id: CLAUDE_PLUGIN_ID,
|
|
831
|
+
plugin_name: CLAUDE_PLUGIN_NAME,
|
|
832
|
+
marketplace_name: CLAUDE_PLUGIN_MARKETPLACE_NAME,
|
|
833
|
+
source_url: CLAUDE_PLUGIN_SOURCE_URL,
|
|
834
|
+
source: claudePluginSource(),
|
|
835
|
+
scope: CLAUDE_PLUGIN_SCOPE,
|
|
836
|
+
version: packageVersion(),
|
|
837
|
+
marketplace_added_by_installer: previous.marketplace_added_by_installer === true ||
|
|
838
|
+
state.marketplaceAdded === true,
|
|
839
|
+
plugin_installed_by_installer: previous.plugin_installed_by_installer === true ||
|
|
840
|
+
state.pluginInstalled === true,
|
|
841
|
+
recorded_at: new Date().toISOString(),
|
|
842
|
+
};
|
|
843
|
+
if (state.installPath || previous.install_path) {
|
|
844
|
+
metadata.install_path = state.installPath || previous.install_path;
|
|
845
|
+
}
|
|
846
|
+
return metadata;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function ensureClaudeGlobalPlugin(layout, manifest, options, action) {
|
|
850
|
+
if (!usesClaudeGlobalPlugin(layout)) return null;
|
|
851
|
+
|
|
852
|
+
const source = claudePluginSource();
|
|
853
|
+
if (options.dryRun) {
|
|
854
|
+
const marketplaceVerb = action === 'update' ? 'refresh' : 'add';
|
|
855
|
+
const pluginVerb = action === 'update' ? 'update or install' : 'install';
|
|
856
|
+
console.log(` Claude plugin marketplace: would ${marketplaceVerb} ${source}`);
|
|
857
|
+
console.log(` Claude plugin: would ${pluginVerb} ${CLAUDE_PLUGIN_ID}`);
|
|
858
|
+
return claudePluginMetadata(manifest);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const marketplaceWasPresent = claudeMarketplaceExists(layout);
|
|
862
|
+
console.log(` Claude plugin marketplace: ${source}`);
|
|
863
|
+
runClaudePluginCommand(layout, [
|
|
864
|
+
'plugin',
|
|
865
|
+
'marketplace',
|
|
866
|
+
'add',
|
|
867
|
+
source,
|
|
868
|
+
'--scope',
|
|
869
|
+
CLAUDE_PLUGIN_SCOPE,
|
|
870
|
+
]);
|
|
871
|
+
|
|
872
|
+
const pluginBefore = claudePluginEntry(layout);
|
|
873
|
+
const pluginWasPresent = Boolean(pluginBefore);
|
|
874
|
+
if (action === 'update' && pluginWasPresent) {
|
|
875
|
+
console.log(` Claude plugin: updating ${CLAUDE_PLUGIN_ID}`);
|
|
876
|
+
runClaudePluginCommand(layout, ['plugin', 'update', CLAUDE_PLUGIN_ID]);
|
|
877
|
+
} else if (!pluginWasPresent) {
|
|
878
|
+
console.log(` Claude plugin: installing ${CLAUDE_PLUGIN_ID}`);
|
|
879
|
+
runClaudePluginCommand(layout, [
|
|
880
|
+
'plugin',
|
|
881
|
+
'install',
|
|
882
|
+
CLAUDE_PLUGIN_ID,
|
|
883
|
+
'--scope',
|
|
884
|
+
CLAUDE_PLUGIN_SCOPE,
|
|
885
|
+
]);
|
|
886
|
+
} else {
|
|
887
|
+
console.log(` Claude plugin: already installed ${CLAUDE_PLUGIN_ID}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const pluginAfter = claudePluginEntry(layout) || pluginBefore;
|
|
891
|
+
return claudePluginMetadata(manifest, {
|
|
892
|
+
marketplaceAdded: !marketplaceWasPresent,
|
|
893
|
+
pluginInstalled: !pluginWasPresent,
|
|
894
|
+
installPath: pluginAfter?.installPath,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function removeClaudeGlobalPlugin(layout, manifest, options) {
|
|
899
|
+
if (!usesClaudeGlobalPlugin(layout)) return;
|
|
900
|
+
const plugin = manifest?.claude_plugin;
|
|
901
|
+
if (!plugin) return;
|
|
902
|
+
|
|
903
|
+
if (options.dryRun) {
|
|
904
|
+
if (plugin.plugin_installed_by_installer) {
|
|
905
|
+
console.log(` Claude plugin: would uninstall ${plugin.plugin_id || CLAUDE_PLUGIN_ID}`);
|
|
906
|
+
}
|
|
907
|
+
if (plugin.marketplace_added_by_installer) {
|
|
908
|
+
console.log(` Claude plugin marketplace: would remove ${plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (plugin.plugin_installed_by_installer) {
|
|
914
|
+
const pluginId = plugin.plugin_id || CLAUDE_PLUGIN_ID;
|
|
915
|
+
if (claudePluginExists(layout)) {
|
|
916
|
+
console.log(` Claude plugin: uninstalling ${pluginId}`);
|
|
917
|
+
runClaudePluginCommand(layout, ['plugin', 'uninstall', pluginId]);
|
|
918
|
+
} else {
|
|
919
|
+
console.log(` Claude plugin: already absent ${pluginId}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (plugin.marketplace_added_by_installer) {
|
|
924
|
+
const marketplaceName = plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME;
|
|
925
|
+
if (claudeMarketplaceExists(layout)) {
|
|
926
|
+
console.log(` Claude plugin marketplace: removing ${marketplaceName}`);
|
|
927
|
+
runClaudePluginCommand(layout, ['plugin', 'marketplace', 'remove', marketplaceName]);
|
|
928
|
+
} else {
|
|
929
|
+
console.log(` Claude plugin marketplace: already absent ${marketplaceName}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
684
934
|
function collectRuntimeStatus(runtime, scope, configDir) {
|
|
685
935
|
const layout = resolveRuntimeLayout(runtime, scope, { configDir });
|
|
686
936
|
const base = {
|
|
@@ -701,6 +951,7 @@ function collectRuntimeStatus(runtime, scope, configDir) {
|
|
|
701
951
|
unknownConflicts: 0,
|
|
702
952
|
hookRegistration: layout.supportsHookRegistration ? 'none' : 'unsupported',
|
|
703
953
|
updateAvailable: false,
|
|
954
|
+
claudePlugin: null,
|
|
704
955
|
issues: [],
|
|
705
956
|
};
|
|
706
957
|
|
|
@@ -790,6 +1041,7 @@ function collectRuntimeStatus(runtime, scope, configDir) {
|
|
|
790
1041
|
unknownConflicts: plan.unknownConflicts.length,
|
|
791
1042
|
hookRegistration: hookState,
|
|
792
1043
|
updateAvailable,
|
|
1044
|
+
claudePlugin: manifest.claude_plugin || null,
|
|
793
1045
|
issues,
|
|
794
1046
|
};
|
|
795
1047
|
}
|
|
@@ -835,6 +1087,9 @@ function printStatusReport(statuses) {
|
|
|
835
1087
|
console.log(` phase: ${status.phase || 'unknown'}`);
|
|
836
1088
|
console.log(` hooks: ${status.hooksMode || 'unknown'}; registration ${status.hookRegistration}`);
|
|
837
1089
|
console.log(` files: ${status.files}; missing ${status.missing}; modified ${status.modified}; stale ${status.stale}; conflicts ${status.unknownConflicts}`);
|
|
1090
|
+
if (status.claudePlugin) {
|
|
1091
|
+
console.log(` plugin: ${status.claudePlugin.plugin_id || CLAUDE_PLUGIN_ID}; marketplace ${status.claudePlugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
|
|
1092
|
+
}
|
|
838
1093
|
} else if (status.hookRegistration === 'present') {
|
|
839
1094
|
console.log(' hooks: managed hook registration present without install manifest');
|
|
840
1095
|
}
|
|
@@ -853,7 +1108,7 @@ function selectedUpdateRuntimes(options) {
|
|
|
853
1108
|
return options.runtimes;
|
|
854
1109
|
}
|
|
855
1110
|
return runtimeInstallStatuses(options.scope, options.configDir)
|
|
856
|
-
.filter((status) => status
|
|
1111
|
+
.filter((status) => isUpdateTargetStatus(status))
|
|
857
1112
|
.map((status) => status.runtime);
|
|
858
1113
|
}
|
|
859
1114
|
|
|
@@ -985,6 +1240,8 @@ async function installRuntime(runtime, options) {
|
|
|
985
1240
|
}
|
|
986
1241
|
|
|
987
1242
|
const hookResult = prepareHookRegistration(layout, options.hookMode, { dryRun: options.dryRun });
|
|
1243
|
+
const pluginState = ensureClaudeGlobalPlugin(layout, manifest, options, verb);
|
|
1244
|
+
const installState = pluginState ? { claude_plugin: pluginState } : {};
|
|
988
1245
|
// Install order is files, installing manifest, hook config, then complete manifest.
|
|
989
1246
|
// The installing manifest gives repair/uninstall a durable handle if hook config write fails.
|
|
990
1247
|
let result;
|
|
@@ -1002,6 +1259,7 @@ async function installRuntime(runtime, options) {
|
|
|
1002
1259
|
try {
|
|
1003
1260
|
writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
|
|
1004
1261
|
phase: 'installing',
|
|
1262
|
+
...installState,
|
|
1005
1263
|
});
|
|
1006
1264
|
} catch (err) {
|
|
1007
1265
|
throw new Error(partialInstallMessage(targetRoot, {
|
|
@@ -1027,13 +1285,14 @@ async function installRuntime(runtime, options) {
|
|
|
1027
1285
|
result.manifest,
|
|
1028
1286
|
runtime,
|
|
1029
1287
|
options.scope,
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1288
|
+
options.hookMode,
|
|
1289
|
+
false,
|
|
1290
|
+
{
|
|
1291
|
+
phase: 'installing',
|
|
1292
|
+
...installState,
|
|
1293
|
+
...hookRegistrationFailureState(hookResult, err),
|
|
1294
|
+
}
|
|
1295
|
+
);
|
|
1037
1296
|
manifestStatus = 'install manifest records the failed hook registration';
|
|
1038
1297
|
} catch {
|
|
1039
1298
|
manifestStatus = 'install manifest could not record the failed hook registration';
|
|
@@ -1061,6 +1320,7 @@ async function installRuntime(runtime, options) {
|
|
|
1061
1320
|
try {
|
|
1062
1321
|
writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
|
|
1063
1322
|
phase: 'complete',
|
|
1323
|
+
...installState,
|
|
1064
1324
|
});
|
|
1065
1325
|
} catch (err) {
|
|
1066
1326
|
throw new Error(partialInstallMessage(targetRoot, {
|
|
@@ -1141,6 +1401,7 @@ async function uninstallRuntime(runtime, options) {
|
|
|
1141
1401
|
console.log(` untracked package-path files left in place: ${plan.untracked.length}`);
|
|
1142
1402
|
}
|
|
1143
1403
|
|
|
1404
|
+
removeClaudeGlobalPlugin(layout, manifest, options);
|
|
1144
1405
|
const result = applyUninstall(targetRoot, plan, options.dryRun);
|
|
1145
1406
|
if (!options.dryRun) {
|
|
1146
1407
|
removeHookRegistrations(layout, false);
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -45,6 +45,7 @@ To assist in logical unit decomposition, the workflow supports an optional sourc
|
|
|
45
45
|
|
|
46
46
|
* **Execution Boundary**: This tooling runs exclusively in the contaminated domain before clean-room role sessions are initialized.
|
|
47
47
|
* **Traversal Bounds**: Source indexing enforces file count, per-file byte, total byte, batch token, and segment caps. It validates file size again after reading, skips files that change during read, records directory walk errors, and prunes traversal after global limits are exhausted with an aggregate skipped entry.
|
|
48
|
+
* **Agent 0 Use**: Agent 0 consumes `source-index.json` only to create neutral `task-manifest.json` units and per-unit `source_index_refs`. The index stays contaminated-only and does not cross to Agent 1.5, Agent 2, Agent 3, or clean handoff packages.
|
|
48
49
|
* **Tool Trust Policy**: By default, tool discovery operates in `stat-only` mode and does not execute third-party binaries. It queries version strings only when explicitly invoked with `--probe-tools`. Tools discovered under `/opt/homebrew` or `/usr/local` remain stat-only unless `--allow-user-toolchain-probes` is also supplied. Project-local directories (such as `.bin` or `node_modules/.bin`) are ignored unless the environment variable `RE_SKILLS_TRUST_PROJECT_TOOLS=1` or the flag `--allow-working-project-tools` is supplied.
|
|
49
50
|
* **Local Tool Install Safety**: Explicit npm-backed helper installs are strict-version pinned and serialized with a cache-local lock before mutating `~/.cache/re-skills/clean-room-tools/npm`. Prefix creation failures, subprocess timeouts, and subprocess launch errors are returned as structured JSON facts instead of raw tracebacks.
|
|
50
51
|
|
|
@@ -196,6 +197,7 @@ The architecture delegates work across five distinct custom role agents to enfor
|
|
|
196
197
|
* Requires clean-safe `goal_contract` fields and `code_hygiene_policy` in `clean-run-context.json`.
|
|
197
198
|
* Accepts Agent 0 input only as schema-valid durable sanitized artifacts.
|
|
198
199
|
* Reads the clean destination foundation under `CLEAN_ROOM_IMPLEMENTATION_ROOTS`.
|
|
200
|
+
* Cannot write to `CLEAN_ROOM_IMPLEMENTATION_ROOTS`; the write hook rejects Agent 2 implementation-root writes.
|
|
199
201
|
* Merges approved handoff artifacts into the clean workspace.
|
|
200
202
|
* Writes `implementation-plan.json` with relative destination paths, tests, code hygiene policy, constraints, risks, and argv-array verification commands.
|
|
201
203
|
* Keeps `skeleton-manifest.json` valid when the target profile expects it.
|
|
@@ -229,7 +231,7 @@ Agent 3's terminal report is not enough to return. Agent 0 must consume that rep
|
|
|
229
231
|
* Records controller memory in contaminated-side `controller-run-ledger.json`.
|
|
230
232
|
* Writes `clean-room-result.json` before returning to the outer spec loop.
|
|
231
233
|
|
|
232
|
-
Progress is durable-artifact based. Chat output alone
|
|
234
|
+
Progress is durable-artifact based. `clean-room-skill run` compares semantic JSON artifact hashes that ignore volatile timestamp and artifact-hash fields, plus raw file hashes under implementation roots. Chat output alone and timestamp-only artifact churn do not count as progress.
|
|
233
235
|
|
|
234
236
|
---
|
|
235
237
|
|
|
@@ -258,13 +260,13 @@ Matcher coverage depends on the host runtime emitting hook events for the tool i
|
|
|
258
260
|
Post-write hook failures are deny-by-default and redacted. If an artifact disappears, becomes unreadable, is replaced between `stat` and read, or a referenced handoff artifact cannot be hashed, the hook reports a controlled validation failure instead of a Python traceback. `doctor` is only an install smoke test, but its failures include spawn status, signal/error, and bounded stdout/stderr snippets to make hook command problems diagnosable.
|
|
259
261
|
|
|
260
262
|
* [clean-room-hook.py](../hooks/clean-room-hook.py): The main safe/strict dispatch wrapper for the policy checks.
|
|
261
|
-
* [agent3-verification-runner.py](../hooks/agent3-verification-runner.py): Runs Agent 3 argv-array verification commands with `shell=False`, a small allowlist
|
|
263
|
+
* [agent3-verification-runner.py](../hooks/agent3-verification-runner.py): Runs Agent 3 argv-array verification commands with `shell=False`, sanitized env, bounded output, timeout, root traversal checks, and a small allowlist covering npm, pnpm, yarn, bun, and deno test commands; pytest directly or through `python -m` / `python3 -m`; `cargo test`; `go test`; and `zig build test`.
|
|
262
264
|
* [require-clean-room-env.py](../hooks/require-clean-room-env.py): Fails closed if the required role and root environment variables are missing, if trust-domain roots overlap, or if clean, implementation, or contaminated artifact root names appear source-derived.
|
|
263
265
|
* [deny-clean-room-shell.py](../hooks/deny-clean-room-shell.py): Denies shell-style tool execution inside clean-room role sessions except installed Agent 3 verification-runner invocations under implementation roots.
|
|
264
266
|
* [deny-clean-source-read.py](../hooks/deny-clean-source-read.py): Enforces that clean roles and Agent 1.5 cannot read source roots or unapproved paths; clean roles may read implementation roots, and source-denied roles are denied direct `preflight-goal.json` reads. Agent 1.5 is also denied clean roots, implementation roots, and direct `source-index.json` reads.
|
|
265
267
|
* [deny-contaminated-clean-write.py](../hooks/deny-contaminated-clean-write.py): Enforces role write roots. Agent 2 writes clean artifacts only, Agent 3 writes implementation files and clean reports, and contaminated roles write only to `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`.
|
|
266
268
|
* [check-artifact-leakage.py](../hooks/check-artifact-leakage.py): Scans clean artifacts and Agent 1.5 staged contaminated artifacts for high-risk leakage markers, source-like identifiers, and private identifier denylist terms. The private identifier denylist (loaded via `CLEAN_ROOM_PRIVATE_IDENTIFIER_DENYLIST`) is subject to hard limits to protect hook execution performance: a maximum of 1,000,000 bytes per file, 20,000 total terms, and 512 characters per individual term.
|
|
267
|
-
* [validate-json-schema.py](../hooks/validate-json-schema.py): Verifies JSON syntax and structural conformance against schemas under `CLEAN_ROOM_SCHEMA_DIR`. Under clean roots, any unrecognized JSON files that do not conform to canonical schemas will trigger a failure unless they are explicitly registered in the path-separated `CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST` environment variable.
|
|
269
|
+
* [validate-json-schema.py](../hooks/validate-json-schema.py): Verifies JSON syntax and structural conformance against schemas under `CLEAN_ROOM_SCHEMA_DIR`, including controller-side `preflight-goal.schema.json` and `init-config.schema.json`. Under clean roots, any unrecognized JSON files that do not conform to canonical schemas will trigger a failure unless they are explicitly registered in the path-separated `CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST` environment variable.
|
|
268
270
|
* [validate-handoff-package.py](../hooks/validate-handoff-package.py): Verifies that handoff packages stay within clean roots, do not reference contaminated paths, `task-manifest.json`, `preflight-goal.json`, or `source-index.json`, and match declared `sha256` checksums.
|
|
269
271
|
|
|
270
272
|
For detailed guidelines on the clean-room process, refer to:
|
package/docs/REFERENCE.md
CHANGED
|
@@ -150,6 +150,7 @@ By default, `init` creates:
|
|
|
150
150
|
|
|
151
151
|
- `contaminated/`
|
|
152
152
|
- `clean/`
|
|
153
|
+
- `implementation/`
|
|
153
154
|
- `quarantine/`
|
|
154
155
|
- `clean-room-bootstrap.json`
|
|
155
156
|
- `.clean-room/README.md` in the target repository
|
|
@@ -175,7 +176,7 @@ Options:
|
|
|
175
176
|
| `--template` | Write an attended draft with blocking open questions. |
|
|
176
177
|
| `--input <path>` | Validate and normalize/copy a completed preflight goal. |
|
|
177
178
|
| `--output <path>` | Destination `preflight-goal.json`. |
|
|
178
|
-
| `--bootstrap <path>` | Generated task root or `clean-room-bootstrap.json`; writes to the generated contaminated artifact root after scaffold validation. |
|
|
179
|
+
| `--bootstrap <path>` | Generated task root or `clean-room-bootstrap.json`; writes to the generated contaminated artifact root after scaffold validation and requires completed input roots to match the bootstrap. |
|
|
179
180
|
| `--mode <mode>` | `attended` or `unattended`; template supports attended only. |
|
|
180
181
|
| `--dry-run` | Print actions without writing files. |
|
|
181
182
|
| `--force` | Overwrite output if it already exists. |
|
package/lib/bootstrap.cjs
CHANGED
|
@@ -27,6 +27,7 @@ const BOOTSTRAP_REPO_STUB = '.clean-room/README.md';
|
|
|
27
27
|
const BOOTSTRAP_DIRS = Object.freeze({
|
|
28
28
|
contaminated: 'contaminated',
|
|
29
29
|
clean: 'clean',
|
|
30
|
+
implementation: 'implementation',
|
|
30
31
|
quarantine: 'quarantine',
|
|
31
32
|
});
|
|
32
33
|
|
|
@@ -127,6 +128,7 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
|
|
|
127
128
|
const roots = {
|
|
128
129
|
contaminated: path.join(outputRoot, 'contaminated'),
|
|
129
130
|
clean: path.join(outputRoot, 'clean'),
|
|
131
|
+
implementation: path.join(outputRoot, 'implementation'),
|
|
130
132
|
quarantine: path.join(outputRoot, 'quarantine'),
|
|
131
133
|
};
|
|
132
134
|
|
|
@@ -158,6 +160,7 @@ function buildBootstrapMetadata(options) {
|
|
|
158
160
|
roots: {
|
|
159
161
|
contaminated_artifacts: options.roots.contaminated,
|
|
160
162
|
clean_artifacts: options.roots.clean,
|
|
163
|
+
implementation_root: options.roots.implementation,
|
|
161
164
|
quarantine: options.roots.quarantine,
|
|
162
165
|
},
|
|
163
166
|
note: 'Bootstrap metadata only. The clean-room skill creates active init-config.json, task-manifest.json, and clean-run-context.json artifacts.',
|
|
@@ -169,7 +172,7 @@ function renderRepoStub(targetProfile) {
|
|
|
169
172
|
|
|
170
173
|
This repository has a clean-room bootstrap stub.
|
|
171
174
|
|
|
172
|
-
Active clean-room run artifacts are stored outside this repository. Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, or active \`init-config.json\`, \`task-manifest.json\`, or \`clean-run-context.json\` files here.
|
|
175
|
+
Active clean-room run artifacts are stored outside this repository. The bootstrap task root contains \`contaminated/\`, \`clean/\`, \`implementation/\`, and \`quarantine/\`. Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, or active \`init-config.json\`, \`task-manifest.json\`, or \`clean-run-context.json\` files here.
|
|
173
176
|
|
|
174
177
|
Default target profile: \`${targetProfile}\`
|
|
175
178
|
|
|
@@ -282,6 +285,7 @@ function validateBootstrapScaffold(taskRoot) {
|
|
|
282
285
|
const roots = {
|
|
283
286
|
contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
|
|
284
287
|
clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
|
|
288
|
+
implementation: path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
|
|
285
289
|
quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
|
|
286
290
|
};
|
|
287
291
|
for (const [label, dirPath] of Object.entries(roots)) {
|
|
@@ -294,6 +298,7 @@ function validateBootstrapScaffold(taskRoot) {
|
|
|
294
298
|
} else {
|
|
295
299
|
assertMetadataPath(metadata.roots, 'contaminated_artifacts', roots.contaminated, errors);
|
|
296
300
|
assertMetadataPath(metadata.roots, 'clean_artifacts', roots.clean, errors);
|
|
301
|
+
assertMetadataPath(metadata.roots, 'implementation_root', roots.implementation, errors);
|
|
297
302
|
assertMetadataPath(metadata.roots, 'quarantine', roots.quarantine, errors);
|
|
298
303
|
}
|
|
299
304
|
}
|
|
@@ -373,6 +378,7 @@ function printInitResult(options) {
|
|
|
373
378
|
console.log(` output folder: ${options.outputRoot}`);
|
|
374
379
|
console.log(` contaminated artifacts: ${options.roots.contaminated}`);
|
|
375
380
|
console.log(` clean artifacts: ${options.roots.clean}`);
|
|
381
|
+
console.log(` implementation root: ${options.roots.implementation}`);
|
|
376
382
|
console.log(` quarantine: ${options.roots.quarantine}`);
|
|
377
383
|
console.log(` metadata: ${options.metadataPath}`);
|
|
378
384
|
console.log(` repo stub: ${options.repoStubPath}`);
|
package/lib/doctor.cjs
CHANGED
|
@@ -187,6 +187,15 @@ function mkdirs(...dirs) {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
function schemaDirForLayout(layout) {
|
|
191
|
+
const manifest = readJsonFile(path.join(layout.targetRoot, 'clean-room-install-manifest.json'), null);
|
|
192
|
+
const pluginInstallPath = manifest?.claude_plugin?.install_path;
|
|
193
|
+
if (typeof pluginInstallPath === 'string' && pluginInstallPath) {
|
|
194
|
+
return path.join(path.resolve(pluginInstallPath), 'skills', 'clean-room', 'assets');
|
|
195
|
+
}
|
|
196
|
+
return path.join(layout.targetRoot, 'skills', 'clean-room', 'assets');
|
|
197
|
+
}
|
|
198
|
+
|
|
190
199
|
function smokeEnv(layout, tmpRoot, role) {
|
|
191
200
|
const source = path.join(tmpRoot, 'source');
|
|
192
201
|
const contaminated = path.join(tmpRoot, 'contaminated');
|
|
@@ -201,7 +210,7 @@ function smokeEnv(layout, tmpRoot, role) {
|
|
|
201
210
|
CLEAN_ROOM_CLEAN_ROOTS: clean,
|
|
202
211
|
CLEAN_ROOM_IMPLEMENTATION_ROOTS: implementation,
|
|
203
212
|
CLEAN_ROOM_ALLOWED_READ_ROOTS: allowed,
|
|
204
|
-
CLEAN_ROOM_SCHEMA_DIR:
|
|
213
|
+
CLEAN_ROOM_SCHEMA_DIR: schemaDirForLayout(layout),
|
|
205
214
|
};
|
|
206
215
|
}
|
|
207
216
|
|
package/lib/preflight.cjs
CHANGED
|
@@ -122,6 +122,31 @@ function resolveInputPath(value, cwd = process.cwd(), homeDir = os.homedir()) {
|
|
|
122
122
|
return path.resolve(cwd, expanded);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
function resolveGoalPath(value, cwd, homeDir) {
|
|
126
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return path.resolve(cwd, expandTilde(value, homeDir));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyBootstrapOutputPolicy(goal, bootstrap) {
|
|
133
|
+
goal.output_policy.artifact_base_root = bootstrap.outputRoot;
|
|
134
|
+
goal.output_policy.implementation_root = bootstrap.roots.implementation;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateBootstrapOutputPolicy(goal, bootstrap, cwd, homeDir) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
const artifactBaseRoot = resolveGoalPath(goal?.output_policy?.artifact_base_root, cwd, homeDir);
|
|
140
|
+
const implementationRoot = resolveGoalPath(goal?.output_policy?.implementation_root, cwd, homeDir);
|
|
141
|
+
if (artifactBaseRoot !== bootstrap.outputRoot) {
|
|
142
|
+
errors.push(`output_policy.artifact_base_root must match bootstrap task root: ${bootstrap.outputRoot}`);
|
|
143
|
+
}
|
|
144
|
+
if (implementationRoot !== bootstrap.roots.implementation) {
|
|
145
|
+
errors.push(`output_policy.implementation_root must match bootstrap implementation root: ${bootstrap.roots.implementation}`);
|
|
146
|
+
}
|
|
147
|
+
return errors;
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
function buildTemplate(mode = 'attended') {
|
|
126
151
|
if (mode !== 'attended') {
|
|
127
152
|
throw new Error('preflight --template supports attended mode only');
|
|
@@ -467,6 +492,9 @@ function runPreflight(argv, context = {}) {
|
|
|
467
492
|
let goal;
|
|
468
493
|
if (parsed.template) {
|
|
469
494
|
goal = buildTemplate(parsed.mode);
|
|
495
|
+
if (bootstrap) {
|
|
496
|
+
applyBootstrapOutputPolicy(goal, bootstrap);
|
|
497
|
+
}
|
|
470
498
|
} else {
|
|
471
499
|
const inputPath = resolveInputPath(parsed.input, cwd, homeDir);
|
|
472
500
|
goal = readJsonFile(inputPath, null);
|
|
@@ -479,6 +507,12 @@ function runPreflight(argv, context = {}) {
|
|
|
479
507
|
if (errors.length > 0) {
|
|
480
508
|
throw new Error(`preflight goal is invalid:\n ${errors.join('\n ')}`);
|
|
481
509
|
}
|
|
510
|
+
if (bootstrap && parsed.input) {
|
|
511
|
+
const bootstrapErrors = validateBootstrapOutputPolicy(goal, bootstrap, cwd, homeDir);
|
|
512
|
+
if (bootstrapErrors.length > 0) {
|
|
513
|
+
throw new Error(`preflight goal does not match bootstrap scaffold:\n ${bootstrapErrors.join('\n ')}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
482
516
|
writePreflightOutput(outputPath, goal, parsed);
|
|
483
517
|
return { ...parsed, outputPath, goal };
|
|
484
518
|
}
|
package/lib/runtime-layout.cjs
CHANGED
package/package.json
CHANGED
package/plugin.json
CHANGED
package/skills/init/SKILL.md
CHANGED
|
@@ -19,9 +19,9 @@ Keep `preflight-goal.json` in the controller/contaminated artifact domain. Clean
|
|
|
19
19
|
|
|
20
20
|
Use the canonical `clean-room` skill workflow and references in this plugin. Preserve the clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
|
|
21
21
|
|
|
22
|
-
The CLI command `clean-room-skill init` may have pre-created neutral external folders and a clean-safe `.clean-room/README.md` stub in the target repository. Treat that bootstrap output as convenience scaffolding only. It does not replace this skill's initialization workflow, and it must not be treated as an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
|
|
22
|
+
The CLI command `clean-room-skill init` may have pre-created neutral external folders and a clean-safe `.clean-room/README.md` stub in the target repository. The bootstrap task root must contain `contaminated/`, `clean/`, `implementation/`, and `quarantine/`. Treat that bootstrap output as convenience scaffolding only. It does not replace this skill's initialization workflow, and it must not be treated as an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
|
|
23
23
|
|
|
24
|
-
When using an existing CLI bootstrap, check `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `quarantine/`, and the target repo `.clean-room/README.md` before recording active init preferences. Stop if metadata is missing, invalid, mismatched with the task root, or any generated path is missing or the wrong type. Do not infer active workflow state from those bootstrap files.
|
|
24
|
+
When using an existing CLI bootstrap, check `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `implementation/`, `quarantine/`, and the target repo `.clean-room/README.md` before recording active init preferences. Stop if metadata is missing, invalid, mismatched with the task root, or any generated path is missing or the wrong type. Do not infer active workflow state from those bootstrap files.
|
|
25
25
|
|
|
26
26
|
## Gather
|
|
27
27
|
|
|
@@ -11,7 +11,7 @@ Create or validate `preflight-goal.json` before active clean-room artifacts star
|
|
|
11
11
|
|
|
12
12
|
Use the canonical `clean-room` workflow and read `skills/clean-room/references/PREFLIGHT.md` when collecting missing goal details. Preserve the clean-room boundary: `preflight-goal.json` is a controller/contaminated-side artifact and must not be placed in clean-role readable roots.
|
|
13
13
|
|
|
14
|
-
If the user provides output from CLI `clean-room-skill init`, check the generated bootstrap scaffold before creating or copying `preflight-goal.json`: `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `quarantine/`, and the target repo `.clean-room/README.md` must exist and agree. Treat that scaffold as convenience output only; it is not an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
|
|
14
|
+
If the user provides output from CLI `clean-room-skill init`, check the generated bootstrap scaffold before creating or copying `preflight-goal.json`: `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `implementation/`, `quarantine/`, and the target repo `.clean-room/README.md` must exist and agree. Treat that scaffold as convenience output only; it is not an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
|
|
15
15
|
|
|
16
16
|
## Required Contract
|
|
17
17
|
|
|
@@ -50,7 +50,7 @@ clean-room-skill preflight --input ./preflight-goal.json --output ~/Documents/Cl
|
|
|
50
50
|
clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/task-xxxxxxxx
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`--template` writes an attended draft with blocking open questions. It does not support unattended mode. Use `--input` for completed contracts. `--bootstrap` accepts either the generated task root or `clean-room-bootstrap.json
|
|
53
|
+
`--template` writes an attended draft with blocking open questions. It does not support unattended mode. Use `--input` for completed contracts. `--bootstrap` accepts either the generated task root or `clean-room-bootstrap.json`, writes to the generated contaminated artifact root after scaffold validation, and requires completed input contracts to match the bootstrap artifact and implementation roots.
|
|
54
54
|
|
|
55
55
|
## Handoff
|
|
56
56
|
|