clean-room-skill 0.2.3 → 0.3.1

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.1",
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.1",
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.1",
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`.
@@ -60,7 +62,9 @@ To assist in logical unit decomposition, the workflow supports an optional sourc
60
62
 
61
63
  Runtime installs and uninstalls serialize per target root with `.clean-room-install.lock`. The installer plans desired file changes from manifest hashes, then rechecks each managed file immediately before write or removal. Managed files that changed after planning are backed up before mutation.
62
64
 
63
- The manifest is written with `phase: "installing"` after file copy and before hook config mutation. It is updated to `phase: "complete"` only after hook config succeeds. If hook config mutation fails, the installer records `hook_registration.status: "failed"` in the manifest when possible. A manifest left in `installing` is recoverable by re-running the same installer command. Bootstrap writes use atomic no-clobber creation unless `--force` is set.
65
+ The manifest is written with `phase: "installing"` after file copy and before hook config mutation. It is updated to `phase: "complete"` only after hook config succeeds. If hook config mutation fails, the installer records `hook_registration.status: "failed"` in the manifest when possible. A manifest left in `installing` is recoverable by re-running the same installer command. Bootstrap writes use atomic no-clobber creation unless `--force` is set. When `--force` adopts an existing project root that lacks or has invalid project metadata, the installer emits a warning so operators know they are reusing existing `tasks/` and `implementation/` content.
66
+
67
+ The implementation lock (`.clean-room-implementation.lock`) uses rename-based stale recovery: when a stale lock directory is detected, it is renamed to `<name>.stale.<ts>.<pid>` rather than deleted so the original can be inspected post-mortem. Implementation-root scans exclude both the active lock name and the stale prefix pattern to prevent orphaned stale lock directories from appearing in progress snapshots or misplaced-artifact checks.
64
68
 
65
69
  ---
66
70
 
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,25 @@ 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
+ // Clause 2 (project contains workspace token) is the primary leakage vector and
152
+ // needs a lower minimum length so that short-but-meaningful workspace names (len 2+)
153
+ // are still caught. Clause 3 keeps the >=4 guard: a short project token included in
154
+ // a long workspace token is a weak signal and would produce too many false rejections.
155
+ const overlapping = projectToken === targetToken
156
+ || (targetToken.length >= 2 && projectToken.includes(targetToken))
157
+ || (projectToken.length >= 4 && targetToken.includes(projectToken));
158
+ if (overlapping) {
159
+ throw new Error('--project must be a neutral name; do not derive project names from workspace or source folder names');
160
+ }
161
+ }
162
+
116
163
  function resolveInitOptions(options, env = process.env, homeDir = os.homedir()) {
117
164
  const taskId = options.taskId || generateTaskId();
118
165
  if (!TASK_ID_PATTERN.test(taskId)) {
@@ -124,12 +171,27 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
124
171
 
125
172
  const targetDir = path.resolve(expandTilde(options.targetDir, homeDir));
126
173
  const artifactBase = path.resolve(expandTilde(options.artifactBase, homeDir));
127
- const outputRoot = path.join(artifactBase, taskId);
174
+
175
+ const projectMode = options.projectId !== null || options.newProject === true;
176
+ const projectId = projectMode ? (options.projectId || generateProjectId()) : null;
177
+ if (projectMode) {
178
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
179
+ throw new Error('--project must match [a-z0-9][a-z0-9-]{0,63}');
180
+ }
181
+ assertNeutralProjectId(projectId, targetDir);
182
+ }
183
+
184
+ const projectRoot = projectMode ? path.join(artifactBase, projectId) : null;
185
+ const outputRoot = projectMode
186
+ ? path.join(projectRoot, PROJECT_TASKS_DIR, taskId)
187
+ : path.join(artifactBase, taskId);
128
188
  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'),
189
+ contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
190
+ clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
191
+ implementation: projectMode
192
+ ? path.join(projectRoot, BOOTSTRAP_DIRS.implementation)
193
+ : path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
194
+ quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
133
195
  };
134
196
 
135
197
  return {
@@ -137,11 +199,14 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
137
199
  env,
138
200
  homeDir,
139
201
  taskId,
202
+ projectId,
203
+ projectRoot,
140
204
  targetDir,
141
205
  artifactBase,
142
206
  outputRoot,
143
207
  roots,
144
208
  metadataPath: assertManagedPath(outputRoot, BOOTSTRAP_METADATA_FILE),
209
+ projectMetadataPath: projectMode ? assertManagedPath(projectRoot, PROJECT_METADATA_FILE) : null,
145
210
  repoStubPath: assertManagedPath(targetDir, BOOTSTRAP_REPO_STUB),
146
211
  };
147
212
  }
@@ -152,6 +217,13 @@ function buildBootstrapMetadata(options) {
152
217
  package: 'clean-room-skill',
153
218
  version: packageVersion(),
154
219
  created_at: new Date().toISOString(),
220
+ ...(options.projectId
221
+ ? {
222
+ layout: 'project',
223
+ project_id: options.projectId,
224
+ project_root: options.projectRoot,
225
+ }
226
+ : {}),
155
227
  task_id: options.taskId,
156
228
  target_profile: options.targetProfile,
157
229
  target_dir: options.targetDir,
@@ -167,6 +239,21 @@ function buildBootstrapMetadata(options) {
167
239
  };
168
240
  }
169
241
 
242
+ function buildProjectMetadata(options) {
243
+ return {
244
+ schema: 1,
245
+ package: 'clean-room-skill',
246
+ version: packageVersion(),
247
+ created_at: new Date().toISOString(),
248
+ project_id: options.projectId,
249
+ artifact_base_root: options.artifactBase,
250
+ project_root: options.projectRoot,
251
+ implementation_root: options.roots.implementation,
252
+ tasks_dir: path.join(options.projectRoot, PROJECT_TASKS_DIR),
253
+ note: 'Project metadata only. Tasks are discovered by scanning tasks/; the shared implementation root is the clean destination for every task in this project.',
254
+ };
255
+ }
256
+
170
257
  function renderRepoStub(targetProfile) {
171
258
  return `# Clean Room Bootstrap
