clean-room-skill 0.1.4 → 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.
@@ -9,7 +9,7 @@
9
9
  "name": "clean-room",
10
10
  "source": "./",
11
11
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
12
- "version": "0.1.4",
12
+ "version": "0.1.6",
13
13
  "author": {
14
14
  "name": "whit3rabbit"
15
15
  },
@@ -2,7 +2,7 @@
2
2
  "name": "clean-room",
3
3
  "displayName": "Clean Room",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
- "version": "0.1.4",
5
+ "version": "0.1.6",
6
6
  "author": {
7
7
  "name": "whit3rabbit"
8
8
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
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 artifact base is `~/Documents/CleanRoom/<task-id>/`. Keep active contaminated artifacts, clean artifacts, and clean implementation roots separate.
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
  ![Clean Room Architecture](assets/clean-room-arch.svg)
110
114
 
111
- 1. Record the goal contract.
112
- Use `/clean-room:preflight` or `clean-room-skill preflight` before source discovery. This creates or validates `preflight-goal.json` on the contaminated/controller side.
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. Initialize preferences.
115
- Use `/clean-room:init` to record artifact roots, target profile, model preferences, clean-safe rules, and contaminated-only rules. The active `init-config.json` stays out of the clean implementation repository.
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
- - `resume`: continue from durable artifacts.
132
- - `start-over`: archive or quarantine current artifacts without deletion, then restart with a fresh neutral task id.
133
- - `refocus`: audit current artifacts against declared scope without expanding scope.
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
- ## Commands / Skills
160
+ ### Recovery Skills
136
161
 
137
- | Command or skill | Use it for |
162
+ | Skill | Use it for |
138
163
  | --- | --- |
139
- | `clean-room-skill init` | Create neutral external run folders and a clean-safe `.clean-room/README.md` stub. |
140
- | `clean-room-skill preflight` | Create or validate the Stage 0 goal contract. |
141
- | `clean-room-skill run` | Execute the bounded inner clean-room runner for one approved spec slice. |
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.filter((status) => status.state === 'installed').map((status) => status.runtime);
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.filter((status) => status.state === 'installed').map((status) => status.runtime);
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.push(runtimeForSelectionIndex(statuses, index));
550
+ addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, index), statuses, action);
499
551
  }
500
552
  continue;
501
553
  }
502
554
  if (/^\d+$/.test(token)) {
503
- selected.push(runtimeForSelectionIndex(statuses, Number(token)));
555
+ addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, Number(token)), statuses, action);
504
556
  continue;
505
557
  }
506
558
  if (RUNTIMES.includes(token)) {
507
- selected.push(token);
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(runtime) {
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].runtime);
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('Select at least one runtime.');
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.state === 'installed')
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
- options.hookMode,
1031
- false,
1032
- {
1033
- phase: 'installing',
1034
- ...hookRegistrationFailureState(hookResult, err),
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);
@@ -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 is not progress.
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, sanitized env, bounded output, timeout, and root traversal checks.
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
@@ -165,6 +166,7 @@ Usage:
165
166
  ```bash
166
167
  npx clean-room-skill@latest preflight --template --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
167
168
  npx clean-room-skill@latest preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
169
+ npx clean-room-skill@latest preflight --template --bootstrap ~/Documents/CleanRoom/task-1234abcd
168
170
  ```
169
171
 
170
172
  Options:
@@ -174,6 +176,7 @@ Options:
174
176
  | `--template` | Write an attended draft with blocking open questions. |
175
177
  | `--input <path>` | Validate and normalize/copy a completed preflight goal. |
176
178
  | `--output <path>` | Destination `preflight-goal.json`. |
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. |
177
180
  | `--mode <mode>` | `attended` or `unattended`; template supports attended only. |
178
181
  | `--dry-run` | Print actions without writing files. |
179
182
  | `--force` | Overwrite output if it already exists. |
package/lib/bootstrap.cjs CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  assertManagedPath,
10
10
  atomicWriteFile,
11
11
  atomicWriteFileNoOverwrite,
12
+ readJsonFile,
12
13
  } = require('./fs-utils.cjs');
13
14
  const { packageVersion } = require('./install-artifacts.cjs');
14
15
  const { expandTilde } = require('./runtime-layout.cjs');
@@ -21,6 +22,14 @@ const TARGET_PROFILES = new Set([
21
22
  ]);
22
23
 
23
24
  const TASK_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
