clean-room-skill 0.1.14 → 0.1.15

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.14",
12
+ "version": "0.1.15",
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.14",
5
+ "version": "0.1.15",
6
6
  "author": {
7
7
  "name": "whit3rabbit"
8
8
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -250,14 +250,14 @@ The outer loop owns spec development: scope, behavior specs, acceptance criteria
250
250
 
251
251
  Agent 3's terminal report is not enough to return. If configured, Agent 4 must produce a passing `polish-report.json`. Agent 0 must then consume the terminal clean reports, verify contaminated-side coverage, and write `clean-room-result.json`.
252
252
 
253
- `clean-room-skill run` is the executable v1 inner-loop runner. It requires preflight refs, the required handoff sequence, unattended `controller_policy`, schema-valid `loop_context`, and a user-supplied agent command adapter. It does not automate outer spec development. The runner:
253
+ `clean-room-skill run` is the executable v1 inner-loop runner. It requires preflight refs, the required handoff sequence, unattended `controller_policy`, schema-valid `loop_context`, and either a user-supplied agent command adapter or the built-in Claude Code agent runtime. It does not automate outer spec development. The runner:
254
254
 
255
255
  * Locks the contaminated artifact root with `.clean-room-run.lock`.
256
256
  * Reloads durable artifacts before each iteration.
257
257
  * Selects at most one pending or gap unit inside `loop_context.approved_scope_refs`.
258
258
  * Requires exactly one `unit_kind: "foundation"` unit, named by `loop_context.foundation_unit_ref`; behavior units cannot run or complete until that foundation unit is covered.
259
259
  * Spawns configured role commands with `shell: false`, bounded output, and bounded timeout.
260
- * In strict context-management mode, requires each configured stage to provide `context.fresh_session: true` and `context.brief_path`, then validates the session brief before spawn.
260
+ * In strict context-management mode, requires each configured worker stage after `contaminated-manager-prepare` to provide `context.fresh_session: true` and `context.brief_path`, then validates the session brief before spawn.
261
261
  * Supports the optional `clean-polish-review` phase between `clean-implement-qc` and `contaminated-coverage-verify`.
262
262
  * Validates schema, leakage, and handoff integrity before advancing state.
263
263
  * Rejects `covered` coverage-ledger units that still have unresolved high-priority `discovery_leads`.
package/docs/REFERENCE.md CHANGED
@@ -214,7 +214,7 @@ Usage:
214
214
  ```bash
215
215
  npx clean-room-skill@latest run \
216
216
  --task-manifest ~/Documents/CleanRoom/task-1234abcd/contaminated/task-manifest.json \
217
- --agent-commands ./agent-commands.json \
217
+ --agent-runtime claude \
218
218
  --max-iterations 3
219
219
  ```
220
220
 
@@ -223,7 +223,9 @@ Options:
223
223
  | Option | Description |
224
224
  | --- | --- |
225
225
  | `--task-manifest <path>` | Required path to `task-manifest.json`. |
226
- | `--agent-commands <path>` | Required role command adapter JSON unless `--dry-run` is set. |
226
+ | `--agent-commands <path>` | Role command adapter JSON unless `--agent-runtime` or `--dry-run` is set. |
227
+ | `--agent-runtime claude` | Use the built-in Claude Code adapter to launch plugin role agents. Mutually exclusive with `--agent-commands`. |
228
+ | `--agent-config-dir <path>` | Claude config directory for `--agent-runtime claude`; defaults to `CLAUDE_CONFIG_DIR` or `~/.claude`. |
227
229
  | `--max-iterations <n>` | May only lower the manifest and `loop_context` cap. |
228
230
  | `--once` | Run at most one inner-loop iteration. |
229
231
  | `--dry-run` | Validate and print the selected unit without writing or spawning agents. |
@@ -259,9 +261,9 @@ Minimal agent command adapter shape for advisory or disabled context management:
259
261
  }
