clean-room-skill 0.2.3 → 0.3.0

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.2.3",
12
+ "version": "0.3.0",
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.2.3",
5
+ "version": "0.3.0",
6
6
  "author": {
7
7
  "name": "whit3rabbit"
8
8
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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
@@ -109,6 +109,8 @@ npx clean-room-skill@latest init
109
109
 
110
110
  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.
111
111
 
112
+ When multiple tasks target the same destination, group them under a clean-room project with `init --project <name>` (or `--new-project` for a generated name). The project layout is `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with per-task `contaminated/`, `clean/`, and `quarantine/` children plus one shared `~/Documents/CleanRoom/<project>/implementation/` root for every task in the project. Project names must stay neutral, like task IDs: a random word pair such as `amber-meadow` or a generated `proj-xxxxxxxx`, never derived from source folder names. Run at most one active task per project at a time because tasks share the implementation root; `clean-room-skill run` enforces this with an advisory `.clean-room-implementation.lock` in each implementation root.
113
+
112
114
  In Claude Code, invoke skills with the plugin namespace:
113
115
 
114
116
  ```text
@@ -33,6 +33,8 @@ Optional Docker or Podman support is limited to Agent 3 verification containers.
33
33
 
34
34
  Artifact roots must not disclose private source names. New runs default to `~/Documents/CleanRoom/<task-id>/`; when no explicitly approved neutral task ID is provided, the controller generates `task-` plus 8 lowercase hex characters instead of using the source folder name.
35
35
 
36
+ Multiple tasks targeting the same destination may be grouped under an optional clean-room project: `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with one shared `<project>/implementation/` root. Project names follow the same neutrality rule as task IDs: a random neutral word pair (such as `amber-meadow`) or a generated `proj-` plus 8 lowercase hex characters, matching `[a-z0-9][a-z0-9-]{0,63}` and never derived from source folder names. Because the implementation root is shared, run at most one active task per project at a time; `clean-room-skill run` enforces this with an advisory `.clean-room-implementation.lock` in each implementation root.
37
+
36
38
  ![Artifact Roots](assets/2.png)
37
39
 
38
40
  The initialization wizard and `require-clean-room-env.py` audit clean, implementation, and contaminated artifact root names. They fail closed when a path contains a source root basename or meaningful non-generic tokens from that basename, while filtering generic terms such as `src`, `app`, `test`, `repo`, and `workspace`.
package/docs/REFERENCE.md CHANGED
@@ -164,6 +164,7 @@ Usage:
164
164
  npx clean-room-skill@latest init
165
165
  npx clean-room-skill@latest init --target-dir . --target-profile speckit-feature-folder
166
166
  npx clean-room-skill@latest init --artifact-base ~/Documents/CleanRoom --task-id task-1234abcd
167
+ npx clean-room-skill@latest init --project amber-meadow --task-id task-1234abcd
167
168
  ```
168
169
 
169
170
  Options:
@@ -173,11 +174,13 @@ Options:
173
174
  | `--target-dir <path>` | Repository to initialize; default is current directory. |
174
175
  | `--artifact-base <path>` | External CleanRoom base; default is `~/Documents/CleanRoom`. |
175
176
  | `--task-id <id>` | Neutral task id; default is generated `task-xxxxxxxx`. |
177
+ | `--project <name>` | Group the task under a clean-room project; joins the project when it already exists. Names must be neutral (`[a-z0-9][a-z0-9-]{0,63}`, never derived from source or workspace folder names). |
178
+ | `--new-project` | Create a project with a generated `proj-xxxxxxxx` name. Cannot be combined with `--project`. |
176
179
  | `--target-profile <name>` | `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`. |
177
180
  | `--dry-run` | Print actions without writing files. |
178
181
  | `--force` | Overwrite existing bootstrap metadata and repo stub. |
179
182
 
180
- By default, `init` creates:
183
+ By default, `init` creates a single-task layout under `<artifact-base>/<task-id>/`:
181
184
 
182
185
  - `contaminated/`
183
186
  - `clean/`
@@ -186,6 +189,22 @@ By default, `init` creates:
186
189
  - `clean-room-bootstrap.json`
187
190
  - `.clean-room/README.md` in the target repository
188
191
 
192
+ With `--project` or `--new-project`, `init` creates a project layout instead. Tasks live under `<artifact-base>/<project>/tasks/<task-id>/` with per-task `contaminated/`, `clean/`, and `quarantine/`, while every task in the project shares one `<artifact-base>/<project>/implementation/` root:
193
+
194
+ ```text
195
+ <artifact-base>/<project>/
196
+ ├── clean-room-project.json
197
+ ├── implementation/ (shared by all tasks)
198
+ └── tasks/
199
+ └── <task-id>/
200
+ ├── contaminated/
201
+ ├── clean/
202
+ ├── quarantine/
203
+ └── clean-room-bootstrap.json
204
+ ```
205
+
206
+ Re-running `init --project <name>` with a new task id joins the existing project without `--force`: the project metadata and shared `implementation/` are reused, and only the new task folders must not already exist. Because tasks share the implementation root, run at most one active task per project at a time; `clean-room-skill run` enforces this with an advisory `.clean-room-implementation.lock` in each implementation root.
207
+
189
208
  Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, `preflight-goal.json`, `init-config.json`, `task-manifest.json`, `controller-status.json`, `role-session-brief.json`, or `clean-run-context.json` into the clean implementation repository.
190
209
 
191
210
  ## Preflight CLI
@@ -198,6 +217,7 @@ Usage:
198
217
  npx clean-room-skill@latest preflight --template --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
199
218
  npx clean-room-skill@latest preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
200
219
  npx clean-room-skill@latest preflight --template --bootstrap ~/Documents/CleanRoom/task-1234abcd
220
+ npx clean-room-skill@latest preflight --template --bootstrap ~/Documents/CleanRoom/amber-meadow/tasks/task-1234abcd
201
221
  ```
202
222
 
203
223
  Options:
@@ -358,11 +378,13 @@ The runner exports `CLEAN_ROOM_SESSION_BRIEF_PATH`, `CLEAN_ROOM_ROLE_SESSION_ID`
358
378
  | `python3 is required to install clean-room hooks` | Python missing or not on `PATH` | Install Python 3 or use `--hooks=copy-only`. |
359
379
  | `safe hooks are installed; clean-room init/onboarding must set role environment variables` | Safe mode default | Start the clean-room init/onboarding flow, or use strict hooks in a dedicated profile. |
360
380
  | `install lock is held` | Another install or uninstall is mutating the same target root | Wait for the other process to finish; stale locks are handled conservatively. |
381
+ | `clean-room run lock is held` | Another run is active for the same task's contaminated root, or a crashed run left a recent lock | Wait for the other run to finish. Locks from dead processes age out and are moved aside automatically; if no run is active, remove `.clean-room-run.lock` from the contaminated root and retry. |
382
+ | `clean-room implementation lock is held` | Another task in the same clean-room project is running against the shared implementation root, or a crashed run left a recent lock | Wait for the other run to finish. Locks from dead processes age out and are moved aside automatically; if no run is active, remove `.clean-room-implementation.lock` from the implementation root and retry. |
361
383
  | Hook config write failed after files copied | Partial installer state | Fix the filesystem error, then re-run the same installer command. |
362
384
  | Install manifest remains `installing` | The previous install did not complete | Re-run the same installer command for that runtime and target root. |
363
385
  | `clean-room-skill` is not found | The CLI is not globally installed or the runtime PATH does not include it | Use `npx clean-room-skill@latest ...` immediately; do not search plugin caches for package internals. |
364
386
  | `--schema-dir` reports missing schemas | The override points at a stale plugin cache, clean root, or non-directory path | Omit `--schema-dir` to use bundled schemas. Pass it only for a real directory containing `task-manifest.schema.json`. Do not use `/dev/null`. |
365
- | `task manifest not found` for a task root | The runner needs the contaminated-side manifest file | Pass `~/Documents/CleanRoom/<task-id>/contaminated/task-manifest.json`, not the task root or clean root. |
387
+ | `task manifest not found` for a task root | The runner needs the contaminated-side manifest file | Pass `~/Documents/CleanRoom/<task-id>/contaminated/task-manifest.json` (or `~/Documents/CleanRoom/<project>/tasks/<task-id>/contaminated/task-manifest.json` in the project layout), not the task root or clean root. |
366
388
  | `preflight goal not found` | `task-manifest.json` references a missing or misplaced contaminated-side preflight file | Restore `preflight-goal.json` under the contaminated artifact root, update `preflight_goal_ref` and `preflight_goal_sha256`, then retry `--dry-run`. |
367
389
  | `clean-room run` rejects the manifest | Invalid or incomplete unattended loop metadata | Fix `controller_policy`, `loop_context.foundation_unit_ref`, and `approved_scope_refs`, then retry `--dry-run`. |
368
390
  | `clean-room artifact validation failed` lists stale JSON files | Old or hand-written clean-room artifacts are still under contaminated or clean artifact roots | Update those artifacts to current schemas or move stale/legacy JSON to quarantine, then retry `--dry-run`. |
package/lib/bootstrap.cjs CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  assertManagedPath,
10
10
  atomicWriteFile,
11
11
  atomicWriteFileNoOverwrite,
12
+ lstatIfExists,
12
13
  readJsonFile,
13
14
  } = require('./fs-utils.cjs');
14
15
  const { packageVersion } = require('./install-artifacts.cjs');
@@ -22,7 +23,10 @@ const TARGET_PROFILES = new Set([
22
23
  ]);
23
24
 
24
25
  const TASK_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
26
+ const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
27
  const BOOTSTRAP_METADATA_FILE = 'clean-room-bootstrap.json';
28
+ const PROJECT_METADATA_FILE = 'clean-room-project.json';
29
+ const PROJECT_TASKS_DIR = 'tasks';
26
30
  const BOOTSTRAP_REPO_STUB = '.clean-room/README.md';
27
31
  const BOOTSTRAP_DIRS = Object.freeze({
28
32
  contaminated: 'contaminated',
@@ -39,6 +43,10 @@ function generateTaskId() {
39
43
  return `task-${crypto.randomBytes(4).toString('hex')}`;
40
44
  }
41
45
 
46
+ function generateProjectId() {
47
+ return `proj-${crypto.randomBytes(4).toString('hex')}`;
48
+ }
49
+
42
50
  function printInitHelp() {
43
51
  console.log(`Usage: clean-room-skill init [options]
44
52
 
@@ -48,6 +56,13 @@ Options:
48
56
  --target-dir <path> Repository to initialize (default: current directory)
49
57
  --artifact-base <path> External CleanRoom base (default: ~/Documents/CleanRoom)
50
58
  --task-id <id> Neutral task id (default: generated task-xxxxxxxx)
59
+ --project <name> Group this task under a clean-room project; joins the
60
+ project when it already exists. Project names must be
61
+ neutral ([a-z0-9][a-z0-9-]{0,63}) and never derived
62
+ from source or workspace folder names. Project layout:
63
+ <base>/<project>/tasks/<task-id> with one shared
64
+ <base>/<project>/implementation root for all tasks
65
+ --new-project Create a project with a generated proj-xxxxxxxx name
51
66
  --target-profile <name> openspec-delta, gsd-planning-package,
52
67
  speckit-feature-folder, or kiro-spec-folder
53
68
  (default: speckit-feature-folder)
@@ -62,6 +77,8 @@ function parseInitArgs(argv) {
62
77
  targetDir: process.cwd(),
63
78
  artifactBase: defaultArtifactBase(),
64
79
  taskId: null,
80
+ projectId: null,
81
+ newProject: false,
65
82
  targetProfile: 'speckit-feature-folder',
66
83
  dryRun: false,
67
84
  force: false,
@@ -91,6 +108,13 @@ function parseInitArgs(argv) {
91
108
  options.taskId = requiredValue(argv, i, '--task-id');
92
109
  } else if (arg.startsWith('--task-id=')) {
93
110
  options.taskId = arg.slice('--task-id='.length);
111
+ } else if (arg === '--new-project') {
112
+ options.newProject = true;
113
+ } else if (arg === '--project') {
114
+ i += 1;
115
+ options.projectId = requiredValue(argv, i, '--project');
116
+ } else if (arg.startsWith('--project=')) {
117
+ options.projectId = arg.slice('--project='.length);
94
118
  } else if (arg === '--target-profile') {
95
119
  i += 1;
96
120
  options.targetProfile = requiredValue(argv, i, '--target-profile');
@@ -103,6 +127,10 @@ function parseInitArgs(argv) {
103
127
  }
104
128
  }
105
129
 
130
+ if (options.projectId !== null && options.newProject) {
131
+ throw new Error('--project and --new-project cannot be combined');
132
+ }
133
+
106
134
  return options;
107
135
  }
108
136
 
@@ -113,6 +141,23 @@ function requiredValue(argv, index, flag) {
113
141
  return argv[index];
114
142
  }
115
143
 
144
+ function normalizeNameToken(value) {
145
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, '');
146
+ }
147
+
148
+ function assertNeutralProjectId(projectId, targetDir) {
149
+ const projectToken = normalizeNameToken(projectId);
150
+ const targetToken = normalizeNameToken(path.basename(targetDir));
151
+ // Substring overlap is only meaningful for tokens long enough to identify a
152
+ // workspace; short tokens would reject unrelated neutral names.
153
+ const overlapping = projectToken === targetToken
154
+ || (targetToken.length >= 4 && projectToken.includes(targetToken))
155
+ || (projectToken.length >= 4 && targetToken.includes(projectToken));
156
+ if (overlapping) {
157
+ throw new Error('--project must be a neutral name; do not derive project names from workspace or source folder names');
158
+ }
159
+ }
160
+
116
161
  function resolveInitOptions(options, env = process.env, homeDir = os.homedir()) {
117
162
  const taskId = options.taskId || generateTaskId();
118
163
  if (!TASK_ID_PATTERN.test(taskId)) {
@@ -124,12 +169,27 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
124
169
 
125
170
  const targetDir = path.resolve(expandTilde(options.targetDir, homeDir));
126
171
  const artifactBase = path.resolve(expandTilde(options.artifactBase, homeDir));
127
- const outputRoot = path.join(artifactBase, taskId);
172
+
173
+ const projectMode = options.projectId !== null || options.newProject === true;
174
+ const projectId = projectMode ? (options.projectId || generateProjectId()) : null;
175
+ if (projectMode) {
176
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
177
+ throw new Error('--project must match [a-z0-9][a-z0-9-]{0,63}');
178
+ }
179
+ assertNeutralProjectId(projectId, targetDir);
180
+ }
181
+
182
+ const projectRoot = projectMode ? path.join(artifactBase, projectId) : null;
183
+ const outputRoot = projectMode
184
+ ? path.join(projectRoot, PROJECT_TASKS_DIR, taskId)
185
+ : path.join(artifactBase, taskId);
128
186
  const roots = {
129
- contaminated: path.join(outputRoot, 'contaminated'),
130
- clean: path.join(outputRoot, 'clean'),
131
- implementation: path.join(outputRoot, 'implementation'),
132
- quarantine: path.join(outputRoot, 'quarantine'),
187
+ contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
188
+ clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
189
+ implementation: projectMode
190
+ ? path.join(projectRoot, BOOTSTRAP_DIRS.implementation)
191
+ : path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
192
+ quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
133
193
  };
134
194
 
135
195
  return {
@@ -137,11 +197,14 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
137
197
  env,
138
198
  homeDir,
139
199
  taskId,
200
+ projectId,
201
+ projectRoot,
140
202
  targetDir,
141
203
  artifactBase,
142
204
  outputRoot,
143
205
  roots,
144
206
  metadataPath: assertManagedPath(outputRoot, BOOTSTRAP_METADATA_FILE),
207
+ projectMetadataPath: projectMode ? assertManagedPath(projectRoot, PROJECT_METADATA_FILE) : null,
145
208
  repoStubPath: assertManagedPath(targetDir, BOOTSTRAP_REPO_STUB),
146
209
  };
147
210
  }