+ const BOOTSTRAP_METADATA_FILE = 'clean-room-bootstrap.json';
26
+ const BOOTSTRAP_REPO_STUB = '.clean-room/README.md';
27
+ const BOOTSTRAP_DIRS = Object.freeze({
28
+ contaminated: 'contaminated',
29
+ clean: 'clean',
30
+ implementation: 'implementation',
31
+ quarantine: 'quarantine',
32
+ });
24
33
 
25
34
  function defaultArtifactBase(homeDir = os.homedir()) {
26
35
  return path.join(homeDir, 'Documents', 'CleanRoom');
@@ -119,6 +128,7 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
119
128
  const roots = {
120
129
  contaminated: path.join(outputRoot, 'contaminated'),
121
130
  clean: path.join(outputRoot, 'clean'),
131
+ implementation: path.join(outputRoot, 'implementation'),
122
132
  quarantine: path.join(outputRoot, 'quarantine'),
123
133
  };
124
134
 
@@ -131,8 +141,8 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
131
141
  artifactBase,
132
142
  outputRoot,
133
143
  roots,
134
- metadataPath: assertManagedPath(outputRoot, 'clean-room-bootstrap.json'),
135
- repoStubPath: assertManagedPath(targetDir, '.clean-room/README.md'),
144
+ metadataPath: assertManagedPath(outputRoot, BOOTSTRAP_METADATA_FILE),
145
+ repoStubPath: assertManagedPath(targetDir, BOOTSTRAP_REPO_STUB),
136
146
  };
137
147
  }
138
148
 