260
262
  ```
261
263
 
262
- Supported phases are `contaminated-analysis`, `sanitize-handoff`, `clean-plan`, `clean-implement-qc`, optional `clean-polish-review`, and `contaminated-coverage-verify`. The coverage verification phase is required. When present, `clean-polish-review` must run after `clean-implement-qc` and before `contaminated-coverage-verify`.
264
+ Supported phases are `contaminated-manager-prepare`, `contaminated-analysis`, `sanitize-handoff`, `clean-plan`, `clean-implement-qc`, optional `clean-polish-review`, and `contaminated-coverage-verify`. The coverage verification phase is required. The built-in Claude adapter includes `contaminated-manager-prepare` so Agent 0 can prepare controller state before downstream role agents run. When present, `clean-polish-review` must run after `clean-implement-qc` and before `contaminated-coverage-verify`.
263
265
 
264
- When `task-manifest.json` sets `context_management.mode` to `role-session-briefs` and `context_management.enforcement` to `strict`, every configured stage must include `context.fresh_session: true` and `context.brief_path`. The runner validates the brief before spawn, passes only the brief path plus environment facts in the stage prompt, and records the brief ref/hash in `controller-run-ledger.json`.
266
+ When `task-manifest.json` sets `context_management.mode` to `role-session-briefs` and `context_management.enforcement` to `strict`, every configured worker stage after `contaminated-manager-prepare` must include `context.fresh_session: true` and `context.brief_path`. The runner validates the brief before spawn, passes only the brief path plus environment facts in the stage prompt, and records the brief ref/hash in `controller-run-ledger.json`.
265
267
 
266
268
  Strict context-management adapter example:
267
269
 
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const { readJsonFile } = require('./fs-utils.cjs');
8
+
9
+ const CLAUDE_AGENT_FILES = Object.freeze([
10
+ 'clean-architect.md',
11
+ 'clean-implementer-verifier-shell.md',
12
+ 'clean-polish-reviewer.md',
13
+ 'clean-qa-editor.md',
14
+ 'contaminated-handoff-sanitizer.md',
15
+ 'contaminated-manager-verifier.md',
16
+ 'contaminated-source-analyst.md',
17
+ ]);
18
+
19
+ function packageRoot() {
20
+ return path.resolve(__dirname, '..');
21
+ }
22
+
23
+ function localClaudePluginDir() {
24
+ return packageRoot();
25
+ }
26
+
27
+ function defaultClaudeConfigDir(env = process.env) {
28
+ if (env.CLAUDE_CONFIG_DIR) {
29
+ return path.resolve(expandTilde(env.CLAUDE_CONFIG_DIR));
30
+ }
31
+ return path.join(os.homedir(), '.claude');
32
+ }
33
+
34
+ function expandTilde(value) {
35
+ if (value === '~') return os.homedir();
36
+ if (typeof value === 'string' && value.startsWith('~/')) {
37
+ return path.join(os.homedir(), value.slice(2));
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function claudePluginDirFromInstallManifest(configDir) {
43
+ const manifestPath = path.join(configDir, 'clean-room-install-manifest.json');
44
+ if (!fs.existsSync(manifestPath)) {
45
+ return null;
46
+ }
47
+ const manifest = readJsonFile(manifestPath, null);
48
+ const installPath = manifest?.claude_plugin?.install_path;
49
+ return typeof installPath === 'string' && installPath !== '' ? path.resolve(installPath) : null;
50
+ }
51
+
52
+ function claudePluginCandidates(configDir, options = {}) {
53
+ const candidates = [];
54
+ const add = (label, pluginDir) => {
55
+ if (typeof pluginDir !== 'string' || pluginDir === '') return;
56
+ const resolved = path.resolve(pluginDir);
57
+ if (candidates.some((candidate) => candidate.pluginDir === resolved)) return;
58
+ candidates.push({ label, pluginDir: resolved });
59
+ };
60
+
61
+ if (options.pluginDir) {
62
+ add('explicit', options.pluginDir);
63
+ }
64
+ if (configDir) {
65
+ add('installed-plugin', claudePluginDirFromInstallManifest(path.resolve(configDir)));
66
+ }
67
+ if (options.includePackageFallback !== false) {
68
+ add('package-plugin', localClaudePluginDir());
69
+ }
70
+ if (configDir) {
71
+ add('local-claude-agents', path.resolve(configDir));
72
+ }
73
+ return candidates;
74
+ }
75
+
76
+ function claudeAgentStatus(configDir, options = {}) {
77
+ const candidates = claudePluginCandidates(configDir, options);
78
+ for (const candidate of candidates) {
79
+ const agentDir = path.join(candidate.pluginDir, 'agents');
80
+ const missing = missingClaudeAgentFiles(candidate.pluginDir);
81
+ if (missing.length === 0) {
82
+ return {
83
+ status: 'ok',
84
+ source: candidate.label,
85
+ pluginDir: candidate.pluginDir,
86
+ agentDir,
87
+ present: CLAUDE_AGENT_FILES.length,
88
+ missing,
89
+ };
90
+ }
91
+ }
92
+
93
+ const preferred = candidates[0] || { label: 'none', pluginDir: configDir ? path.resolve(configDir) : null };
94
+ const missing = preferred.pluginDir ? missingClaudeAgentFiles(preferred.pluginDir) : [...CLAUDE_AGENT_FILES];
95
+ return {
96
+ status: 'missing',
97
+ source: preferred.label,
98
+ pluginDir: preferred.pluginDir,
99
+ agentDir: preferred.pluginDir ? path.join(preferred.pluginDir, 'agents') : null,
100
+ present: CLAUDE_AGENT_FILES.length - missing.length,
101
+ missing,
102
+ };
103
+ }
104
+
105
+ function missingClaudeAgentFiles(pluginDir) {
106
+ const agentDir = path.join(pluginDir, 'agents');
107
+ return CLAUDE_AGENT_FILES.filter((name) => {
108
+ const filePath = path.join(agentDir, name);
109
+ try {
110
+ return !fs.statSync(filePath).isFile();
111
+ } catch {
112
+ return true;
113
+ }
114
+ });
115
+ }
116
+
117
+ function assertClaudeAgentsAvailable(configDir, options = {}) {
118
+ const status = claudeAgentStatus(configDir, options);
119
+ if (status.status !== 'ok') {
120
+ const base = status.pluginDir || String(configDir || '<unknown>');
121
+ throw new Error(`Claude role-agent dispatch unavailable: missing ${status.missing.join(', ')} under ${base}`);
122
+ }
123
+ return status;
124
+ }
125
+
126
+ module.exports = {
127
+ CLAUDE_AGENT_FILES,
128
+ assertClaudeAgentsAvailable,
129
+ claudeAgentStatus,
130
+ defaultClaudeConfigDir,
131
+ localClaudePluginDir,
132
+ };
package/lib/doctor.cjs CHANGED
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const { readJsonFile } = require('./fs-utils.cjs');
9
+ const { claudeAgentStatus } = require('./claude-agents.cjs');
9
10
  const {
10
11
  CLEAN_ROOM_HOOKS,
11
12
  configPathForRuntime,
@@ -397,6 +398,22 @@ function printOpenCodeCoverage(plugin, hookMode) {
397
398
  console.log(` strict required: ${hookMode === 'strict' ? 'yes' : 'no'}`);
398
399
  }
399
400
 
401
+ function assertClaudeAgentAvailability(layout) {
402
+ const status = claudeAgentStatus(layout.targetRoot, { includePackageFallback: false });
403
+ if (status.status !== 'ok') {
404
+ const base = status.pluginDir || layout.targetRoot;
405
+ throw new Error(`Claude role-agent dispatch unavailable: missing ${status.missing.join(', ')} under ${base}`);
406
+ }
407
+ return status;
408
+ }
409
+
410
+ function printClaudeAgentCoverage(status) {
411
+ console.log('clean-room Claude plugin agent coverage:');
412
+ console.log(` ok agents ${status.present}`);
413
+ console.log(` source: ${status.source}`);
414
+ console.log(` path: ${status.agentDir}`);
415
+ }
416
+
400
417
  function runOpenCodeDoctor(options, layout) {
401
418
  const plugin = assertOpenCodePlugin(layout, options.hookMode);
402
419
  const pathEnv = { PATH: process.env.PATH || '' };
@@ -497,6 +514,10 @@ function runDoctor(argv) {
497
514
  if (options.coverage) {
498
515
  printCoverage(entries, options.hookMode);
499
516
  }
517
+ const claudeAgents = layout.runtime === 'claude' ? assertClaudeAgentAvailability(layout) : null;
518
+ if (options.coverage && claudeAgents) {
519
+ printClaudeAgentCoverage(claudeAgents);
520
+ }
500
521
  if (options.hookMode === 'strict') {
501
522
  assertStrictCoverage(entries);
502
523
  }
@@ -556,8 +577,11 @@ function runDoctor(argv) {
556
577
  console.log(`clean-room doctor passed for ${options.runtime}`);
557
578
  console.log(` hooks config: ${configPath}`);
558
579
  console.log(` managed hooks: ${entries.length}`);
580
+ if (claudeAgents) {
581
+ console.log(` plugin agents: ${claudeAgents.present}`);
582
+ }
559
583
  console.log(` mode: ${options.hookMode}`);
560
- return { configPath, managedHooks: entries.length };
584
+ return { configPath, managedHooks: entries.length, pluginAgents: claudeAgents?.present || 0 };
561
585
  }
562
586
 
563
587
  module.exports = {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
 
5
+ const { claudeAgentStatus } = require('./claude-agents.cjs');
5
6
  const { assertManagedPath, fileHash } = require('./fs-utils.cjs');
6
7
  const {
7
8
  configPathForRuntime,
@@ -98,6 +99,7 @@ function collectRuntimeStatus(runtime, scope, configDir) {
98
99
  hookRegistration: layout.supportsHookRegistration ? 'none' : 'unsupported',
99
100
  updateAvailable: false,
100
101
  claudePlugin: null,
102
+ claudeAgents: null,
101
103
  issues: [],
102
104
  };
103
105
 
@@ -167,11 +169,18 @@ function collectRuntimeStatus(runtime, scope, configDir) {
167
169
  if (layout.supportsHookRegistration && hooksMode !== 'copy-only' && hookState !== 'present') {
168
170
  issues.push('managed hook registration missing');
169
171
  }
172
+ const claudeAgents = runtime === 'claude'
173
+ ? claudeAgentStatus(layout.targetRoot, { includePackageFallback: false })
174
+ : null;
175
+ if (claudeAgents && claudeAgents.status !== 'ok') {
176
+ issues.push(`Claude role-agent dispatch unavailable: missing ${claudeAgents.missing.join(', ')}`);
177
+ }
170
178
 
171
- const updateAvailable = manifest.version !== packageVersion() ||
179
+ const updateAvailable = Boolean(manifest.version !== packageVersion() ||
172
180
  plan.removals.length > 0 ||
173
181
  plan.unknownConflicts.length > 0 ||
174
- fileStats.missing > 0;
182
+ fileStats.missing > 0 ||
183
+ (claudeAgents && claudeAgents.status !== 'ok'));
175
184
 
176
185
  return {
177
186
  ...base,
@@ -188,6 +197,7 @@ function collectRuntimeStatus(runtime, scope, configDir) {
188
197
  hookRegistration: hookState,
189
198
  updateAvailable,
190
199
  claudePlugin: manifest.claude_plugin || null,
200
+ claudeAgents,
191
201
  issues,
192
202
  };
193
203
  }
@@ -243,6 +253,9 @@ function printStatusReport(statuses) {
243
253
  if (status.claudePlugin) {
244
254
  console.log(` plugin: ${status.claudePlugin.plugin_id || CLAUDE_PLUGIN_ID}; marketplace ${status.claudePlugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
245
255
  }