@@ -152,6 +215,13 @@ function buildBootstrapMetadata(options) {
152
215
  package: 'clean-room-skill',
153
216
  version: packageVersion(),
154
217
  created_at: new Date().toISOString(),
218
+ ...(options.projectId
219
+ ? {
220
+ layout: 'project',
221
+ project_id: options.projectId,
222
+ project_root: options.projectRoot,
223
+ }
224
+ : {}),
155
225
  task_id: options.taskId,
156
226
  target_profile: options.targetProfile,
157
227
  target_dir: options.targetDir,
@@ -167,6 +237,21 @@ function buildBootstrapMetadata(options) {
167
237
  };
168
238
  }
169
239
 
240
+ function buildProjectMetadata(options) {
241
+ return {
242
+ schema: 1,
243
+ package: 'clean-room-skill',
244
+ version: packageVersion(),
245
+ created_at: new Date().toISOString(),
246
+ project_id: options.projectId,
247
+ artifact_base_root: options.artifactBase,
248
+ project_root: options.projectRoot,
249
+ implementation_root: options.roots.implementation,
250
+ tasks_dir: path.join(options.projectRoot, PROJECT_TASKS_DIR),
251
+ note: 'Project metadata only. Tasks are discovered by scanning tasks/; the shared implementation root is the clean destination for every task in this project.',
252
+ };
253
+ }
254
+
170
255
  function renderRepoStub(targetProfile) {
171
256
  return `# Clean Room Bootstrap
172
257
 
@@ -182,19 +267,59 @@ Start the runtime skill from your agent and provide the external output folder p
182
267
  `;
183
268
  }