@@ -150,6 +160,7 @@ function buildBootstrapMetadata(options) {
150
160
  roots: {
151
161
  contaminated_artifacts: options.roots.contaminated,
152
162
  clean_artifacts: options.roots.clean,
163
+ implementation_root: options.roots.implementation,
153
164
  quarantine: options.roots.quarantine,
154
165
  },
155
166
  note: 'Bootstrap metadata only. The clean-room skill creates active init-config.json, task-manifest.json, and clean-run-context.json artifacts.',
@@ -161,7 +172,7 @@ function renderRepoStub(targetProfile) {
161
172
 
162
173
  This repository has a clean-room bootstrap stub.
163
174
 
164
- 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.
165
176
 
166
177
  Default target profile: \`${targetProfile}\`
167
178
 
@@ -170,15 +181,167 @@ Start the runtime skill from your agent and provide the external output folder p
170
181
  }
171
182
 
172
183
  function assertWritableTargets(options) {
173
- const conflicts = [];
184
+ const fileConflicts = [];
174
185
  for (const filePath of [options.metadataPath, options.repoStubPath]) {
175
186
  if (fs.existsSync(filePath) && !options.force) {
176
- conflicts.push(filePath);
187
+ fileConflicts.push(filePath);
177
188
  }
178
189
  }
179
- if (conflicts.length > 0) {
180
- throw new Error(`bootstrap file already exists; use --force to overwrite: ${conflicts.join(', ')}`);
190
+ if (fileConflicts.length > 0) {
191
+ throw new Error(`bootstrap file already exists; use --force to overwrite: ${fileConflicts.join(', ')}`);
181
192
  }
193
+
194
+ const pathConflicts = [];
195
+ for (const dirPath of Object.values(options.roots)) {
196
+ if (fs.existsSync(dirPath) && !options.force) {
197
+ pathConflicts.push(dirPath);
198
+ }
199
+ }
200
+ if (pathConflicts.length > 0) {
201
+ throw new Error(`bootstrap generated path already exists; use --force to reuse it: ${pathConflicts.join(', ')}`);
202
+ }
203
+
204
+ for (const dirPath of Object.values(options.roots)) {
205
+ const stat = lstatIfExists(dirPath);
206
+ if (stat && !stat.isDirectory()) {
207
+ throw new Error(`bootstrap generated path is not a directory: ${dirPath}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ function lstatIfExists(filePath) {
213
+ try {
214
+ return fs.lstatSync(filePath);
215
+ } catch (err) {
216
+ if (err?.code === 'ENOENT') return null;
217
+ throw err;
218
+ }
219
+ }
220
+
221
+ function requireDirectory(dirPath, label, errors) {
222
+ const stat = lstatIfExists(dirPath);
223
+ if (!stat) {
224
+ errors.push(`${label} missing: ${dirPath}`);
225
+ return;
226
+ }
227
+ if (!stat.isDirectory()) {
228
+ errors.push(`${label} is not a directory: ${dirPath}`);
229
+ }
230
+ }
231
+
232
+ function requireFile(filePath, label, errors) {
233
+ const stat = lstatIfExists(filePath);
234
+ if (!stat) {
235
+ errors.push(`${label} missing: ${filePath}`);
236
+ return;
237
+ }
238
+ if (!stat.isFile()) {
239
+ errors.push(`${label} is not a file: ${filePath}`);
240
+ }
241
+ }
242
+
243
+ function expectMetadataString(metadata, field, errors) {
244
+ if (typeof metadata?.[field] !== 'string' || metadata[field].length === 0) {
245
+ errors.push(`bootstrap metadata ${field} must be a non-empty string`);
246
+ return null;
247
+ }
248
+ return metadata[field];
249
+ }
250
+
251
+ function assertMetadataPath(metadata, field, expectedPath, errors) {
252
+ const value = expectMetadataString(metadata, field, errors);
253
+ if (!value) return;
254
+ if (path.resolve(expandTilde(value)) !== expectedPath) {
255
+ errors.push(`bootstrap metadata ${field} must match ${expectedPath}`);
256
+ }
257
+ }
258
+
259
+ function validateBootstrapScaffold(taskRoot) {
260
+ if (typeof taskRoot !== 'string' || taskRoot.trim() === '') {
261
+ throw new Error('bootstrap path requires a task root');
262
+ }
263
+ const outputRoot = path.resolve(taskRoot);
264
+ const metadataPath = assertManagedPath(outputRoot, BOOTSTRAP_METADATA_FILE);
265
+ requireFileOrThrow(metadataPath, 'bootstrap metadata');
266
+
267
+ const metadata = readJsonFile(metadataPath, null);
268
+ const errors = [];
269
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
270
+ errors.push('bootstrap metadata must be an object');
271
+ } else {
272
+ if (metadata.schema !== 1) {
273
+ errors.push('bootstrap metadata schema must be 1');
274
+ }
275
+ if (metadata.package !== 'clean-room-skill') {
276
+ errors.push('bootstrap metadata package must be clean-room-skill');
277
+ }
278
+ const taskId = expectMetadataString(metadata, 'task_id', errors);
279
+ if (taskId && taskId !== path.basename(outputRoot)) {
280
+ errors.push('bootstrap metadata task_id must match the task root basename');
281
+ }
282
+ assertMetadataPath(metadata, 'output_root', outputRoot, errors);
283
+ }
284
+
285
+ const roots = {
286
+ contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
287
+ clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
288
+ implementation: path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
289
+ quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
290
+ };
291
+ for (const [label, dirPath] of Object.entries(roots)) {
292
+ requireDirectory(dirPath, `bootstrap ${label} directory`, errors);
293
+ }
294
+
295
+ if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
296
+ if (!metadata.roots || typeof metadata.roots !== 'object' || Array.isArray(metadata.roots)) {
297
+ errors.push('bootstrap metadata roots must be an object');
298
+ } else {
299
+ assertMetadataPath(metadata.roots, 'contaminated_artifacts', roots.contaminated, errors);
300
+ assertMetadataPath(metadata.roots, 'clean_artifacts', roots.clean, errors);
301
+ assertMetadataPath(metadata.roots, 'implementation_root', roots.implementation, errors);
302
+ assertMetadataPath(metadata.roots, 'quarantine', roots.quarantine, errors);
303
+ }
304
+ }
305
+
306
+ const targetDir = metadata && typeof metadata === 'object' && !Array.isArray(metadata)
307
+ ? expectMetadataString(metadata, 'target_dir', errors)
308
+ : null;
309
+ const repoStubPath = targetDir ? assertManagedPath(path.resolve(expandTilde(targetDir)), BOOTSTRAP_REPO_STUB) : null;
310
+ if (repoStubPath) {
311
+ requireFile(repoStubPath, 'bootstrap repo stub', errors);
312
+ }
313
+
314
+ if (errors.length > 0) {
315
+ throw new Error(`bootstrap scaffold is invalid:\n ${errors.join('\n ')}`);
316
+ }
317
+
318
+ return {
319
+ outputRoot,
320
+ metadataPath,
321
+ metadata,
322
+ roots,
323
+ repoStubPath,
324
+ };
325
+ }
326
+
327
+ function requireFileOrThrow(filePath, label) {
328
+ const errors = [];
329
+ requireFile(filePath, label, errors);
330
+ if (errors.length > 0) {
331
+ throw new Error(`bootstrap scaffold is invalid:\n ${errors.join('\n ')}`);
332
+ }
333
+ }
334
+
335
+ function resolveBootstrapScaffold(value, cwd = process.cwd(), homeDir = os.homedir()) {
336
+ if (typeof value !== 'string' || value.trim() === '') {
337
+ throw new Error('--bootstrap requires a path');
338
+ }
339
+ const expanded = expandTilde(value, homeDir);
340
+ const resolved = path.resolve(cwd, expanded);
341
+ const taskRoot = path.basename(resolved) === BOOTSTRAP_METADATA_FILE
342
+ ? path.dirname(resolved)
343
+ : resolved;
344
+ return validateBootstrapScaffold(taskRoot);
182
345
  }