172
259
 
@@ -182,19 +269,79 @@ Start the runtime skill from your agent and provide the external output folder p
182
269
  `;
183
270
  }
184
271
 
272
+ function resolveExistingProject(options) {
273
+ if (!options.projectRoot) {
274
+ return { mode: 'none' };
275
+ }
276
+ const projectRootStat = lstatIfExists(options.projectRoot);
277
+ if (!projectRootStat) {
278
+ return { mode: 'new' };
279
+ }
280
+ if (projectRootStat.isSymbolicLink()) {
281
+ throw new Error(`project root must not be a symbolic link: ${options.projectRoot}`);
282
+ }
283
+ if (!projectRootStat.isDirectory()) {
284
+ throw new Error(`project root is not a directory: ${options.projectRoot}`);
285
+ }
286
+ if (options.force) {
287
+ // Warn when adopting a directory that lacks valid project metadata so the
288
+ // operator knows they are reusing existing tasks/ and implementation/ content.
289
+ if (!fs.existsSync(options.projectMetadataPath)) {
290
+ process.stderr.write(`warning: --force is adopting project root without ${PROJECT_METADATA_FILE}: ${options.projectRoot}\n`);
291
+ } else {
292
+ try {
293
+ const adoptErrors = [];
294
+ validateProjectMetadataObject(readJsonFile(options.projectMetadataPath, null), options.projectRoot, adoptErrors);
295
+ if (adoptErrors.length > 0) {
296
+ process.stderr.write(`warning: --force is adopting project root with invalid ${PROJECT_METADATA_FILE}:\n ${adoptErrors.join('\n ')}\n`);
297
+ }
298
+ } catch {
299
+ process.stderr.write(`warning: --force is adopting project root with unreadable ${PROJECT_METADATA_FILE}: ${options.projectRoot}\n`);
300
+ }
301
+ }
302
+ return { mode: 'existing' };
303
+ }
304
+ // During dry-run the metadata is not written, so skip validation that would
305
+ // fail on an imperfect existing project root and just preview the layout.
306
+ if (options.dryRun) {
307
+ return { mode: 'existing' };
308
+ }
309
+ if (!fs.existsSync(options.projectMetadataPath)) {
310
+ throw new Error(`project root exists but ${PROJECT_METADATA_FILE} is missing; use --force to adopt it: ${options.projectRoot}`);
311
+ }
312
+ const metadata = readJsonFile(options.projectMetadataPath, null);
313
+ const errors = [];
314
+ validateProjectMetadataObject(metadata, options.projectRoot, errors);
315
+ if (errors.length > 0) {
316
+ throw new Error(`project root exists but ${PROJECT_METADATA_FILE} is invalid; use --force to adopt it:\n ${errors.join('\n ')}`);
317
+ }
318
+ return { mode: 'existing' };
319
+ }
320
+
185
321
  function assertWritableTargets(options) {
322
+ const projectState = resolveExistingProject(options);
323
+ const joiningExistingProject = projectState.mode === 'existing';
324
+
186
325
  const fileConflicts = [];
187
- for (const filePath of [options.metadataPath, options.repoStubPath]) {
188
- if (fs.existsSync(filePath) && !options.force) {
189
- fileConflicts.push(filePath);
190
- }
326
+ if (fs.existsSync(options.metadataPath) && !options.force) {
327
+ fileConflicts.push(options.metadataPath);
328
+ }
329
+ // A second task joining a project commonly shares the same target repo; its
330
+ // existing stub is reused, not a conflict.
331
+ if (fs.existsSync(options.repoStubPath) && !options.force && !joiningExistingProject) {
332
+ fileConflicts.push(options.repoStubPath);
191
333
  }
192
334
  if (fileConflicts.length > 0) {
193
335
  throw new Error(`bootstrap file already exists; use --force to overwrite: ${fileConflicts.join(', ')}`);
194
336
  }
195
337
 
196
338
  const pathConflicts = [];
197
- for (const dirPath of Object.values(options.roots)) {
339
+ for (const [label, dirPath] of Object.entries(options.roots)) {
340
+ // The project-level implementation root is shared across tasks, so it may
341
+ // already exist once the project metadata has been validated.
342
+ if (label === 'implementation' && joiningExistingProject) {
343
+ continue;
344
+ }
198
345
  if (fs.existsSync(dirPath) && !options.force) {
199
346
  pathConflicts.push(dirPath);
200
347
  }
@@ -209,15 +356,8 @@ function assertWritableTargets(options) {
209
356
  throw new Error(`bootstrap generated path is not a directory: ${dirPath}`);
210
357
  }
211
358
  }
212
- }
213
359
 
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
- }
360
+ return projectState;
221
361
  }
222
362
 
223
363
  function requireDirectory(dirPath, label, errors) {
@@ -242,22 +382,51 @@ function requireFile(filePath, label, errors) {
242
382
  }
243
383
  }
244
384
 
245
- function expectMetadataString(metadata, field, errors) {
385
+ function expectMetadataString(metadata, field, errors, label = 'bootstrap metadata') {
246
386
  if (typeof metadata?.[field] !== 'string' || metadata[field].length === 0) {
247
- errors.push(`bootstrap metadata ${field} must be a non-empty string`);
387
+ errors.push(`${label} ${field} must be a non-empty string`);
248
388
  return null;
249
389
  }
250
390
  return metadata[field];
251
391
  }
252
392
 
253
- function assertMetadataPath(metadata, field, expectedPath, errors) {
254
- const value = expectMetadataString(metadata, field, errors);
393
+ function assertMetadataPath(metadata, field, expectedPath, errors, label = 'bootstrap metadata') {
394
+ const value = expectMetadataString(metadata, field, errors, label);
255
395
  if (!value) return;
256
396
  if (path.resolve(expandTilde(value)) !== expectedPath) {
257
- errors.push(`bootstrap metadata ${field} must match ${expectedPath}`);
397
+ errors.push(`${label} ${field} must match ${expectedPath}`);
398
+ }
399
+ }
400
+
401
+ function validateProjectIdValue(projectId, projectRoot, errors, label) {
402
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
403
+ errors.push(`${label} project_id must match [a-z0-9][a-z0-9-]{0,63}`);
404
+ }
405
+ if (projectId !== path.basename(projectRoot)) {
406
+ errors.push(`${label} project_id must match the project root basename`);
258
407
  }
259
408
  }
260
409
 
410
+ function validateProjectMetadataObject(metadata, projectRoot, errors) {
411
+ const label = 'project metadata';
412
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
413
+ errors.push(`${label} must be an object`);
414
+ return;
415
+ }
416
+ if (metadata.schema !== 1) {
417
+ errors.push(`${label} schema must be 1`);
418
+ }
419
+ if (metadata.package !== 'clean-room-skill') {
420
+ errors.push(`${label} package must be clean-room-skill`);
421
+ }
422
+ const projectId = expectMetadataString(metadata, 'project_id', errors, label);
423
+ if (projectId) {
424
+ validateProjectIdValue(projectId, projectRoot, errors, label);
425
+ }
426
+ assertMetadataPath(metadata, 'project_root', projectRoot, errors, label);
427
+ assertMetadataPath(metadata, 'implementation_root', path.join(projectRoot, BOOTSTRAP_DIRS.implementation), errors, label);
428
+ }
429
+
261
430
  function validateBootstrapScaffold(taskRoot) {
262
431
  if (typeof taskRoot !== 'string' || taskRoot.trim() === '') {
263
432
  throw new Error('bootstrap path requires a task root');
@@ -282,7 +451,8 @@ function validateBootstrapScaffold(taskRoot) {
282
451
 
283
452
  const metadata = readJsonFile(metadataPath, null);
284
453
  const errors = [];
285
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
454
+ const metadataIsObject = Boolean(metadata) && typeof metadata === 'object' && !Array.isArray(metadata);
455
+ if (!metadataIsObject) {
286
456
  errors.push('bootstrap metadata must be an object');
287
457
  } else {
288
458
  if (metadata.schema !== 1) {
@@ -298,10 +468,54 @@ function validateBootstrapScaffold(taskRoot) {
298
468
  assertMetadataPath(metadata, 'output_root', outputRoot, errors);
299
469
  }
300
470
 
471
+ // Project layouts nest the task root under <project>/tasks/ and share one
472
+ // project-level implementation root. Never trust the metadata paths
473
+ // directly: derive the project root from the task root location, then
474
+ // require the metadata to match the derived layout.
475
+ // Use metadata.layout === 'project' as the single authoritative signal:
476
+ // stray project_id/project_root fields on a flat task must not silently flip
477
+ // layout detection and redirect the shared implementation root.
478
+ const projectLayout = metadataIsObject && metadata.layout === 'project';
479
+ let projectRoot = null;
480
+ let projectId = null;
481
+ let projectMetadataPath = null;
482
+ if (projectLayout) {
483
+ if (metadata.layout !== 'project'
484
+ || typeof metadata.project_id !== 'string'
485
+ || typeof metadata.project_root !== 'string') {
486
+ errors.push('bootstrap metadata project layout requires layout "project", project_id, and project_root');
487
+ }
488
+ const tasksDir = path.dirname(outputRoot);
489
+ if (path.basename(tasksDir) !== PROJECT_TASKS_DIR) {
490
+ errors.push(`bootstrap project task root must live under a ${PROJECT_TASKS_DIR}/ directory: ${outputRoot}`);
491
+ } else {
492
+ projectRoot = path.dirname(tasksDir);
493
+ requireDirectory(projectRoot, 'project root', errors);
494
+ assertMetadataPath(metadata, 'project_root', projectRoot, errors);
495
+ projectId = expectMetadataString(metadata, 'project_id', errors);
496
+ if (projectId) {
497
+ validateProjectIdValue(projectId, projectRoot, errors, 'bootstrap metadata');
498
+ }
499
+ try {
500
+ projectMetadataPath = assertManagedPath(projectRoot, PROJECT_METADATA_FILE);
501
+ } catch (err) {
502
+ errors.push(err.message);
503
+ }
504
+ if (projectMetadataPath) {
505
+ requireFile(projectMetadataPath, 'project metadata', errors);
506
+ if (fs.existsSync(projectMetadataPath)) {
507
+ validateProjectMetadataObject(readJsonFile(projectMetadataPath, null), projectRoot, errors);
508
+ }
509
+ }
510
+ }
511
+ }
512
+
301
513
  const roots = {
302
514
  contaminated: path.join(outputRoot, BOOTSTRAP_DIRS.contaminated),
303
515
  clean: path.join(outputRoot, BOOTSTRAP_DIRS.clean),
304
- implementation: path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
516
+ implementation: projectRoot
517
+ ? path.join(projectRoot, BOOTSTRAP_DIRS.implementation)
518
+ : path.join(outputRoot, BOOTSTRAP_DIRS.implementation),
305
519
  quarantine: path.join(outputRoot, BOOTSTRAP_DIRS.quarantine),
306
520
  };
307
521
  for (const [label, dirPath] of Object.entries(roots)) {
@@ -337,6 +551,9 @@ function validateBootstrapScaffold(taskRoot) {
337
551
  metadata,
338
552
  roots,
339
553
  repoStubPath,
554
+ projectRoot,
555
+ projectId,
556
+ projectMetadataPath,
340
557
  };
341
558
  }
342
559
 
@@ -375,29 +592,88 @@ function writeBootstrapFile(filePath, data, force) {
375
592
  }
376
593
  }
377
594
 
595
+ function writeProjectMetadataFile(options) {
596
+ const metadata = buildProjectMetadata(options);
597
+ if (options.force) {
598
+ // A forced rewrite refreshes the metadata but keeps the project's
599
+ // original creation time when the prior file is readable. --force is
600
+ // also the documented way to adopt invalid metadata, so parse failures
601
+ // fall back to the fresh timestamp instead of aborting.
602
+ try {
603
+ const existing = readJsonFile(options.projectMetadataPath, null);
604
+ if (typeof existing?.created_at === 'string' && existing.created_at.length > 0) {
605
+ metadata.created_at = existing.created_at;
606
+ }
607
+ } catch {
608
+ // Keep the fresh created_at when existing metadata is unreadable.
609
+ }
610
+ atomicWriteFile(options.projectMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
611
+ return;
612
+ }
613
+ const data = `${JSON.stringify(metadata, null, 2)}\n`;
614
+ try {
615
+ atomicWriteFileNoOverwrite(options.projectMetadataPath, data, 'utf8');
616
+ } catch (err) {
617
+ if (err?.code !== 'EEXIST') {
618
+ throw err;
619
+ }
620
+ // Joining an existing project, or losing a creation race: exactly one
621
+ // writer wins the no-overwrite link; everyone else validates the winner's
622
+ // metadata and proceeds without rewriting it.
623
+ const errors = [];
624
+ validateProjectMetadataObject(readJsonFile(options.projectMetadataPath, null), options.projectRoot, errors);
625
+ if (errors.length > 0) {
626
+ throw new Error(`existing ${PROJECT_METADATA_FILE} is invalid; use --force to adopt it:\n ${errors.join('\n ')}`);
627
+ }
628
+ }
629
+ }
630
+
378
631
  function applyBootstrap(options) {
379
- assertWritableTargets(options);
632
+ const projectState = assertWritableTargets(options);
380
633
  if (!options.dryRun) {
634
+ if (options.projectRoot) {
635
+ fs.mkdirSync(path.join(options.projectRoot, PROJECT_TASKS_DIR), { recursive: true });
636
+ }
381
637
  for (const dir of Object.values(options.roots)) {
382
638
  fs.mkdirSync(dir, { recursive: true });
383
639
  }
640
+ if (options.projectRoot) {
641
+ writeProjectMetadataFile(options);
642
+ }
384
643
  const metadata = `${JSON.stringify(buildBootstrapMetadata(options), null, 2)}\n`;
385
644
  writeBootstrapFile(options.metadataPath, metadata, options.force);
386
- writeBootstrapFile(options.repoStubPath, renderRepoStub(options.targetProfile), options.force);
645
+ if (options.force || !fs.existsSync(options.repoStubPath)) {
646
+ writeBootstrapFile(options.repoStubPath, renderRepoStub(options.targetProfile), options.force);
647
+ }
387
648
  }
388
- printInitResult(options);
649
+ printInitResult(options, projectState);
389
650
  }
390
651
 
391
- function printInitResult(options) {
652
+ function printInitResult(options, projectState = { mode: 'none' }) {
392
653
  const verb = options.dryRun ? 'Would create' : 'Created';
393
654
  console.log(`${verb} clean-room bootstrap`);
655
+ if (options.projectId) {
656
+ const projectLabel = projectState.mode === 'existing' ? 'existing' : 'new';
657
+ console.log(` project: ${options.projectId} (${projectLabel})`);
658
+ console.log(` project root: ${options.projectRoot}`);
659
+ }
394
660
  console.log(` output folder: ${options.outputRoot}`);
395
661
  console.log(` contaminated artifacts: ${options.roots.contaminated}`);
396
662
  console.log(` clean artifacts: ${options.roots.clean}`);
397
- console.log(` implementation root: ${options.roots.implementation}`);
663
+ if (options.projectId) {
664
+ console.log(` implementation root (shared): ${options.roots.implementation}`);
665
+ } else {
666
+ console.log(` implementation root: ${options.roots.implementation}`);
667
+ }
398
668
  console.log(` quarantine: ${options.roots.quarantine}`);
399
669
  console.log(` metadata: ${options.metadataPath}`);
670
+ if (options.projectId) {
671
+ console.log(` project metadata: ${options.projectMetadataPath}`);
672
+ }
400
673
  console.log(` repo stub: ${options.repoStubPath}`);
674
+ if (options.projectId && projectState.mode === 'existing') {
675
+ console.log(' note: shared repo stub kept from first task; --target-profile for this task is in per-task metadata');
676
+ }
401
677
  console.log('');
402
678
  console.log('Next steps:');
403
679
  console.log(' Codex:');
@@ -429,7 +705,9 @@ function runInit(argv, context = {}) {
429
705
 
430
706
  module.exports = {
431
707
  BOOTSTRAP_METADATA_FILE,
708
+ PROJECT_METADATA_FILE,
432
709
  defaultArtifactBase,
710
+ generateProjectId,
433
711
  generateTaskId,
434
712
  parseInitArgs,
435
713
  resolveBootstrapScaffold,
package/lib/fs-utils.cjs CHANGED
@@ -162,6 +162,7 @@ function listFiles(root, options = {}) {
162
162
  return [];
163
163
  }
164
164
  const ignoreNames = new Set(options.ignoreNames || []);
165
+ const ignoreNamePrefixes = options.ignoreNamePrefixes || [];
165
166
  const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 64;
166
167
  const maxFiles = Number.isInteger(options.maxFiles) ? options.maxFiles : 10000;
167
168
  const files = [];
@@ -180,6 +181,9 @@ function listFiles(root, options = {}) {
180
181
  if (ignoreNames.has(entry.name)) {
181
182
  continue;
182
183
  }
184
+ if (ignoreNamePrefixes.length > 0 && ignoreNamePrefixes.some((p) => entry.name.startsWith(p))) {
185
+ continue;
186
+ }
183
187
  const fullPath = path.join(dir, entry.name);
184
188
  const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
185
189
  if (entry.isDirectory()) {
@@ -217,6 +221,7 @@ module.exports = {
217
221
  atomicWriteFileNoOverwrite,
218
222
  fileHash,
219
223
  listFiles,
224
+ lstatIfExists,
220
225
  normalizeRelativePath,
221
226
  readJsonFile,
222
227
  removeEmptyParents,
@@ -6,6 +6,13 @@ 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
+ // Stale lock recovery renames the lock dir to <name>.stale.<ts>.<pid> rather than
11
+ // removing it, so the original can be inspected post-mortem. Filter these orphans
12
+ // out of implementation-root scans with IMPLEMENTATION_LOCK_STALE_PREFIX.
13
+ const IMPLEMENTATION_LOCK_STALE_PREFIX = `${IMPLEMENTATION_LOCK_NAME}.stale.`;
14
+ const IMPLEMENTATION_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_IMPLEMENTATION_LOCK_WAIT_MS', 30_000);
15
+ const IMPLEMENTATION_LOCK_POLL_MS = 100;
9
16
  const RUN_HOOK_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_RUN_HOOK_TIMEOUT_MS', 30_000);
10
17
  const LEDGER_NAME = 'controller-run-ledger.json';
11
18
  const RESULT_NAME = 'clean-room-result.json';
@@ -97,6 +104,7 @@ const VOLATILE_PROGRESS_KEYS = new Set([
97
104
 
98
105
  const IMPLEMENTATION_IGNORE_NAMES = Object.freeze([
99
106
  '.git',
107
+ IMPLEMENTATION_LOCK_NAME,
100
108
  'node_modules',
101
109
  'target',
102
110
  ]);
@@ -151,6 +159,10 @@ module.exports = {
151
159
  HANDOFF_PACKAGE_NAME,
152
160
  HOOK_ONLY_ENV_ALLOWLIST,
153
161
  IMPLEMENTATION_IGNORE_NAMES,
162
+ IMPLEMENTATION_LOCK_NAME,
163
+ IMPLEMENTATION_LOCK_POLL_MS,
164
+ IMPLEMENTATION_LOCK_STALE_PREFIX,
165
+ IMPLEMENTATION_LOCK_WAIT_MS,
154
166
  LEDGER_NAME,
155
167
  MAX_LEDGER_ITERATIONS,
156
168
  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 = {
@@ -12,6 +12,7 @@ const {
12
12
  const {
13
13
  CLEAN_ROOM_ARTIFACT_PREFIXES,
14
14
  IMPLEMENTATION_IGNORE_NAMES,
15
+ IMPLEMENTATION_LOCK_STALE_PREFIX,
15
16
  LEDGER_NAME,
16
17
  STATUS_NAME,
17
18
  VOLATILE_PROGRESS_KEYS,
@@ -36,7 +37,7 @@ function misplacedImplementationArtifacts(roots) {
36
37
  const misplaced = [];
37
38
  for (const root of roots.implementationRoots) {
38
39
  if (!root || !fs.existsSync(root)) continue;
39
- for (const relPath of listFiles(root, { ignoreNames: IMPLEMENTATION_IGNORE_NAMES })) {
40
+ for (const relPath of listFiles(root, { ignoreNames: IMPLEMENTATION_IGNORE_NAMES, ignoreNamePrefixes: [IMPLEMENTATION_LOCK_STALE_PREFIX] })) {
40
41
  if (isCleanRoomArtifactName(path.basename(relPath))) {
41
42
  misplaced.push(path.join(root, relPath));
42
43
  }
@@ -115,7 +116,7 @@ function semanticProgressSnapshot(manifestPath, roots) {
115
116
  }
116
117
  roots.implementationRoots.forEach((root, rootIndex) => {
117
118
  if (!root || !fs.existsSync(root)) return;
118
- for (const relPath of listFiles(root, { ignoreNames: IMPLEMENTATION_IGNORE_NAMES })) {
119
+ for (const relPath of listFiles(root, { ignoreNames: IMPLEMENTATION_IGNORE_NAMES, ignoreNamePrefixes: [IMPLEMENTATION_LOCK_STALE_PREFIX] })) {
119
120
  const filePath = path.join(root, relPath);
120
121
  entries[`implementation:${rootIndex}:${relPath}`] = fileHash(filePath);
121
122
  }
@@ -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.1",
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.1",
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`.