184
269
 
270
+ function resolveExistingProject(options) {
271
+ if (!options.projectRoot) {
272
+ return { mode: 'none' };
273
+ }
274
+ const projectRootStat = lstatIfExists(options.projectRoot);
275
+ if (!projectRootStat) {
276
+ return { mode: 'new' };
277
+ }
278
+ if (projectRootStat.isSymbolicLink()) {
279
+ throw new Error(`project root must not be a symbolic link: ${options.projectRoot}`);
280
+ }
281
+ if (!projectRootStat.isDirectory()) {
282
+ throw new Error(`project root is not a directory: ${options.projectRoot}`);
283
+ }
284
+ if (options.force) {
285
+ return { mode: 'existing' };
286
+ }
287
+ if (!fs.existsSync(options.projectMetadataPath)) {
288
+ throw new Error(`project root exists but ${PROJECT_METADATA_FILE} is missing; use --force to adopt it: ${options.projectRoot}`);
289
+ }
290
+ const metadata = readJsonFile(options.projectMetadataPath, null);
291
+ const errors = [];
292
+ validateProjectMetadataObject(metadata, options.projectRoot, errors);
293
+ if (errors.length > 0) {
294
+ throw new Error(`project root exists but ${PROJECT_METADATA_FILE} is invalid; use --force to adopt it:\n ${errors.join('\n ')}`);
295
+ }
296
+ return { mode: 'existing' };
297
+ }
298
+
185
299
  function assertWritableTargets(options) {
300
+ const projectState = resolveExistingProject(options);
301
+ const joiningExistingProject = projectState.mode === 'existing';
302
+
186
303
  const fileConflicts = [];
187
- for (const filePath of [options.metadataPath, options.repoStubPath]) {
188
- if (fs.existsSync(filePath) && !options.force) {
189
- fileConflicts.push(filePath);
190
- }
304
+ if (fs.existsSync(options.metadataPath) && !options.force) {
305
+ fileConflicts.push(options.metadataPath);
306
+ }
307
+ // A second task joining a project commonly shares the same target repo; its
308
+ // existing stub is reused, not a conflict.
309
+ if (fs.existsSync(options.repoStubPath) && !options.force && !joiningExistingProject) {
310
+ fileConflicts.push(options.repoStubPath);
191
311
  }
192
312
  if (fileConflicts.length > 0) {
193
313
  throw new Error(`bootstrap file already exists; use --force to overwrite: ${fileConflicts.join(', ')}`);
194
314
  }
195
315
 
196
316
  const pathConflicts = [];
197
- for (const dirPath of Object.values(options.roots)) {
317
+ for (const [label, dirPath] of Object.entries(options.roots)) {
318
+ // The project-level implementation root is shared across tasks, so it may
319
+ // already exist once the project metadata has been validated.
320
+ if (label === 'implementation' && joiningExistingProject) {
321
+ continue;
322
+ }
198
323
  if (fs.existsSync(dirPath) && !options.force) {
199
324
  pathConflicts.push(dirPath);
200
325
  }
@@ -209,15 +334,8 @@ function assertWritableTargets(options) {
209
334
  throw new Error(`bootstrap generated path is not a directory: ${dirPath}`);
210
335
  }
211
336
  }
212
- }
213
337
 
214
- function lstatIfExists(filePath) {
215
- try {
216
- return fs.lstatSync(filePath);
217
- } catch (err) {
218
- if (err?.code === 'ENOENT') return null;
219
- throw err;
220
- }
338
+ return projectState;
221
339
  }
222
340
 
223
341
  function requireDirectory(dirPath, label, errors) {
@@ -242,20 +360,45 @@ function requireFile(filePath, label, errors) {
242
360
  }
243
361
  }
244
362
 
245
- function expectMetadataString(metadata, field, errors) {
363
+ function expectMetadataString(metadata, field, errors, label = 'bootstrap metadata') {
246
364
  if (typeof metadata?.[field] !== 'string' || metadata[field].length === 0) {
247
- errors.push(`bootstrap metadata ${field} must be a non-empty string`);
365
+ errors.push(`${label} ${field} must be a non-empty string`);
248
366
  return null;
249
367
  }
250
368
  return metadata[field];
251
369
  }
252
370
 
253
- function assertMetadataPath(metadata, field, expectedPath, errors) {
254
- const value = expectMetadataString(metadata, field, errors);
371
+ function assertMetadataPath(metadata, field, expectedPath, errors, label = 'bootstrap metadata') {
372
+ const value = expectMetadataString(metadata, field, errors, label);
255
373
  if (!value) return;
256
374
  if (path.resolve(expandTilde(value)) !== expectedPath) {
257
- errors.push(`bootstrap metadata ${field} must match ${expectedPath}`);
375
+ errors.push(`${label} ${field} must match ${expectedPath}`);
376
+ }
377
+ }
378
+
379
+ function validateProjectMetadataObject(metadata, projectRoot, errors) {
380
+ const label = 'project metadata';
381
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
382
+ errors.push(`${label} must be an object`);
383
+ return;
384
+ }
385
+ if (metadata.schema !== 1) {
386
+ errors.push(`${label} schema must be 1`);
387
+ }
388
+ if (metadata.package !== 'clean-room-skill') {
389
+ errors.push(`${label} package must be clean-room-skill`);
390
+ }
391
+ const projectId = expectMetadataString(metadata, 'project_id', errors, label);
392
+ if (projectId) {
393
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
394
+ errors.push(`${label} project_id must match [a-z0-9][a-z0-9-]{0,63}`);
395
+ }
396
+ if (projectId !== path.basename(projectRoot)) {
397
+ errors.push(`${label} project_id must match the project root basename`);
398
+ }
258
399
  }
400
+ assertMetadataPath(metadata, 'project_root', projectRoot, errors, label);
401
+ assertMetadataPath(metadata, 'implementation_root', path.join(projectRoot, BOOTSTRAP_DIRS.implementation), errors, label);
259
402
  }
260
403
 
261
404
  function validateBootstrapScaffold(taskRoot) {
@@ -282,7 +425,8 @@ function validateBootstrapScaffold(taskRoot) {
282
425
 
283
426
  const metadata = readJsonFile(metadataPath, null);
284
427
  const errors = [];
285
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
428
+ const metadataIsObject = Boolean(metadata) && typeof metadata === 'object' && !Array.isArray(metadata);
429
+ if (!metadataIsObject) {
286
430
  errors.push('bootstrap metadata must be an object');
287
431
  } else {
288
432
  if (metadata.schema !== 1) {
@@ -298,10 +442,57 @@ function validateBootstrapScaffold(taskRoot) {
298
442
  assertMetadataPath(metadata, 'output_root', outputRoot, errors);
299
443
  }
300
444
 
445
+ // Project layouts nest the task root under <project>/tasks/ and share one
446
+ // project-level implementation root. Never trust the metadata paths
447
+ // directly: derive the project root from the task root location, then
448
+ // require the metadata to match the derived layout.
449
+ const projectLayout = metadataIsObject
450
+ && (metadata.layout !== undefined || metadata.project_id !== undefined || metadata.project_root !== undefined);
451
+ let projectRoot = null;
452
+ let projectId = null;
453
+ let projectMetadataPath = null;
454
+ if (projectLayout) {
455
+ if (metadata.layout !== 'project'
456
+ || typeof metadata.project_id !== 'string'
457
+ || typeof metadata.project_root !== 'string') {
458
+ errors.push('bootstrap metadata project layout requires layout "project", project_id, and project_root');
459
+ }
460
+ const tasksDir = path.dirname(outputRoot);
461
+ if (path.basename(tasksDir) !== PROJECT_TASKS_DIR) {
462
+ errors.push(`bootstrap project task root must live under a ${PROJECT_TASKS_DIR}/ directory: ${outputRoot}`);
463
+ } else {
464
+ projectRoot = path.dirname(tasksDir);
465
+ requireDirectory(projectRoot, 'project root', errors);
466
+ assertMetadataPath(metadata, 'project_root', projectRoot, errors);
467
+ projectId = expectMetadataString(metadata, 'project_id', errors);
468
+ if (projectId) {
469
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
470
+ errors.push('bootstrap metadata project_id must match [a-z0-9][a-z0-9-]{0,63}');
471
+ }
472
+ if (projectId !== path.basename(projectRoot)) {
473
+ errors.push('bootstrap metadata project_id must match the project root basename');
474
+ }
475
+ }
476
+ try {
477
+ projectMetadataPath = assertManagedPath(projectRoot, PROJECT_METADATA_FILE);
478
+ } catch (err) {
479
+ errors.push(err.message);
480
+ }
481
+ if (projectMetadataPath) {
482
+ requireFile(projectMetadataPath, 'project metadata', errors);
483
+ if (fs.existsSync(projectMetadataPath)) {
484
+ validateProjectMetadataObject(readJsonFile(projectMetadataPath, null), projectRoot, errors);
485
+ }
486
+ }
487
+ }
488
+ }
489
+
301
490
  const roots = {
302
491
  contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
303
492
  clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
304
- implementation: path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
493
+ implementation: projectRoot
494
+ ? path.join(projectRoot, BOOTSTRAP_DIRS.implementation)
495
+ : path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
305
496
  quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
306
497
  };
307
498
  for (const [label, dirPath] of Object.entries(roots)) {
@@ -337,6 +528,9 @@ function validateBootstrapScaffold(taskRoot) {
337
528
  metadata,
338
529
  roots,
339
530
  repoStubPath,
531
+ projectRoot,
532
+ projectId,
533
+ projectMetadataPath,
340
534
  };
341
535
  }
342
536
 
@@ -375,28 +569,84 @@ function writeBootstrapFile(filePath, data, force) {
375
569
  }
376
570
  }
377
571
 
572
+ function writeProjectMetadataFile(options) {
573
+ const metadata = buildProjectMetadata(options);
574
+ if (options.force) {
575
+ // A forced rewrite refreshes the metadata but keeps the project's
576
+ // original creation time when the prior file is readable. --force is
577
+ // also the documented way to adopt invalid metadata, so parse failures
578
+ // fall back to the fresh timestamp instead of aborting.
579
+ try {
580
+ const existing = readJsonFile(options.projectMetadataPath, null);
581
+ if (typeof existing?.created_at === 'string' && existing.created_at.length > 0) {
582
+ metadata.created_at = existing.created_at;
583
+ }
584
+ } catch {
585
+ // Keep the fresh created_at when existing metadata is unreadable.
586
+ }
587
+ atomicWriteFile(options.projectMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
588
+ return;
589
+ }
590
+ const data = `${JSON.stringify(metadata, null, 2)}\n`;
591
+ try {
592
+ atomicWriteFileNoOverwrite(options.projectMetadataPath, data, 'utf8');
593
+ } catch (err) {
594
+ if (err?.code !== 'EEXIST') {
595
+ throw err;
596
+ }
597
+ // Joining an existing project, or losing a creation race: exactly one
598
+ // writer wins the no-overwrite link; everyone else validates the winner's
599
+ // metadata and proceeds without rewriting it.
600
+ const errors = [];
601
+ validateProjectMetadataObject(readJsonFile(options.projectMetadataPath, null), options.projectRoot, errors);
602
+ if (errors.length > 0) {
603
+ throw new Error(`existing ${PROJECT_METADATA_FILE} is invalid; use --force to adopt it:\n ${errors.join('\n ')}`);
604
+ }
605
+ }
606
+ }
607
+
378
608
  function applyBootstrap(options) {
379
- assertWritableTargets(options);
609
+ const projectState = assertWritableTargets(options);
380
610
  if (!options.dryRun) {
611
+ if (options.projectRoot) {
612
+ fs.mkdirSync(path.join(options.projectRoot, PROJECT_TASKS_DIR), { recursive: true });
613
+ }
381
614
  for (const dir of Object.values(options.roots)) {
382
615
  fs.mkdirSync(dir, { recursive: true });
383
616
  }
617
+ if (options.projectRoot) {
618
+ writeProjectMetadataFile(options);
619
+ }
384
620
  const metadata = `${JSON.stringify(buildBootstrapMetadata(options), null, 2)}\n`;
385
621
  writeBootstrapFile(options.metadataPath, metadata, options.force);
386
- writeBootstrapFile(options.repoStubPath, renderRepoStub(options.targetProfile), options.force);
622
+ if (options.force || !fs.existsSync(options.repoStubPath)) {
623
+ writeBootstrapFile(options.repoStubPath, renderRepoStub(options.targetProfile), options.force);
624
+ }
387
625
  }
388
- printInitResult(options);
626
+ printInitResult(options, projectState);
389
627
  }
390
628
 
391
- function printInitResult(options) {
629
+ function printInitResult(options, projectState = { mode: 'none' }) {
392
630
  const verb = options.dryRun ? 'Would create' : 'Created';
393
631
  console.log(`${verb} clean-room bootstrap`);
632
+ if (options.projectId) {
633
+ const projectLabel = projectState.mode === 'existing' ? 'existing' : 'new';
634
+ console.log(` project: ${options.projectId} (${projectLabel})`);
635
+ console.log(` project root: ${options.projectRoot}`);
636
+ }
394
637
  console.log(` output folder: ${options.outputRoot}`);
395
638
  console.log(` contaminated artifacts: ${options.roots.contaminated}`);
396
639
  console.log(` clean artifacts: ${options.roots.clean}`);
397
- console.log(` implementation root: ${options.roots.implementation}`);
640
+ if (options.projectId) {
641
+ console.log(` implementation root (shared): ${options.roots.implementation}`);
642
+ } else {
643
+ console.log(` implementation root: ${options.roots.implementation}`);
644
+ }
398
645
  console.log(` quarantine: ${options.roots.quarantine}`);
399
646
  console.log(` metadata: ${options.metadataPath}`);
647
+ if (options.projectId) {
648
+ console.log(` project metadata: ${options.projectMetadataPath}`);
649
+ }
400
650
  console.log(` repo stub: ${options.repoStubPath}`);
401
651
  console.log('');
402
652
  console.log('Next steps:');
@@ -429,7 +679,9 @@ function runInit(argv, context = {}) {
429
679
 
430
680
  module.exports = {
431
681
  BOOTSTRAP_METADATA_FILE,
682
+ PROJECT_METADATA_FILE,
432
683
  defaultArtifactBase,
684
+ generateProjectId,
433
685
  generateTaskId,
434
686
  parseInitArgs,
435
687
  resolveBootstrapScaffold,
package/lib/fs-utils.cjs CHANGED
@@ -217,6 +217,7 @@ module.exports = {
217
217
  atomicWriteFileNoOverwrite,
218
218
  fileHash,
219
219
  listFiles,
220
+ lstatIfExists,
220
221
  normalizeRelativePath,
221
222
  readJsonFile,
222
223
  removeEmptyParents,
@@ -6,6 +6,9 @@ const MAX_OUTPUT_BYTES = 256 * 1024;
6
6
  const RUN_LOCK_NAME = '.clean-room-run.lock';
7
7
  const RUN_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_RUN_LOCK_WAIT_MS', 30_000);
8
8
  const RUN_LOCK_POLL_MS = 100;
9
+ const IMPLEMENTATION_LOCK_NAME = '.clean-room-implementation.lock';
10
+ const IMPLEMENTATION_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_IMPLEMENTATION_LOCK_WAIT_MS', 30_000);
11
+ const IMPLEMENTATION_LOCK_POLL_MS = 100;
9
12
  const RUN_HOOK_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_RUN_HOOK_TIMEOUT_MS', 30_000);
10
13
  const LEDGER_NAME = 'controller-run-ledger.json';
11
14
  const RESULT_NAME = 'clean-room-result.json';
@@ -97,6 +100,7 @@ const VOLATILE_PROGRESS_KEYS = new Set([
97
100
 
98
101
  const IMPLEMENTATION_IGNORE_NAMES = Object.freeze([
99
102
  '.git',
103
+ IMPLEMENTATION_LOCK_NAME,
100
104
  'node_modules',
101
105
  'target',
102
106
  ]);
@@ -151,6 +155,9 @@ module.exports = {
151
155
  HANDOFF_PACKAGE_NAME,
152
156
  HOOK_ONLY_ENV_ALLOWLIST,
153
157
  IMPLEMENTATION_IGNORE_NAMES,
158
+ IMPLEMENTATION_LOCK_NAME,
159
+ IMPLEMENTATION_LOCK_POLL_MS,
160
+ IMPLEMENTATION_LOCK_WAIT_MS,
154
161
  LEDGER_NAME,
155
162
  MAX_LEDGER_ITERATIONS,
156
163
  MAX_OUTPUT_BYTES,
@@ -54,6 +54,7 @@ const {
54
54
  loadLedger,
55
55
  noProgressResult,
56
56
  stageFailureResult,
57
+ withImplementationRootLocks,
57
58
  withRunLock,
58
59
  writeLedger,
59
60
  writeResult,
@@ -211,7 +212,7 @@ async function runCleanRoom(options, context = {}) {
211
212
  ? { agentConfig: null, configDir: process.cwd() }
212
213
  : resolveAgentConfig(options, context, roots, manifest, agentConfigPath);
213
214
 
214
- return withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
215
+ return withImplementationRootLocks(roots.implementationRoots, options.dryRun, () => withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
215
216
  const coverageLedgerPath = path.join(roots.contaminatedRoot, 'coverage-ledger.json');
216
217
  const coverageLedger = validateRunState(options, taskManifestPath, roots, manifest, coverageLedgerPath);
217
218
  const selectedUnit = selectUnit(manifest, coverageLedger);
@@ -374,7 +375,7 @@ async function runCleanRoom(options, context = {}) {
374
375
  validateFoundationCoverageGate(resultManifest, finalCoverageLedger);
375
376
  console.log(`clean-room run: ${terminalResult.result}`);
376
377
  return terminalResult;
377
- });
378
+ }));
378
379
  }
379
380
 
380
381
  module.exports = {
@@ -10,6 +10,9 @@ const {
10
10
  } = require('./fs-utils.cjs');
11
11
  const {
12
12
  CLEAN_RUN_CONTEXT_NAME,
13
+ IMPLEMENTATION_LOCK_NAME,
14
+ IMPLEMENTATION_LOCK_POLL_MS,
15
+ IMPLEMENTATION_LOCK_WAIT_MS,
13
16
  MAX_LEDGER_ITERATIONS,
14
17
  POLISH_REPORT_NAME,
15
18
  RUN_LOCK_NAME,
@@ -431,6 +434,25 @@ async function withRunLock(contaminatedRoot, dryRun, fn) {
431
434
  }, fn);
432
435
  }
433
436
 
437
+ async function withImplementationRootLocks(implementationRoots, dryRun, fn) {
438
+ if (dryRun) return fn();
439
+ // Realpath dedupe: root-separation checks do not compare implementation
440
+ // roots against each other, so symlink-aliased duplicates would otherwise
441
+ // self-deadlock on their own live-pid lock. Sorting gives every run the
442
+ // same global acquisition order, preventing AB/BA deadlocks.
443
+ const roots = [...new Set(implementationRoots.map((root) => {
444
+ fs.mkdirSync(root, { recursive: true });
445
+ return fs.realpathSync(root);
446
+ }))].sort();
447
+ const run = roots.reduceRight((next, root) => () => withDirectoryLock({
448
+ lockPath: path.join(root, IMPLEMENTATION_LOCK_NAME),
449
+ waitMs: IMPLEMENTATION_LOCK_WAIT_MS,
450
+ pollMs: IMPLEMENTATION_LOCK_POLL_MS,
451
+ label: 'clean-room implementation lock',
452
+ }, next), fn);
453
+ return run();
454
+ }
455
+
434
456
  module.exports = {
435
457
  buildResult,
436
458
  completeResultOrSpecDelta,
@@ -439,6 +461,7 @@ module.exports = {
439
461
  loadLedger,
440
462
  noProgressResult,
441
463
  stageFailureResult,
464
+ withImplementationRootLocks,
442
465
  withRunLock,
443
466
  writeLedger,
444
467
  writeResult,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room-skill",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -13,7 +13,7 @@ In Pi, this entry point is invoked as `/skill:attended`.
13
13
 
14
14
  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.
15
15
 
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.
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-*` (legacy) and `~/Documents/CleanRoom/*/tasks/task-*` (project layout) 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
18
  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.
19
19
 
@@ -21,6 +21,7 @@ Gather only required setup facts:
21
21
 
22
22
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
23
23
  - Artifact base root, defaulting to `~/Documents/CleanRoom/<task-id>/`. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. Do not derive task IDs or output directory names from source folder names.
24
+ - Optional project grouping for multi-task destinations, following the canonical `clean-room` project layout rules: `<base>/<project>/tasks/<task-id>/` with one shared `<base>/<project>/implementation/` root, a neutral project name (random word pair or `proj-` plus 8 lowercase hex, matching `[a-z0-9][a-z0-9-]{0,63}`, never source-derived), and at most one active task per project.
24
25
  - Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
25
26
  - Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
26
27
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
@@ -80,15 +80,16 @@ Discovery order:
80
80
 
81
81
  1. Resolve explicit user-provided paths first. Accept a task root, `task-manifest.json`, `preflight-goal.json`, or `clean-room-bootstrap.json`.
82
82
  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.
83
- 3. Scan `~/Documents/CleanRoom/task-*` as a bounded fallback. Inspect only immediate task directories and their expected artifact names.
83
+ 3. Scan `~/Documents/CleanRoom/task-*` (legacy single-task layout) and `~/Documents/CleanRoom/*/clean-room-project.json` plus `~/Documents/CleanRoom/*/tasks/task-*` (project layout) as a bounded fallback. Inspect only immediate project directories, their `tasks/` children, and expected artifact names.
84
84
 
85
- 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.
85
+ If more than one candidate run is found without an explicit user path, list the candidate task roots grouped by project and stop for explicit selection. Do not choose the newest candidate automatically.
86
86
 
87
87
  Classify the selected candidate before starting the wizard:
88
88
 
89
89
  - Valid `task-manifest.json`: route to `resume-cr` and continue from the earliest incomplete gate.
90
90
  - Valid canonical `preflight-goal.json` without `task-manifest.json`: continue at source/destination discovery and manifest creation. Do not ask the preflight wizard again.
91
91
  - `clean-room-bootstrap.json` only: run preflight using the bootstrap roots.
92
+ - `clean-room-project.json` with no task directories under `tasks/`: treat as an empty project and offer to create its first task inside that project.
92
93
  - Invalid `preflight-goal.json`: stop, report canonical schema or required-field errors, and do not create a replacement preflight.
93
94
  - No artifacts found: start the normal preflight wizard.
94
95
 
@@ -98,6 +99,7 @@ Gather only the setup facts needed to decide whether the workflow may start, or
98
99
 
99
100
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
100
101
  - Artifact base root. Default to `~/Documents/CleanRoom/<task-id>/`. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. Do not derive task IDs or output directory names from source folder names.
102
+ - Optional project grouping. Ask whether to group this run under a clean-room project when multiple tasks will target the same destination; default to the legacy single-task layout. Project layout is `<base>/<project>/tasks/<task-id>/` with one shared `<base>/<project>/implementation/` root for every task in the project. When the user does not supply an approved neutral project name, generate a random neutral word pair such as `amber-meadow`; it must match `[a-z0-9][a-z0-9-]{0,63}`, must never be derived from source or destination folder basenames or meaningful source-name tokens (project names appear in paths clean roles can see), and falls back to `proj-` plus 8 lowercase hex characters when no neutral word pair is available. Only one task per project may run at a time because tasks share the implementation root; the durable runner enforces this with an advisory `.clean-room-implementation.lock` in each implementation root.
101
103
  - Source roots or fallback visual evidence roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
102
104
  - Target stack and destination constraints from `preflight-goal.json`.
103
105
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
@@ -107,7 +109,7 @@ Gather only the setup facts needed to decide whether the workflow may start, or
107
109
  - Run state. New runs use `generation: 1`, current `started_at`, and `restart_reason: user-requested`.
108
110
  - Role hook environment block. Derive `CLEAN_ROOM_ROLE`, `CLEAN_ROOM_SOURCE_ROOTS`, `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, `CLEAN_ROOM_CLEAN_ROOTS`, `CLEAN_ROOM_IMPLEMENTATION_ROOTS`, `CLEAN_ROOM_ALLOWED_READ_ROOTS`, `CLEAN_ROOM_SCHEMA_DIR`, and optional hook-only denylist variables from the approved roots before launching any role session. Do not ask the user to export `CLEAN_ROOM_HOOK_ENFORCE` for normal safe-hook runs.
109
111
 
110
- Before indexing or artifact generation, confirm that source roots, contaminated artifact roots, clean artifact roots, clean implementation roots, approved public reference roots, and schema directory are separate paths, and that clean/contaminated/implementation root path names are not source-derived. Stop if authorization is unclear, if clean and contaminated roots overlap, if implementation roots overlap any other trust-domain root, or if artifact/root paths contain source root basenames or meaningful non-generic source-name tokens. Agent 2, Agent 3, and Agent 4 must not receive source mounts or the full task manifest.
112
+ Before indexing or artifact generation, confirm that source roots, contaminated artifact roots, clean artifact roots, clean implementation roots, approved public reference roots, and schema directory are separate paths, and that clean/contaminated/implementation root path names are not source-derived. In project layout the implementation root is the shared project-level folder; per-task contaminated, clean, and quarantine roots stay under the task root, and the project name itself must pass the same source-derived-name checks. Stop if authorization is unclear, if clean and contaminated roots overlap, if implementation roots overlap any other trust-domain root, or if artifact/root paths contain source root basenames or meaningful non-generic source-name tokens. Agent 2, Agent 3, and Agent 4 must not receive source mounts or the full task manifest.
111
113
 
112
114
  For `attended` mode, record a `controller_policy` that pauses for human review at scope gate, clean handoff, terminal implementation deltas, blocked units, and final coverage. Include stop conditions for `authorization-missing`, `scope-change`, `contamination-suspected`, `schema-validation-failed`, `leakage-scan-failed`, `unit-blocked`, `implementation-complete`, and `coverage-complete`; attended mode does not add an iteration-limit stop unless the user explicitly sets one.
113
115
 
@@ -27,6 +27,14 @@
27
27
  "type": "string",
28
28
  "minLength": 1
29
29
  },
30
+ "project_id": {
31
+ "type": "string",
32
+ "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
33
+ },
34
+ "project_root": {
35
+ "type": "string",
36
+ "minLength": 1
37
+ },
30
38
  "root_preferences": {
31
39
  "type": "object",
32
40
  "additionalProperties": false,
@@ -33,6 +33,14 @@
33
33
  "type": "string",
34
34
  "minLength": 1
35
35
  },
36
+ "project_id": {
37
+ "type": "string",
38
+ "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
39
+ },
40
+ "project_root": {
41
+ "type": "string",
42
+ "minLength": 1
43
+ },
36
44
  "target_identifier": {
37
45
  "type": "string",
38
46
  "minLength": 1
@@ -1215,6 +1223,14 @@
1215
1223
  "type": "string",
1216
1224
  "minLength": 1
1217
1225
  },
1226
+ "project_id": {
1227
+ "type": "string",
1228
+ "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
1229
+ },
1230
+ "project_root": {
1231
+ "type": "string",
1232
+ "minLength": 1
1233
+ },
1218
1234
  "target_profile": {
1219
1235
  "enum": [
1220
1236
  "openspec-delta",
@@ -47,6 +47,8 @@ Treat implementation identifiers as contaminated by default. Package names, name
47
47
 
48
48
  Public compatibility surface means the name is externally documented, required by an existing integration, visible in a public protocol or file format, or explicitly required by the destination scope. If a name is retained, place it in `public_surface` or `public_contracts` with `name`, `kind`, `visibility`, and a concrete compatibility reason. Valid `visibility` values are `public`, `destination`, `protocol`, and `user-required`. Do not mention source-private names in summaries, claims, tests, open questions, skeleton areas, QC findings, or delta tickets.
49
49
 
50
+ Task IDs and project names are clean-visible path components: they appear in roots that clean roles read and in paths recorded by clean artifacts. Both must stay neutral. Use generated `task-` plus 8 lowercase hex IDs, and for projects a random neutral word pair or `proj-` plus 8 lowercase hex, matching `[a-z0-9][a-z0-9-]{0,63}`. Never derive a task ID or project name from source folder basenames or meaningful source-name tokens.
51
+
50
52
  The contaminated side should maintain a private identifier denylist for guardrail scanning when practical. The denylist is line-oriented, ignores blank lines and `#` comments, and is bounded to 1,000,000 bytes per file, 20,000 total terms, and 512 characters per term. Keep that list out of clean/source-denied readable roots and do not paste its contents into clean artifacts or model-visible reports.
51
53
 
52
54
  Agent 1.5 may use the denylist only through hook scanning. Do not include denylist terms in the neutral sanitizer brief, sanitizer prompts, sanitizer reports, clean artifacts, or model-visible feedback.
@@ -24,6 +24,7 @@ Record every default as an assumption. Good defaults:
24
24
 
25
25
  - Artifact base: `~/Documents/CleanRoom/<task-id>/`.
26
26
  - Implementation root: `~/Documents/CleanRoom/<task-id>/implementation/`.
27
+ - Project layout (when grouping multiple tasks): task root `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with shared implementation root `~/Documents/CleanRoom/<project>/implementation/`.
27
28
  - Existing destination policy: `inspect-and-preserve`.
28
29
  - Dependency policy: allow new dependencies, prefer standard library, require approval for native/system dependencies.
29
30
  - Dependency licenses: allow MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause; block GPL-3.0 and AGPL-3.0 unless the user explicitly approves otherwise.
@@ -20,7 +20,9 @@ Use separate locations for each trust domain:
20
20
 
21
21
  Clean, contaminated, and implementation paths must remain neutral. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters and use it under `~/Documents/CleanRoom/`.
22
22
 
23
- Do not derive task IDs, clean roots, contaminated artifact roots, or implementation roots from source folder names. The initialization wizard and environment preflight reject artifact paths that contain a source root basename or meaningful non-generic tokens from that basename.
23
+ Multiple tasks targeting the same destination may be grouped under an optional clean-room project: `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with one shared `<project>/implementation/` root. Project names follow the same neutrality rules as task IDs: a random neutral word pair (such as `amber-meadow`) or `proj-` plus 8 lowercase hex characters, matching `[a-z0-9][a-z0-9-]{0,63}`. Because the shared implementation root serves every task in the project, run at most one active task per project at a time; root-separation checks for one task cannot see a sibling task's concurrent clean implementation session. `clean-room-skill run` enforces this with an advisory `.clean-room-implementation.lock` in each implementation root, but manually launched role sessions outside the runner must still respect the rule.
24
+
25
+ Do not derive task IDs, project names, clean roots, contaminated artifact roots, or implementation roots from source folder names. The initialization wizard and environment preflight reject artifact paths that contain a source root basename or meaningful non-generic tokens from that basename.
24
26
 
25
27
  Prefer separate agent profiles or homes when the host supports them. Do not rely on one chat context with role labels as the only separation control.
26
28
 
@@ -212,6 +214,7 @@ Clean polish reviewer:
212
214
  2. Initialization gate:
213
215
  - Record reusable preferences in `init-config.json` when requested.
214
216
  - Default the artifact base root to `~/Documents/CleanRoom/<task-id>/` unless the user selects another separated location. Generate a neutral `task-` plus 8 lowercase hex characters when the user does not provide an explicitly approved neutral task ID.
217
+ - When grouping tasks under a project, place the task root at `~/Documents/CleanRoom/<project>/tasks/<task-id>/`, share the project-level `implementation/` root, and record `project_id` and `project_root` in `init-config.json` and the manifest `initialization_snapshot`.
215
218
  - Reject clean, contaminated, or implementation roots that mirror source root basenames or meaningful non-generic source-name tokens.
216
219
  - Record model preferences as a default model plus optional domain or role overrides.
217
220
  - Split user rules into clean-safe and contaminated-only rules.
@@ -100,11 +100,14 @@ Unattended mode requires `unattended_allowed_after_preflight: true`, finite `max
100
100
 
101
101
  Default artifact roots live under `~/Documents/CleanRoom/<task-id>/`. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. Do not use the source folder name as the task ID.
102
102
 
103
+ When tasks are grouped under a project, roots live under `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with a shared `~/Documents/CleanRoom/<project>/implementation/` root. Project names follow the same neutrality rules: a random neutral word pair or `proj-` plus 8 lowercase hex characters, never derived from source folder names.
104
+
103
105
  Clean artifact, contaminated artifact, and implementation roots must not contain source root basenames or meaningful non-generic tokens from those basenames. The environment preflight enforces this for `CLEAN_ROOM_CLEAN_ROOTS`, `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, and `CLEAN_ROOM_IMPLEMENTATION_ROOTS`.
104
106
 
105
107
  Capture:
106
108
 
107
109
  - artifact base root, defaulting to `~/Documents/CleanRoom/<task-id>/` with a neutral task ID
110
+ - optional `project_id` and `project_root` when grouping tasks under a clean-room project
108
111
  - source roots or fallback visual evidence roots, contaminated artifact root, clean artifact root, clean implementation roots, quarantine root, and approved public references
109
112
  - target profile
110
113
  - default model plus optional clean, contaminated, or per-role overrides
@@ -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` (or `npx clean-room-skill@latest init` if the binary is not available) 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`.
22
+ The CLI command `clean-room-skill init` (or `npx clean-room-skill@latest init` if the binary is not available) may have pre-created neutral external folders and a clean-safe `.clean-room/README.md` stub in the target repository. The bootstrap has two shapes. The legacy single-task root contains `contaminated/`, `clean/`, `implementation/`, and `quarantine/`. The project layout (`--project` or `--new-project`) places the task root at `<base>/<project>/tasks/<task-id>/` with per-task `contaminated/`, `clean/`, and `quarantine/`, plus a shared project-level `implementation/` and a `clean-room-project.json` metadata file at the project root. 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/`, `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.
24
+ When using an existing CLI bootstrap, check `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `quarantine/`, the implementation root (task-level in the legacy layout, project-level in the project layout), and the target repo `.clean-room/README.md` before recording active init preferences. In the project layout also check `clean-room-project.json` and that the task root sits under the project's `tasks/` directory. 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
 
@@ -30,6 +30,7 @@ Collect only setup decisions that affect correctness, safety, resumability, or o
30
30
  - Requester authorization, allowed actions, prohibited actions, and evidence handling.
31
31
  - Source roots, contaminated artifact root, clean artifact root, clean implementation roots, quarantine root, and approved public or destination reference roots.
32
32
  - Artifact base root. Default to `~/Documents/CleanRoom/<task-id>/`, never to the source workspace or a temporary directory unless the user explicitly chooses it. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. Do not derive task IDs or output directory names from source folder names.
33
+ - Optional project grouping. When multiple tasks will target the same destination, record `project_id` and `project_root` for the `<base>/<project>/tasks/<task-id>/` layout with its shared project-level implementation root. Project names follow the same neutrality rules as task IDs: a random neutral word pair or `proj-` plus 8 lowercase hex, matching `[a-z0-9][a-z0-9-]{0,63}`, never derived from source folder names. Record both fields in `init-config.json` and the manifest `initialization_snapshot`.
33
34
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
34
35
  - Goal contract choices from `preflight-goal.json`, including target stack, dependency/license policy, exactness policy, feature policy, code hygiene, output policy, and controller mode.
35
36
  - Default model plus optional overrides for contaminated roles, clean roles, or individual roles. Keep model ids as runtime-specific strings.
@@ -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` (or `npx clean-room-skill@latest init` if the binary is not available), 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`.
14
+ If the user provides output from CLI `clean-room-skill init` (or `npx clean-room-skill@latest init` if the binary is not available), check the generated bootstrap scaffold before creating or copying `preflight-goal.json`: `clean-room-bootstrap.json`, `contaminated/`, `clean/`, the implementation root, `quarantine/`, and the target repo `.clean-room/README.md` must exist and agree. In the project layout the task root sits at `<base>/<project>/tasks/<task-id>/`, the implementation root is the shared project-level `implementation/`, and `clean-room-project.json` must exist at the project root. 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
 
@@ -52,6 +52,7 @@ Use the CLI (`clean-room-skill` if installed, or `npx clean-room-skill@latest` a
52
52
  clean-room-skill preflight --template --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
53
53
  clean-room-skill preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
54
54
  clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/task-xxxxxxxx
55
+ clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/<project>/tasks/task-xxxxxxxx
55
56
  ```
56
57
 
57
58
  `--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.
@@ -15,7 +15,7 @@ If `task-manifest.json` records `controller_policy.mode: "unattended"` in Claude
15
15
 
16
16
  ## Load Order
17
17
 
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.
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. A task may live at `<base>/<project>/tasks/<task-id>/` with a shared project-level implementation root; trust the absolute paths recorded in `task-manifest.json` `artifact_paths` and never re-derive the layout from folder positions.
19
19
 
20
20
  - `task-manifest.json`
21
21
  - `preflight-goal.json`, when referenced by `task-manifest.json`, only on the contaminated/controller side
@@ -46,6 +46,7 @@ Before choosing work:
46
46
  - Confirm authorization still covers the recorded source scope and allowed actions.
47
47
  - Report `run_state` when present; do not infer generation from chat history when it is missing.
48
48
  - Trust `initialization_snapshot` before any reusable `init-config.json`. If they differ, report drift and stop before changing roots, model policy, schema profile, or rule classification.
49
+ - When `initialization_snapshot` records `project_id` or `project_root`, confirm the on-disk `clean-room-project.json` at the project root still agrees with them and with the shared implementation root. Report drift and stop on mismatch.
49
50
  - Preserve the existing `controller_policy`; missing policy means `attended`.
50
51
  - Stop if new-run artifacts lack `preflight_goal_ref`, `preflight_goal_sha256`, or the required `handoff_sequence`. Treat this as legacy or incomplete preflight state and ask for a reviewed preflight goal before resuming.
51
52
  - Validate referenced `preflight-goal.json` before using goal, stack, dependency, license, exactness, output, or hygiene decisions.
@@ -24,6 +24,7 @@ Archive or quarantine previous artifacts before creating new ones:
24
24
  - Do not mix contaminated and clean archives.
25
25
  - Do not delete artifacts.
26
26
  - Do not overwrite an existing archive path; create a unique archive directory.
27
+ - In the project layout, archive scope is the task folder only. Never archive, move, or quarantine the project root, `clean-room-project.json`, or the shared project-level `implementation/` root; sibling tasks depend on them, and implementation content is destination state, not per-task artifact state.
27
28
  - Preserve existing `preflight-goal.json`, `task-manifest.json`, ledgers, handoff packages, behavior specs, skeleton manifests, implementation plans, implementation reports, QC reports, incident records, and open delta tickets.
28
29
 
29
30
  If safe archive targets cannot be proven from `task-manifest.json`, root environment, or explicit user input, stop before moving anything.
@@ -37,6 +38,7 @@ Start from the preflight gate, not from prior QC:
37
38
  - Reconfirm source roots or visual roots, contaminated artifact roots, clean roots, implementation roots, and clean allowed-read roots are separated, and that root path names are not source-derived.
38
39
  - Preserve source or visual roots and authorization only when they are still valid for the requested restart.
39
40
  - Create a fresh neutral `task_id` by default. Use `task-` plus 8 lowercase hex characters unless the user provides an explicitly approved neutral ID. Do not derive the new ID or output directory names from source folder names.
41
+ - In the project layout, the new task joins the same project by default. Starting a new project instead requires an explicit user choice and a fresh neutral project name (random word pair or `proj-` plus 8 lowercase hex, never source-derived).
40
42
  - Record `run_state.generation`, `run_state.started_at`, optional `run_state.previous_generation_ref`, and `run_state.restart_reason`.
41
43
  - Recreate `clean-run-context.json` from the new effective preflight and initialization choices; do not carry forward an old clean context by default.
42
44
  - Rebuild `source-index.json`, or `visual-index.json` for visual fallback runs, unless the user explicitly says the source or visual scope is unchanged and a recorded old index hash can still be validated.
@@ -13,7 +13,7 @@ In Pi, this entry point is invoked as `/skill:unattended`.
13
13
 
14
14
  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.
15
15
 
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.
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-*` (legacy) and `~/Documents/CleanRoom/*/tasks/task-*` (project layout) 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
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`. If `clean-room-skill` is not on `PATH`, immediately use `npx clean-room-skill@latest run --task-manifest <path> --agent-runtime claude` instead of searching for the installed package. Do not search plugin cache paths for schema files, and do not pass `--schema-dir /dev/null`. The runner uses bundled schemas by default; pass `--schema-dir` only when the user provides a real schema directory. 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
19
 
@@ -25,6 +25,7 @@ Gather only required setup facts:
25
25
 
26
26
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
27
27
  - Artifact base root, defaulting to `~/Documents/CleanRoom/<task-id>/`. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. Do not derive task IDs or output directory names from source folder names.
28
+ - Optional project grouping for multi-task destinations, following the canonical `clean-room` project layout rules: `<base>/<project>/tasks/<task-id>/` with one shared `<base>/<project>/implementation/` root, a neutral project name (random word pair or `proj-` plus 8 lowercase hex, matching `[a-z0-9][a-z0-9-]{0,63}`, never source-derived), and at most one active task per project.
28
29
  - Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
29
30
  - Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
30
31
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.