183
346
 
184
347
  function writeBootstrapFile(filePath, data, force) {
@@ -215,14 +378,20 @@ function printInitResult(options) {
215
378
  console.log(` output folder: ${options.outputRoot}`);
216
379
  console.log(` contaminated artifacts: ${options.roots.contaminated}`);
217
380
  console.log(` clean artifacts: ${options.roots.clean}`);
381
+ console.log(` implementation root: ${options.roots.implementation}`);
218
382
  console.log(` quarantine: ${options.roots.quarantine}`);
219
383
  console.log(` metadata: ${options.metadataPath}`);
220
384
  console.log(` repo stub: ${options.repoStubPath}`);
221
385
  console.log('');
222
386
  console.log('Next steps:');
223
- console.log(' install safe hooks: npx clean-room-skill@latest --codex --global --hooks=safe --yes');
224
- console.log(' start in your runtime: invoke the clean-room init skill, then clean-room');
225
- console.log(' uninstall runtime install: npx clean-room-skill@latest --codex --global --uninstall --yes');
387
+ console.log(' Codex:');
388
+ console.log(' install safe hooks: npx clean-room-skill@latest --codex --global --hooks=safe --yes');
389
+ console.log(' start in Codex: invoke the init skill, then clean-room through @ or the skills UI');
390
+ console.log(' uninstall runtime install: npx clean-room-skill@latest --codex --global --uninstall --yes');
391
+ console.log(' Claude Code:');
392
+ console.log(' install safe hooks: npx clean-room-skill@latest --claude --global --hooks=safe --yes');
393
+ console.log(' start in Claude Code: /clean-room:init, then /clean-room or /clean-room:attended');
394
+ console.log(' uninstall runtime install: npx clean-room-skill@latest --claude --global --uninstall --yes');
226
395
  console.log(' strict hooks are only for dedicated clean-room Codex or Claude homes');
227
396
  }
228
397
 
@@ -238,10 +407,13 @@ function runInit(argv, context = {}) {
238
407
  }
239
408
 
240
409
  module.exports = {
410
+ BOOTSTRAP_METADATA_FILE,
241
411
  defaultArtifactBase,
242
412
  generateTaskId,
243
413
  parseInitArgs,
414
+ resolveBootstrapScaffold,
244
415
  resolveInitOptions,
245
416
  runInit,
246
417
  TARGET_PROFILES,
418
+ validateBootstrapScaffold,
247
419
  };
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: path.join(layout.targetRoot, 'skills', 'clean-room', 'assets'),
213
+ CLEAN_ROOM_SCHEMA_DIR: schemaDirForLayout(layout),
205
214
  };
206
215
  }
207
216
 
package/lib/preflight.cjs CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  atomicWriteFileNoOverwrite,
10
10
  readJsonFile,
11
11
  } = require('./fs-utils.cjs');
12
+ const { resolveBootstrapScaffold } = require('./bootstrap.cjs');
12
13
 
13
14
  const VALID_MODES = new Set(['attended', 'unattended']);
