clean-room-skill 0.1.5 → 0.1.7

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.5",
12
+ "version": "0.1.7",
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.5",
5
+ "version": "0.1.7",
6
6
  "author": {
7
7
  "name": "whit3rabbit"
8
8
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -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: 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
@@ -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
  }
@@ -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.5",
3
+ "version": "0.1.7",
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.5",
3
+ "version": "0.1.7",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -11,6 +11,8 @@ Start the clean-room startup wizard with `controller_policy.mode` fixed to `atte
11
11
 
12
12
  Use the canonical `clean-room` skill workflow and references in this plugin. Preserve the same clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
13
13
 
14
+ Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` candidates. If a valid `task-manifest.json` exists, route to `resume`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
15
+
14
16
  Load or create `preflight-goal.json` first. Attended mode may continue with unresolved questions only when they are recorded as `open_questions`; blocking questions become pause gates before affected work starts.
15
17
 
16
18
  Gather only required setup facts:
@@ -65,7 +65,27 @@ Use the recovery skills when a run already has durable artifacts:
65
65
 
66
66
  Use the startup wizard when the user invokes this skill directly, such as `/clean-room` or `/clean-room:clean-room`, and does not provide an existing `task-manifest.json` or specific artifact review task.
67
67
 
68
- Load or create `preflight-goal.json` first. Do not start attended or unattended execution until the goal contract records the end goal, target stack, license policy, dependency policy, compatibility/exactness policy, feature add/remove policy, code hygiene limits, output policy, existing destination policy, and controller mode.
68
+ ### Run State Discovery Before Wizard
69
+
70
+ Before asking preflight questions, perform read-only run-state discovery. Do not rely on prior chat as state.
71
+
72
+ Discovery order:
73
+
74
+ 1. Resolve explicit user-provided paths first. Accept a task root, `task-manifest.json`, `preflight-goal.json`, or `clean-room-bootstrap.json`.
75
+ 2. Inspect configured clean-room roots from the current request or environment, including `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, `CLEAN_ROOM_CLEAN_ROOTS`, and `CLEAN_ROOM_IMPLEMENTATION_ROOTS` when present.
76
+ 3. Scan `~/Documents/CleanRoom/task-*` as a bounded fallback. Inspect only immediate task directories and their expected artifact names.
77
+
78
+ If more than one candidate run is found without an explicit user path, list the candidate task roots and stop for explicit selection. Do not choose the newest candidate automatically.
79
+
80
+ Classify the selected candidate before starting the wizard:
81
+
82
+ - Valid `task-manifest.json`: route to `resume` and continue from the earliest incomplete gate.
83
+ - Valid canonical `preflight-goal.json` without `task-manifest.json`: continue at source/destination discovery and manifest creation. Do not ask the preflight wizard again.
84
+ - `clean-room-bootstrap.json` only: run preflight using the bootstrap roots.
85
+ - Invalid `preflight-goal.json`: stop, report canonical schema or required-field errors, and do not create a replacement preflight.
86
+ - No artifacts found: start the normal preflight wizard.
87
+
88
+ Load or create `preflight-goal.json` only after this discovery step. Do not start attended or unattended execution until the goal contract records the end goal, target stack, license policy, dependency policy, compatibility/exactness policy, feature add/remove policy, code hygiene limits, output policy, existing destination policy, and controller mode.
69
89
 
70
90
  Gather only the setup facts needed to decide whether the workflow may start, or invoke `init` when the user wants a dedicated setup pass:
71
91
 
@@ -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
 
@@ -27,6 +27,10 @@ Record these decisions:
27
27
  - Controller policy: attended or unattended, iteration cap, and whether unattended is allowed after preflight.
28
28
  - Open questions, with blocking questions clearly marked.
29
29
 
30
+ The artifact must use the canonical `preflight-goal.schema.json` shape. Required top-level keys are `goal_id`, `created_at`, `end_goal`, `target_stack`, `license_policy`, `dependency_policy`, `compatibility_policy`, `feature_policy`, `code_hygiene_policy`, `output_policy`, `controller_policy`, and `open_questions`.
31
+
32
+ Reject non-canonical or legacy-shaped preflight artifacts instead of treating them as complete. Do not accept invented fields such as `version`, `created`, `source`, `destination`, `exactness_policy`, `output_policy.artifact_base`, `output_policy.contaminated_root`, `output_policy.clean_root`, or `output_policy.quarantine_root` as substitutes for canonical fields. Report the missing or invalid canonical fields and stop for review.
33
+
30
34
  ## Mode Rules
31
35
 
32
36
  Attended runs may continue with recorded `open_questions`, but each blocking question becomes a pause gate before the affected work starts.
@@ -50,7 +54,7 @@ clean-room-skill preflight --input ./preflight-goal.json --output ~/Documents/Cl
50
54
  clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/task-xxxxxxxx
51
55
  ```
52
56
 
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` and writes to the generated contaminated artifact root after scaffold validation.
57
+ `--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
58
 
55
59
  ## Handoff
56
60
 
@@ -11,6 +11,8 @@ Start the clean-room startup wizard with `controller_policy.mode` fixed to `unat
11
11
 
12
12
  Use the canonical `clean-room` skill workflow and references in this plugin. Read `skills/clean-room/references/CONTROLLER-LOOP.md` before defining unattended loop behavior. Preserve the same clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
13
13
 
14
+ Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` candidates. If a valid `task-manifest.json` exists, route to `resume`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
15
+
14
16
  Load or create `preflight-goal.json` first. Unattended mode requires a complete goal contract with no blocking or non-blocking `open_questions`, `controller_policy.unattended_allowed_after_preflight: true`, and a finite `controller_policy.max_iterations`.
15
17
 
16
18
  Do not assume target language, license policy, dependency policy, exactness policy, output directory, or feature add/remove policy during the unattended loop. Stop on ambiguity instead of inventing product decisions.