256
+ if (status.claudeAgents) {
257
+ console.log(` plugin agents: ${status.claudeAgents.status}; present ${status.claudeAgents.present}; missing ${status.claudeAgents.missing.length}`);
258
+ }
246
259
  } else if (status.hookRegistration === 'present') {
247
260
  console.log(' hooks: managed hook registration present without install manifest');
248
261
  }
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ const { assertClaudeAgentsAvailable, defaultClaudeConfigDir } = require('./claude-agents.cjs');
6
+ const { resolveClaudeExecutable } = require('./install-claude-plugin.cjs');
7
+ const {
8
+ MANAGER_PREPARE_PHASE,
9
+ REQUIRED_COVERAGE_PHASE,
10
+ ROLE_BY_PHASE,
11
+ } = require('./run-constants.cjs');
12
+ const { resolvePath } = require('./run-roots.cjs');
13
+
14
+ const CLAUDE_PERMISSION_MODE = 'acceptEdits';
15
+
16
+ function buildClaudeAgentCommandConfig(options, roots, cwd = process.cwd()) {
17
+ const agentConfigDir = options.agentConfigDir
18
+ ? resolvePath(options.agentConfigDir, cwd)
19
+ : defaultClaudeConfigDir();
20
+ const agentStatus = assertClaudeAgentsAvailable(agentConfigDir);
21
+ const { executable, searchPath } = resolveClaudeExecutable();
22
+ const env = {
23
+ CLAUDE_CONFIG_DIR: agentConfigDir,
24
+ PATH: searchPath,
25
+ };
26
+ const pluginArgs = agentStatus.source === 'installed-plugin' || agentStatus.source === 'package-plugin'
27
+ ? ['--plugin-dir', agentStatus.pluginDir]
28
+ : [];
29
+
30
+ return {
31
+ configDir: agentConfigDir,
32
+ config: {
33
+ version: 1,
34
+ stages: claudeStages(roots, executable, env, pluginArgs),
35
+ },
36
+ };
37
+ }
38
+
39
+ function claudeStages(roots, executable, env, pluginArgs) {
40
+ const contaminatedCwd = roots.contaminatedRoot;
41
+ const cleanCwd = roots.cleanRoot;
42
+ const implementationCwd = roots.implementationRoots[0] || roots.cleanRoot;
43
+ return [
44
+ claudeStage(MANAGER_PREPARE_PHASE, contaminatedCwd, executable, env, pluginArgs),
45
+ claudeStage('contaminated-analysis', contaminatedCwd, executable, env, pluginArgs),
46
+ claudeStage('sanitize-handoff', contaminatedCwd, executable, env, pluginArgs),
47
+ claudeStage('clean-plan', cleanCwd, executable, env, pluginArgs),
48
+ claudeStage('clean-implement-qc', implementationCwd, executable, env, pluginArgs),
49
+ claudeStage(REQUIRED_COVERAGE_PHASE, contaminatedCwd, executable, env, pluginArgs),
50
+ ];
51
+ }
52
+
53
+ function claudeStage(phase, cwd, executable, env, pluginArgs) {
54
+ const role = ROLE_BY_PHASE[phase];
55
+ return {
56
+ phase,
57
+ role,
58
+ cwd,
59
+ argv: [
60
+ executable,
61
+ '--print',
62
+ '--input-format',
63
+ 'text',
64
+ '--output-format',
65
+ 'text',
66
+ '--no-session-persistence',
67
+ '--permission-mode',
68
+ CLAUDE_PERMISSION_MODE,
69
+ '--agent',
70
+ `clean-room:${role}`,
71
+ ...pluginArgs,
72
+ ],
73
+ env,
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ buildClaudeAgentCommandConfig,
79
+ };
package/lib/run-cli.cjs CHANGED
@@ -1,13 +1,18 @@
1
1
  'use strict';
2
2
 
3
+ const AGENT_RUNTIMES = new Set(['claude']);
4
+
3
5
  function printRunHelp() {
4
- console.log(`Usage: clean-room-skill run --task-manifest <path> --agent-commands <path> [options]
6
+ console.log(`Usage: clean-room-skill run --task-manifest <path> (--agent-commands <path> | --agent-runtime claude) [options]
5
7
 
6
8
  Run one bounded inner clean-room controller loop for an approved spec slice.
7
9
 
8
10
  Options:
9
11
  --task-manifest <path> Required task-manifest.json path
10
- --agent-commands <path> Required role command adapter JSON unless --dry-run is set
12
+ --agent-commands <path> Role command adapter JSON unless --agent-runtime or --dry-run is set
13
+ --agent-runtime <name> Built-in role agent runtime; currently supports claude
14
+ --agent-config-dir <path>
15
+ Runtime config dir for --agent-runtime claude
11
16
  --max-iterations <n> Lower the manifest/loop iteration cap
12
17
  --once Run at most one inner iteration
13
18
  --dry-run Validate and print the selected unit without writing or spawning agents
@@ -21,6 +26,8 @@ function parseRunArgs(argv) {
21
26
  const options = {
22
27
  taskManifest: null,
23
28
  agentCommands: null,
29
+ agentRuntime: null,
30
+ agentConfigDir: null,
24
31
  maxIterations: null,
25
32
  once: false,
26
33
  dryRun: false,
@@ -47,6 +54,16 @@ function parseRunArgs(argv) {
47
54
  options.agentCommands = requiredValue(argv, index, '--agent-commands');
48
55
  } else if (arg.startsWith('--agent-commands=')) {
49
56
  options.agentCommands = arg.slice('--agent-commands='.length);
57
+ } else if (arg === '--agent-runtime') {
58
+ index += 1;
59
+ options.agentRuntime = parseAgentRuntime(requiredValue(argv, index, '--agent-runtime'));
60
+ } else if (arg.startsWith('--agent-runtime=')) {
61
+ options.agentRuntime = parseAgentRuntime(arg.slice('--agent-runtime='.length));
62
+ } else if (arg === '--agent-config-dir') {
63
+ index += 1;
64
+ options.agentConfigDir = requiredValue(argv, index, '--agent-config-dir');
65
+ } else if (arg.startsWith('--agent-config-dir=')) {
66
+ options.agentConfigDir = arg.slice('--agent-config-dir='.length);
50
67
  } else if (arg === '--max-iterations') {
51
68
  index += 1;
52
69
  options.maxIterations = parsePositiveInteger(requiredValue(argv, index, '--max-iterations'), '--max-iterations');
@@ -70,6 +87,13 @@ function parseRunArgs(argv) {
70
87
  return options;
71
88
  }
72
89
 
90
+ function parseAgentRuntime(value) {
91
+ if (!AGENT_RUNTIMES.has(value)) {
92
+ throw new Error('--agent-runtime must be claude');
93
+ }
94
+ return value;
95
+ }
96
+
73
97
  function requiredValue(argv, index, flag) {
74
98
  if (index >= argv.length || argv[index] === '') {
75
99
  throw new Error(`${flag} requires a value`);
@@ -85,6 +109,7 @@ function parsePositiveInteger(value, flag) {
85
109
  }
86
110
 
87
111
  module.exports = {
112
+ AGENT_RUNTIMES,
88
113
  parseRunArgs,
89
114
  printRunHelp,
90
115
  };
@@ -13,6 +13,7 @@ const STATUS_NAME = 'controller-status.json';
13
13
  const CLEAN_RUN_CONTEXT_NAME = 'clean-run-context.json';
14
14
  const HANDOFF_PACKAGE_NAME = 'handoff-package.json';
15
15
  const POLISH_REPORT_NAME = 'polish-report.json';
16
+ const MANAGER_PREPARE_PHASE = 'contaminated-manager-prepare';
16
17
  const REQUIRED_COVERAGE_PHASE = 'contaminated-coverage-verify';
17
18
  const POLISH_PHASE = 'clean-polish-review';
18
19
  const PUBLIC_SURFACE_COMPLETION_LEVELS = new Set(['exact-public-contract', 'behavior-compatible']);
@@ -65,6 +66,7 @@ const HOOK_ONLY_ENV_ALLOWLIST = Object.freeze([
65
66
  ]);
66
67
 
67
68
  const ROLE_BY_PHASE = Object.freeze({
69
+ [MANAGER_PREPARE_PHASE]: 'contaminated-manager-verifier',
68
70
  'contaminated-analysis': 'contaminated-source-analyst',
69
71
  'sanitize-handoff': 'contaminated-handoff-sanitizer',
70
72
  'clean-plan': 'clean-architect',
@@ -153,6 +155,7 @@ module.exports = {
153
155
  MAX_LEDGER_ITERATIONS,
154
156
  MAX_OUTPUT_BYTES,
155
157
  MAX_TIMEOUT_MS,
158
+ MANAGER_PREPARE_PHASE,
156
159
  POLISH_PHASE,
157
160
  POLISH_REPORT_NAME,
158
161
  PUBLIC_SURFACE_COMPLETION_LEVELS,
@@ -24,6 +24,7 @@ const {
24
24
  validateArtifacts,
25
25
  validateTaskManifestSchema,
26
26
  } = require('./run-hooks.cjs');
27
+ const { buildClaudeAgentCommandConfig } = require('./run-claude-agent-runtime.cjs');
27
28
  const {
28
29
  effectiveIterationCap,
29
30
  validateTaskManifestForRun,
@@ -70,6 +71,70 @@ function repeatedUnitSelection(previous, selectedUnit) {
70
71
  return previous?.unit_id === selectedUnit.unit_id && previous?.stop_reason === 'no-progress-detected';
71
72
  }
72
73
 
74
+ function validateRunState(options, taskManifestPath, roots, manifest, coverageLedgerPath) {
75
+ validateImplementationArtifactPlacement(roots);
76
+ validateArtifacts(options.python, taskManifestPath, roots);
77
+ validateCleanRunContextReferences(options.python, roots);
78
+ const coverageLedger = readOptionalJson(coverageLedgerPath);
79
+ validateCoverageLedgerIntegrity(manifest, roots, coverageLedger);
80
+ validateFoundationCoverageGate(manifest, coverageLedger);
81
+ return coverageLedger;
82
+ }
83
+
84
+ function rootListEqual(left, right) {
85
+ return left.length === right.length && left.every((value, index) => value === right[index]);
86
+ }
87
+
88
+ function assertStableRunRoots(initialRoots, currentRoots) {
89
+ if (
90
+ !rootListEqual(initialRoots.sourceRoots, currentRoots.sourceRoots) ||
91
+ initialRoots.contaminatedRoot !== currentRoots.contaminatedRoot ||
92
+ initialRoots.cleanRoot !== currentRoots.cleanRoot ||
93
+ !rootListEqual(initialRoots.implementationRoots, currentRoots.implementationRoots) ||
94
+ !rootListEqual(initialRoots.allowedReadRoots, currentRoots.allowedReadRoots) ||
95
+ initialRoots.schemaDir !== currentRoots.schemaDir
96
+ ) {
97
+ throw new Error('task manifest root drift detected during unattended run');
98
+ }
99
+ }
100
+
101
+ function reloadManifestForIteration(options, taskManifestPath, manifestDir, roots, schemaDir) {
102
+ validateTaskManifestSchema(options.python, taskManifestPath, schemaDir);
103
+ const currentManifest = readJsonFile(taskManifestPath, null);
104
+ validateTaskManifestForRun(currentManifest);
105
+ const currentRoots = resolveRoots(currentManifest, manifestDir, schemaDir);
106
+ assertStableRunRoots(roots, currentRoots);
107
+ validateTaskManifestLocation(taskManifestPath, currentRoots);
108
+ verifyPreflightGoal(currentManifest, manifestDir, currentRoots);
109
+ return currentManifest;
110
+ }
111
+
112
+ function resolveAgentConfig(options, context, roots, manifest, agentConfigPath) {
113
+ if (options.agentCommands && options.agentRuntime) {
114
+ throw new Error('--agent-runtime cannot be used with --agent-commands');
115
+ }
116
+ if (!options.agentCommands && !options.agentRuntime) {
117
+ return { agentConfig: null, configDir: process.cwd() };
118
+ }
119
+ if (options.agentRuntime === 'claude') {
120
+ const builtIn = buildClaudeAgentCommandConfig(options, roots, context.cwd || process.cwd());
121
+ validateCommandConfig(builtIn.config, {
122
+ roots,
123
+ configDir: builtIn.configDir,
124
+ contextManagement: manifest.context_management,
125
+ });
126
+ return { agentConfig: builtIn.config, configDir: builtIn.configDir };
127
+ }
128
+ const agentConfig = readJsonFile(agentConfigPath, null);
129
+ const configDir = path.dirname(agentConfigPath);
130
+ validateCommandConfig(agentConfig, { roots, configDir, contextManagement: manifest.context_management });
131
+ return { agentConfig, configDir };
132
+ }
133
+
134
+ function shouldContinueAfterUnitComplete(manifest, coverageLedger) {
135
+ return Boolean(selectUnit(manifest, coverageLedger));
136
+ }
137
+
73
138
  async function runCleanRoom(options, context = {}) {
74
139
  if (options.help) {
75
140
  printRunHelp();
@@ -78,8 +143,11 @@ async function runCleanRoom(options, context = {}) {
78
143
  if (!options.taskManifest) {
79
144
  throw new Error('--task-manifest is required');
80
145
  }
81
- if (!options.dryRun && !options.agentCommands) {
82
- throw new Error('--agent-commands is required unless --dry-run is set');
146
+ if (!options.dryRun && !options.agentCommands && !options.agentRuntime) {
147
+ throw new Error('--agent-commands or --agent-runtime is required unless --dry-run is set');
148
+ }
149
+ if (options.agentCommands && options.agentRuntime) {
150
+ throw new Error('--agent-runtime cannot be used with --agent-commands');
83
151
  }
84
152
 
85
153
  const taskManifestPath = resolvePath(options.taskManifest, context.cwd || process.cwd());
@@ -96,20 +164,13 @@ async function runCleanRoom(options, context = {}) {
96
164
  verifyPreflightGoal(manifest, manifestDir, roots);
97
165
  const cap = effectiveIterationCap(manifest, options);
98
166
  const agentConfigPath = options.agentCommands ? resolvePath(options.agentCommands, context.cwd || process.cwd()) : null;
99
- const agentConfig = agentConfigPath ? readJsonFile(agentConfigPath, null) : null;
100
- const configDir = agentConfigPath ? path.dirname(agentConfigPath) : process.cwd();
101
- if (agentConfig) {
102
- validateCommandConfig(agentConfig, { roots, configDir, contextManagement: manifest.context_management });
103
- }
167
+ const { agentConfig, configDir } = options.dryRun
168
+ ? { agentConfig: null, configDir: process.cwd() }
169
+ : resolveAgentConfig(options, context, roots, manifest, agentConfigPath);
104
170
 
105
171
  return withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
106
172
  const coverageLedgerPath = path.join(roots.contaminatedRoot, 'coverage-ledger.json');
107
- validateImplementationArtifactPlacement(roots);
108
- validateArtifacts(options.python, taskManifestPath, roots);
109
- validateCleanRunContextReferences(options.python, roots);
110
- const coverageLedger = readOptionalJson(coverageLedgerPath);
111
- validateCoverageLedgerIntegrity(manifest, roots, coverageLedger);
112
- validateFoundationCoverageGate(manifest, coverageLedger);
173
+ const coverageLedger = validateRunState(options, taskManifestPath, roots, manifest, coverageLedgerPath);
113
174
  const selectedUnit = selectUnit(manifest, coverageLedger);
114
175
  if (!selectedUnit) {
115
176
  const result = completeResultOrSpecDelta(manifest, roots, coverageLedger);
@@ -121,28 +182,6 @@ async function runCleanRoom(options, context = {}) {
121
182
  const ledgerPath = path.join(roots.contaminatedRoot, LEDGER_NAME);
122
183
  const resultPath = path.join(roots.contaminatedRoot, RESULT_NAME);
123
184
  const ledger = loadLedger(ledgerPath, manifest);
124
- const previous = previousIteration(ledger);
125
- if (repeatedUnitSelection(previous, selectedUnit)) {
126
- const result = buildResult(manifest, 'no-progress-detected', 'partial', null, null, [
127
- {
128
- kind: 'other',
129
- summary: 'The same unit was selected again after a no-progress iteration.',
130
- status: 'open',
131
- },
132
- ]);
133
- if (!options.dryRun) {
134
- writeResult(resultPath, result);
135
- ledger.iterations.push({
136
- iteration: ledger.iterations.length + 1,
137
- unit_id: selectedUnit.unit_id,
138
- stop_reason: 'repeated-unit-selection',
139
- phases: [],
140
- });
141
- writeLedger(ledgerPath, ledger);
142
- }
143
- console.log('clean-room run: repeated-unit-selection');
144
- return result;
145
- }
146
185
 
147
186
  if (options.dryRun) {
148
187
  console.log(`clean-room run dry-run: selected ${selectedUnit.unit_id}`);
@@ -156,10 +195,39 @@ async function runCleanRoom(options, context = {}) {
156
195
  }
157
196
 
158
197
  let terminalResult = null;
198
+ let resultManifest = manifest;
159
199
  const polishRequired = agentConfig.stages.some((stage) => stage.phase === POLISH_PHASE);
160
- const strictContext = strictContextManagement(manifest.context_management);
161
200
  for (let offset = 0; offset < cap; offset += 1) {
162
- const iteration = (manifest.loop_context.inner_iteration || 0) + offset + 1;
201
+ const currentManifest = reloadManifestForIteration(options, taskManifestPath, manifestDir, roots, schemaDir);
202
+ resultManifest = currentManifest;
203
+ const strictContext = strictContextManagement(currentManifest.context_management);
204
+ const currentCoverageLedger = validateRunState(options, taskManifestPath, roots, currentManifest, coverageLedgerPath);
205
+ const selected = selectUnit(currentManifest, currentCoverageLedger);
206
+ if (!selected) {
207
+ terminalResult = completeResultOrSpecDelta(currentManifest, roots, currentCoverageLedger);
208
+ break;
209
+ }
210
+ const previous = previousIteration(ledger);
211
+ if (repeatedUnitSelection(previous, selected)) {
212
+ terminalResult = buildResult(currentManifest, 'no-progress-detected', 'partial', null, null, [
213
+ {
214
+ kind: 'other',
215
+ summary: 'The same unit was selected again after a no-progress iteration.',
216
+ status: 'open',
217
+ },
218
+ ]);
219
+ ledger.iterations.push({
220
+ iteration: ledger.iterations.length + 1,
221
+ unit_id: selected.unit_id,
222
+ stop_reason: 'repeated-unit-selection',
223
+ phases: [],
224
+ });
225
+ writeLedger(ledgerPath, ledger);
226
+ console.log('clean-room run: repeated-unit-selection');
227
+ break;
228
+ }
229
+
230
+ const iteration = (currentManifest.loop_context.inner_iteration || 0) + offset + 1;
163
231
  const before = semanticProgressSnapshot(taskManifestPath, roots);
164
232
  const phaseResults = [];
165
233
  let coveragePhaseRan = false;
@@ -172,19 +240,19 @@ async function runCleanRoom(options, context = {}) {
172
240
  stage,
173
241
  configDir,
174
242
  roots,
175
- manifest,
176
- selectedUnit,
243
+ currentManifest,
244
+ selected,
177
245
  strictContext
178
246
  );
179
- const stageResult = runStage(stage, configDir, roots, manifest, selectedUnit, iteration, sessionContext);
247
+ const stageResult = runStage(stage, configDir, roots, currentManifest, selected, iteration, sessionContext);
180
248
  const afterStage = artifactSnapshot(taskManifestPath, roots);
181
249
  phaseResults.push(stageResult);
182
250
  validateImplementationArtifactPlacement(roots);
183
251
  validateArtifacts(options.python, taskManifestPath, roots, changedSnapshotPaths(beforeStage, afterStage));
184
252
  validateCleanRunContextReferences(options.python, roots);
185
253
  const stageCoverageLedger = readOptionalJson(coverageLedgerPath);
186
- validateCoverageLedgerIntegrity(manifest, roots, stageCoverageLedger);
187
- validateFoundationCoverageGate(manifest, stageCoverageLedger);
254
+ validateCoverageLedgerIntegrity(currentManifest, roots, stageCoverageLedger);
255
+ validateFoundationCoverageGate(currentManifest, stageCoverageLedger);
188
256
  if (stage.phase === REQUIRED_COVERAGE_PHASE && stageResult.status === 'passed') {
189
257
  coveragePhaseRan = true;
190
258
  }
@@ -198,25 +266,37 @@ async function runCleanRoom(options, context = {}) {
198
266
  const progressDetected = !snapshotsEqual(before, after);
199
267
  const ledgerEntry = {
200
268
  iteration,
201
- unit_id: selectedUnit.unit_id,
202
- spec_slice_ref: manifest.loop_context.spec_slice_ref,
269
+ unit_id: selected.unit_id,
270
+ spec_slice_ref: currentManifest.loop_context.spec_slice_ref,
203
271
  phases: phaseResults,
204
272
  progress_detected: progressDetected,
205
273
  };
206
274
 
207
275
  if (failedStage) {
208
- terminalResult = stageFailureResult(manifest, failedStage);
276
+ terminalResult = stageFailureResult(currentManifest, failedStage);
209
277
  ledgerEntry.stop_reason = 'spec-slice-blocked';
210
278
  } else if (!progressDetected) {
211
- terminalResult = noProgressResult(manifest);
279
+ terminalResult = noProgressResult(currentManifest);
212
280
  ledgerEntry.stop_reason = 'no-progress-detected';
213
281
  } else if (coveragePhaseRan) {
214
- terminalResult = inferTerminalResult(manifest, roots, selectedUnit, {
282
+ terminalResult = inferTerminalResult(currentManifest, roots, selected, {
215
283
  polishRequired,
216
284
  observedChangedPaths: changedImplementationPaths(before, after),
217
285
  });
218
286
  if (terminalResult) {
219
- ledgerEntry.stop_reason = terminalResult.result;
287
+ if (terminalResult.result === 'spec-slice-complete') {
288
+ const latestCoverageLedger = readOptionalJson(coverageLedgerPath);
289
+ validateCoverageLedgerIntegrity(currentManifest, roots, latestCoverageLedger);
290
+ validateFoundationCoverageGate(currentManifest, latestCoverageLedger);
291
+ if (shouldContinueAfterUnitComplete(currentManifest, latestCoverageLedger)) {
292
+ ledgerEntry.stop_reason = 'unit-complete';
293
+ terminalResult = null;
294
+ } else {
295
+ ledgerEntry.stop_reason = terminalResult.result;
296
+ }
297
+ } else {
298
+ ledgerEntry.stop_reason = terminalResult.result;
299
+ }
220
300
  }
221
301
  }
222
302
 
@@ -228,15 +308,15 @@ async function runCleanRoom(options, context = {}) {
228
308
  }
229
309
 
230
310
  if (!terminalResult) {
231
- terminalResult = iterationLimitResult(manifest);
311
+ terminalResult = iterationLimitResult(resultManifest);
232
312
  }
233
313
  writeResult(resultPath, terminalResult);
234
314
  validateImplementationArtifactPlacement(roots);
235
315
  validateArtifacts(options.python, taskManifestPath, roots);
236
316
  validateCleanRunContextReferences(options.python, roots);
237
317
  const finalCoverageLedger = readOptionalJson(coverageLedgerPath);
238
- validateCoverageLedgerIntegrity(manifest, roots, finalCoverageLedger);
239
- validateFoundationCoverageGate(manifest, finalCoverageLedger);
318
+ validateCoverageLedgerIntegrity(resultManifest, roots, finalCoverageLedger);
319
+ validateFoundationCoverageGate(resultManifest, finalCoverageLedger);
240
320
  console.log(`clean-room run: ${terminalResult.result}`);
241
321
  return terminalResult;
242
322
  });
@@ -8,6 +8,7 @@ const { spawnSync } = require('node:child_process');
8
8
  const { fileHash } = require('./fs-utils.cjs');
9
9
  const {
10
10
  DEFAULT_TIMEOUT_MS,
11
+ MANAGER_PREPARE_PHASE,
11
12
  MAX_OUTPUT_BYTES,
12
13
  MAX_TIMEOUT_MS,
13
14
  POLISH_PHASE,
@@ -60,7 +61,7 @@ function validateStageBoundaries(stage, index, context) {
60
61
  }
61
62
 
62
63
  let allowed = false;
63
- if (stage.phase === 'contaminated-analysis' || stage.phase === 'contaminated-coverage-verify') {
64
+ if (stage.phase === MANAGER_PREPARE_PHASE || stage.phase === 'contaminated-analysis' || stage.phase === 'contaminated-coverage-verify') {
64
65
  allowed = pathIsUnder(cwd, roots.contaminatedRoot) || pathIsUnder(cwd, configDir);
65
66
  } else if (stage.phase === 'sanitize-handoff') {
66
67
  allowed = pathIsUnder(cwd, roots.contaminatedRoot);
@@ -114,7 +115,7 @@ function resolveStageBriefPath(stage, configDir, roots) {
114
115
  function validateStageContext(stage, index, context = {}) {
115
116
  const strict = strictContextManagement(context.contextManagement);
116
117
  if (stage.context === undefined) {
117
- if (strict) {
118
+ if (strict && stage.phase !== MANAGER_PREPARE_PHASE) {
118
119
  throw new Error(`agent command stage ${index} must provide context in strict context-management mode`);
119
120
  }
120
121
  return;
@@ -134,10 +135,10 @@ function validateStageContext(stage, index, context = {}) {
134
135
  if (stage.context.brief_path !== undefined && (typeof stage.context.brief_path !== 'string' || stage.context.brief_path === '')) {
135
136
  throw new Error(`agent command stage ${index} context.brief_path must be a non-empty string`);
136
137
  }
137
- if (strict && stage.context.fresh_session !== true) {
138
+ if (strict && stage.phase !== MANAGER_PREPARE_PHASE && stage.context.fresh_session !== true) {
138
139
  throw new Error(`agent command stage ${index} context.fresh_session must be true in strict context-management mode`);
139
140
  }
140
- if (strict && !stage.context.brief_path) {
141
+ if (strict && stage.phase !== MANAGER_PREPARE_PHASE && !stage.context.brief_path) {
141
142
  throw new Error(`agent command stage ${index} context.brief_path is required in strict context-management mode`);
142
143
  }
143
144
  if (stage.context.brief_path && context.roots && context.configDir) {
@@ -330,6 +331,10 @@ function stagePrompt(stage, manifest, unit, iteration, sessionContext = null) {
330
331
  '',
331
332
  'Run only this configured clean-room stage from durable artifacts.',
332
333
  'Do not use prior chat history as state.',
334
+ ...(stage.phase === MANAGER_PREPARE_PHASE ? [
335
+ 'Act only as Agent 0 manager/controller for this selected unit.',
336
+ 'Prepare durable controller status or role-session briefs as needed, then return. Do not perform downstream role work.',
337
+ ] : []),
333
338
  ...(sessionContext ? ['Read CLEAN_ROOM_SESSION_BRIEF_PATH first and load only the artifact refs it permits.'] : []),
334
339
  '',
335
340
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room-skill",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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.14",
3
+ "version": "0.1.15",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -54,6 +54,8 @@ Optional AST/indexing helpers are detected before the controller loop through `s
54
54
 
55
55
  Controller mode defaults to `attended` when `task-manifest.json` has no `controller_policy`. The outer loop evolves specs and selects one approved spec slice. Code-development runs start with exactly one `unit_kind: "foundation"` unit named by `loop_context.foundation_unit_ref`; non-foundation behavior slices wait until that unit is covered. The inner clean-room loop completes the approved slice through sanitized handoff, implementation, QC, optional final polish review, and contaminated-side coverage verification, then returns `clean-room-result.json` to the outer loop. In `attended` mode, agent zero pauses for human review at scope gate, handoff, QC deltas, polish deltas, blocked units, and final coverage. In `unattended` mode, agent zero may run a bounded inner loop: reload durable artifacts for each iteration, select at most one pending or gap unit inside `loop_context.approved_scope_refs`, start each role from fresh context with the required environment block, validate before advancing, and stop on any configured safety or ambiguity condition.
56
56
 
57
+ In Claude Code unattended mode, launch the durable runner with `clean-room-skill run --task-manifest <path> --agent-runtime claude` when possible. The main conversation must not do Agent 1, Agent 2, Agent 3, or Agent 4 work, and must not ask to continue while unattended policy still allows bounded progress. If role-agent dispatch is unavailable, fail closed with a blocker.
58
+
57
59
  Do not grant shell-style tools to Agent 0, Agent 1, Agent 1.5, Agent 2, or the default Agent 3/4 role sessions. Agent 3 terminal verification may use shell-style tools only when `CLEAN_ROOM_ALLOW_AGENT3_SHELL=1`, the command cwd is under `CLEAN_ROOM_IMPLEMENTATION_ROOTS`, and the command invokes the installed `agent3-verification-runner.py`. Agent 4 polish verification and commit may use shell-style tools only when `CLEAN_ROOM_ALLOW_AGENT4_SHELL=1`, cwd is under `CLEAN_ROOM_IMPLEMENTATION_ROOTS`, and the command invokes the installed `agent4-polish-runner.py`. Use `--hooks=strict` for dedicated Codex, Claude, or OpenCode clean-room homes so hooks fail closed if required environment is missing or shell tools are invoked outside the allowed runner boundaries. Safe hook installs are compatibility-only between runs; during init/onboarding, prepare the role environment block and pass it into every clean-room role session so safe hooks enforce during active work.
58
60
 
59
61
  Post-write hook failures are policy failures, not implementation guidance. If a clean or staged artifact cannot be read, scanned, schema-checked, or hashed because the filesystem changed, report the controlled redacted failure and ask the controller/user to restore readable artifact state before retrying.
@@ -11,6 +11,8 @@ Resume an existing clean-room run from durable artifacts. Never use prior chat h
11
11
 
12
12
  Use the canonical `clean-room` skill workflow and references in this plugin. Read `skills/clean-room/references/CONTROLLER-LOOP.md` when the manifest records `loop_context` or unattended mode. Preserve the same clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
13
13
 
14
+ If `task-manifest.json` records `controller_policy.mode: "unattended"` in Claude Code, prefer launching `clean-room-skill run --task-manifest <path> --agent-runtime claude` and let the durable runner assign role agents. The main conversation must not perform Agent 1, Agent 2, Agent 3, or Agent 4 work. Do not ask to continue while unattended policy, iteration budget, and approved pending or gap units still permit progress. If the runner or Claude role-agent dispatch is unavailable, stop with `BLOCKERS: Claude role-agent dispatch unavailable` rather than silently continuing in the main chat.
15
+
14
16
  ## Load Order
15
17
 
16
18
  Load these artifacts from the paths recorded in `task-manifest.json` and the configured root environment. Treat missing optional artifacts as blockers only when the current gate requires them.
@@ -15,6 +15,8 @@ Use the canonical `clean-room` skill workflow and references in this plugin. Rea
15
15
 
16
16
  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-cr`. 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.
17
17
 
18
+ When resuming a valid unattended `task-manifest.json` in Claude Code, prefer launching the durable runner with `clean-room-skill run --task-manifest <path> --agent-runtime claude`. The main conversation must not perform Agent 1, Agent 2, Agent 3, or Agent 4 work. Do not ask to continue while `controller_policy.mode` is `unattended`, the iteration budget remains, and approved pending or gap units remain. If Claude role-agent dispatch or the runner is unavailable, stop with `BLOCKERS: Claude role-agent dispatch unavailable` instead of falling back to main-chat execution.
19
+
18
20
  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`.
19
21
 
20
22
  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.