clean-room-skill 0.2.2 → 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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +5 -1
  5. package/agents/clean-architect.md +4 -0
  6. package/agents/clean-implementer-verifier-shell.md +4 -0
  7. package/agents/clean-polish-reviewer.md +4 -0
  8. package/agents/clean-qa-editor.md +4 -0
  9. package/agents/contaminated-handoff-sanitizer.md +4 -0
  10. package/agents/contaminated-manager-verifier.md +4 -0
  11. package/agents/contaminated-source-analyst.md +4 -0
  12. package/docs/ARCHITECTURE.md +2 -0
  13. package/docs/REFERENCE.md +28 -1
  14. package/lib/bootstrap.cjs +282 -30
  15. package/lib/fs-utils.cjs +1 -0
  16. package/lib/run-cli.cjs +1 -1
  17. package/lib/run-constants.cjs +7 -0
  18. package/lib/run-controller.cjs +38 -3
  19. package/lib/run-hooks.cjs +44 -11
  20. package/lib/run-results.cjs +23 -0
  21. package/lib/run-roots.cjs +56 -11
  22. package/package.json +1 -1
  23. package/plugin.json +1 -1
  24. package/skills/attended/SKILL.md +2 -1
  25. package/skills/clean-room/SKILL.md +6 -4
  26. package/skills/clean-room/assets/init-config.schema.json +8 -0
  27. package/skills/clean-room/assets/task-manifest.schema.json +16 -0
  28. package/skills/clean-room/references/LEAKAGE-RULES.md +2 -0
  29. package/skills/clean-room/references/PREFLIGHT.md +1 -0
  30. package/skills/clean-room/references/PROCESS.md +4 -1
  31. package/skills/clean-room/references/SPEC-SCHEMA.md +3 -0
  32. package/skills/init/SKILL.md +3 -2
  33. package/skills/preflight/SKILL.md +2 -1
  34. package/skills/resume-cr/SKILL.md +3 -2
  35. package/skills/start-over/SKILL.md +2 -0
  36. package/skills/unattended/SKILL.md +3 -2
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,
package/lib/run-cli.cjs CHANGED
@@ -16,7 +16,7 @@ Options:
16
16
  --max-iterations <n> Lower the manifest/loop iteration cap
17
17
  --once Run at most one inner iteration
18
18
  --dry-run Validate and print the selected unit without writing or spawning agents
19
- --schema-dir <path> Schema directory override
19
+ --schema-dir <path> Schema directory override; omit to use bundled schemas
20
20
  --python <path> Python executable for bundled validation hooks (default: python3)
21
21
  -h, --help Show this help
22
22
  `);
@@ -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,
@@ -43,6 +43,7 @@ const {
43
43
  resolvePath,
44
44
  resolveRoots,
45
45
  validateTaskManifestLocation,
46
+ validateSchemaDir,
46
47
  verifyPreflightGoal,
47
48
  } = require('./run-roots.cjs');
48
49
  const {
@@ -53,6 +54,7 @@ const {
53
54
  loadLedger,
54
55
  noProgressResult,
55
56
  stageFailureResult,
57
+ withImplementationRootLocks,
56
58
  withRunLock,
57
59
  writeLedger,
58
60
  writeResult,
@@ -144,6 +146,35 @@ function markStageFailed(stageResult, error) {
144
146
  : message;
145
147
  }
146
148
 
149
+ function inferredTaskManifestCandidate(taskManifestPath) {
150
+ if (fs.existsSync(taskManifestPath)) {
151
+ const stat = fs.statSync(taskManifestPath);
152
+ if (stat.isDirectory()) {
153
+ return path.join(taskManifestPath, 'contaminated', 'task-manifest.json');
154
+ }
155
+ }
156
+ if (path.basename(taskManifestPath) !== 'task-manifest.json') {
157
+ return null;
158
+ }
159
+ const parent = path.dirname(taskManifestPath);
160
+ if (path.basename(parent) === 'contaminated') {
161
+ return taskManifestPath;
162
+ }
163
+ return path.join(parent, 'contaminated', 'task-manifest.json');
164
+ }
165
+
166
+ function taskManifestNotFoundMessage(taskManifestPath) {
167
+ const parts = [
168
+ `task manifest not found: ${taskManifestPath}`,
169
+ 'expected task manifest layout: <task-root>/contaminated/task-manifest.json',
170
+ ];
171
+ const candidate = inferredTaskManifestCandidate(taskManifestPath);
172
+ if (candidate && candidate !== taskManifestPath) {
173
+ parts.push(`candidate path: ${candidate}`);
174
+ }
175
+ return parts.join('; ');
176
+ }
177
+
147
178
  async function runCleanRoom(options, context = {}) {
148
179
  if (options.help) {
149
180
  printRunHelp();
@@ -161,10 +192,14 @@ async function runCleanRoom(options, context = {}) {
161
192
 
162
193
  const taskManifestPath = resolvePath(options.taskManifest, context.cwd || process.cwd());
163
194
  if (!fs.existsSync(taskManifestPath)) {
164
- throw new Error(`task manifest not found: ${taskManifestPath}`);
195
+ throw new Error(taskManifestNotFoundMessage(taskManifestPath));
196
+ }
197
+ if (fs.statSync(taskManifestPath).isDirectory()) {
198
+ throw new Error(taskManifestNotFoundMessage(taskManifestPath));
165
199
  }
166
200
  const manifestDir = path.dirname(taskManifestPath);
167
201
  const schemaDir = options.schemaDir ? resolvePath(options.schemaDir, context.cwd || process.cwd()) : defaultSchemaDir();
202
+ validateSchemaDir(schemaDir, Boolean(options.schemaDir));
168
203
  validateTaskManifestSchema(options.python, taskManifestPath, schemaDir);
169
204
  const manifest = readJsonFile(taskManifestPath, null);
170
205
  validateTaskManifestForRun(manifest);
@@ -177,7 +212,7 @@ async function runCleanRoom(options, context = {}) {
177
212
  ? { agentConfig: null, configDir: process.cwd() }
178
213
  : resolveAgentConfig(options, context, roots, manifest, agentConfigPath);
179
214
 
180
- return withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
215
+ return withImplementationRootLocks(roots.implementationRoots, options.dryRun, () => withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
181
216
  const coverageLedgerPath = path.join(roots.contaminatedRoot, 'coverage-ledger.json');
182
217
  const coverageLedger = validateRunState(options, taskManifestPath, roots, manifest, coverageLedgerPath);
183
218
  const selectedUnit = selectUnit(manifest, coverageLedger);
@@ -340,7 +375,7 @@ async function runCleanRoom(options, context = {}) {
340
375
  validateFoundationCoverageGate(resultManifest, finalCoverageLedger);
341
376
  console.log(`clean-room run: ${terminalResult.result}`);
342
377
  return terminalResult;
343
- });
378
+ }));
344
379
  }
345
380
 
346
381
  module.exports = {