14
15
  const VALID_INTENTS = new Set([
@@ -26,7 +27,7 @@ const VALID_NETWORK_POLICIES = new Set(['off', 'deps-only', 'on']);
26
27
  const VALID_DEPENDENCY_INSTALL_POLICIES = new Set(['offline', 'locked', 'allow-new']);
27
28
 
28
29
  function printPreflightHelp() {
29
- console.log(`Usage: clean-room-skill preflight (--template | --input <path>) --output <path> [options]
30
+ console.log(`Usage: clean-room-skill preflight (--template | --input <path>) (--output <path> | --bootstrap <path>) [options]
30
31
 
31
32
  Create or validate a clean-room preflight goal contract.
32
33
 
@@ -34,6 +35,7 @@ Options:
34
35
  --template Write an attended draft with blocking open questions
35
36
  --input <path> Validate and normalize/copy a completed preflight goal
36
37
  --output <path> Destination preflight-goal.json
38
+ --bootstrap <path> Generated task root or clean-room-bootstrap.json
37
39
  --mode <mode> attended or unattended (template supports attended only)
38
40
  --dry-run Print actions without writing files
39
41
  --force Overwrite output if it already exists
@@ -46,6 +48,7 @@ function parsePreflightArgs(argv) {
46
48
  template: false,
47
49
  input: null,
48
50
  output: null,
51
+ bootstrap: null,
49
52
  mode: 'attended',
50
53
  dryRun: false,
51
54
  force: false,
@@ -72,6 +75,11 @@ function parsePreflightArgs(argv) {
72
75
  options.output = requiredValue(argv, index, '--output');
73
76
  } else if (arg.startsWith('--output=')) {
74
77
  options.output = arg.slice('--output='.length);
78
+ } else if (arg === '--bootstrap') {
79
+ index += 1;
80
+ options.bootstrap = requiredValue(argv, index, '--bootstrap');
81
+ } else if (arg.startsWith('--bootstrap=')) {
82
+ options.bootstrap = arg.slice('--bootstrap='.length);
75
83
  } else if (arg === '--mode') {
76
84
  index += 1;
77
85
  options.mode = requiredValue(argv, index, '--mode');
@@ -114,6 +122,31 @@ function resolveInputPath(value, cwd = process.cwd(), homeDir = os.homedir()) {
114
122
  return path.resolve(cwd, expanded);
115
123
  }
116
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
+
117
150
  function buildTemplate(mode = 'attended') {
118
151
  if (mode !== 'attended') {
119
152
  throw new Error('preflight --template supports attended mode only');
@@ -444,12 +477,24 @@ function runPreflight(argv, context = {}) {
444
477
  if (parsed.template === Boolean(parsed.input)) {
445
478
  throw new Error('specify exactly one of --template or --input');
446
479
  }
480
+ if (parsed.bootstrap && parsed.output) {
481
+ throw new Error('--bootstrap conflicts with --output');
482
+ }
483
+ if (!parsed.bootstrap && !parsed.output) {
484
+ throw new Error('specify exactly one of --output or --bootstrap');
485
+ }
447
486
  const cwd = context.cwd || process.cwd();
448
487
  const homeDir = context.homeDir || os.homedir();
449
- const outputPath = resolveOutputPath(parsed.output, cwd, homeDir);
488
+ const bootstrap = parsed.bootstrap ? resolveBootstrapScaffold(parsed.bootstrap, cwd, homeDir) : null;
489
+ const outputPath = bootstrap
490
+ ? path.join(bootstrap.roots.contaminated, 'preflight-goal.json')
491
+ : resolveOutputPath(parsed.output, cwd, homeDir);
450
492
  let goal;
451
493
  if (parsed.template) {
452
494
  goal = buildTemplate(parsed.mode);
495
+ if (bootstrap) {
496
+ applyBootstrapOutputPolicy(goal, bootstrap);
497
+ }
453
498
  } else {
454
499
  const inputPath = resolveInputPath(parsed.input, cwd, homeDir);
455
500
  goal = readJsonFile(inputPath, null);
@@ -462,6 +507,12 @@ function runPreflight(argv, context = {}) {
462
507
  if (errors.length > 0) {
463
508
  throw new Error(`preflight goal is invalid:\n ${errors.join('\n ')}`);
464
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
+ }
465
516
  writePreflightOutput(outputPath, goal, parsed);
466
517
  return { ...parsed, outputPath, goal };
467
518
  }
@@ -45,7 +45,7 @@ const RUNTIME_DEFS = Object.freeze({
45
45
  localDir: '.claude',
46
46
  hooks: true,
47
47
  artifacts: {
48
- global: [STANDARD_SKILLS, CLAUDE_AGENTS, HOOKS],
48
+ global: [HOOKS],
49
49
  local: [
50
50
  {
51
51
  kind: 'commands',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room-skill",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "bin": {
6
6
  "clean-room-skill": "bin/install.js"
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -19,7 +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
+
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.
23
25
 
24
26
  ## Gather
25
27
 
@@ -11,6 +11,8 @@ 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/`, `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
+
14
16
  ## Required Contract
15
17
 
16
18
  Record these decisions:
@@ -45,9 +47,10 @@ Use the CLI only for template creation or validation/copying:
45
47
  ```bash
46
48
  clean-room-skill preflight --template --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
47
49
  clean-room-skill preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
50
+ clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/task-xxxxxxxx
48
51
  ```
49
52
 
50
- `--template` writes an attended draft with blocking open questions. It does not support unattended mode. Use `--input` for completed contracts.
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.
51
54
 
52
55
  ## Handoff
53
56