clean-room-skill 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "name": "clean-room",
10
10
  "source": "./",
11
11
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
12
- "version": "0.3.0",
12
+ "version": "0.4.0",
13
13
  "author": {
14
14
  "name": "whit3rabbit"
15
15
  },
@@ -2,7 +2,7 @@
2
2
  "name": "clean-room",
3
3
  "displayName": "Clean Room",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
- "version": "0.3.0",
5
+ "version": "0.4.0",
6
6
  "author": {
7
7
  "name": "whit3rabbit"
8
8
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
package/README.md CHANGED
@@ -107,9 +107,9 @@ Optionally create neutral external run folders and a clean-safe repository stub:
107
107
  npx clean-room-skill@latest init
108
108
  ```
109
109
 
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.
110
+ By default this creates a neutral clean-room project. The default task root is `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with per-task `contaminated/`, `clean/`, and `quarantine/` children, plus one shared `~/Documents/CleanRoom/<project>/implementation/` root. Use `init --single-task` only when you need the legacy flat `~/Documents/CleanRoom/<task-id>/` layout.
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.
112
+ To add another task to the same destination project, pass `init --project <name>` with the existing neutral project name. Plain `init` creates a new generated `proj-xxxxxxxx` 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
113
 
114
114
  In Claude Code, invoke skills with the plugin namespace:
115
115
 
@@ -62,7 +62,9 @@ To assist in logical unit decomposition, the workflow supports an optional sourc
62
62
 
63
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.
64
64
 
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.
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.
66
68
 
67
69
  ---
68
70
 
package/docs/REFERENCE.md CHANGED
@@ -165,6 +165,7 @@ 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
167
  npx clean-room-skill@latest init --project amber-meadow --task-id task-1234abcd
168
+ npx clean-room-skill@latest init --single-task --task-id task-1234abcd
168
169
  ```
169
170
 
170
171
  Options:
@@ -174,22 +175,14 @@ Options:
174
175
  | `--target-dir <path>` | Repository to initialize; default is current directory. |
175
176
  | `--artifact-base <path>` | External CleanRoom base; default is `~/Documents/CleanRoom`. |
176
177
  | `--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`. |
178
+ | `--project <name>` | Group the task under a named 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). |
179
+ | `--new-project` | Explicitly create a project with a generated `proj-xxxxxxxx` name. This is the default unless `--single-task` is passed. Cannot be combined with `--project`. |
180
+ | `--single-task` | Use the legacy flat layout under `<artifact-base>/<task-id>`. Cannot be combined with `--project` or `--new-project`. |
179
181
  | `--target-profile <name>` | `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`. |
180
182
  | `--dry-run` | Print actions without writing files. |
181
183
  | `--force` | Overwrite existing bootstrap metadata and repo stub. |
182
184
 
183
- By default, `init` creates a single-task layout under `<artifact-base>/<task-id>/`:
184
-
185
- - `contaminated/`
186
- - `clean/`
187
- - `implementation/`
188
- - `quarantine/`
189
- - `clean-room-bootstrap.json`
190
- - `.clean-room/README.md` in the target repository
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:
185
+ By default, `init` creates a project layout. Plain `init` creates a new generated `proj-xxxxxxxx` project; pass `--project <name>` to add a task to an existing project. 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
186
 
194
187
  ```text
195
188
  <artifact-base>/<project>/
@@ -203,6 +196,15 @@ With `--project` or `--new-project`, `init` creates a project layout instead. Ta
203
196
  └── clean-room-bootstrap.json
204
197
  ```
205
198
 
199
+ With `--single-task`, `init` creates the legacy flat layout under `<artifact-base>/<task-id>/`:
200
+
201
+ - `contaminated/`
202
+ - `clean/`
203
+ - `implementation/`
204
+ - `quarantine/`
205
+ - `clean-room-bootstrap.json`
206
+ - `.clean-room/README.md` in the target repository
207
+
206
208
  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
209
 
208
210
  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.
package/lib/bootstrap.cjs CHANGED
@@ -56,13 +56,16 @@ Options:
56
56
  --target-dir <path> Repository to initialize (default: current directory)
57
57
  --artifact-base <path> External CleanRoom base (default: ~/Documents/CleanRoom)
58
58
  --task-id <id> Neutral task id (default: generated task-xxxxxxxx)
59
- --project <name> Group this task under a clean-room project; joins the
59
+ --project <name> Group this task under a named clean-room project; joins the
60
60
  project when it already exists. Project names must be
61
61
  neutral ([a-z0-9][a-z0-9-]{0,63}) and never derived
62
62
  from source or workspace folder names. Project layout:
63
63
  <base>/<project>/tasks/<task-id> with one shared
64
64
  <base>/<project>/implementation root for all tasks
65
- --new-project Create a project with a generated proj-xxxxxxxx name
65
+ --new-project Explicitly create a project with a generated
66
+ proj-xxxxxxxx name (the default unless --single-task
67
+ is passed)
68
+ --single-task Use the legacy flat layout under <base>/<task-id>
66
69
  --target-profile <name> openspec-delta, gsd-planning-package,
67
70
  speckit-feature-folder, or kiro-spec-folder
68
71
  (default: speckit-feature-folder)
@@ -79,6 +82,7 @@ function parseInitArgs(argv) {
79
82
  taskId: null,
80
83
  projectId: null,
81
84
  newProject: false,
85
+ singleTask: false,
82
86
  targetProfile: 'speckit-feature-folder',
83
87
  dryRun: false,
84
88
  force: false,
@@ -110,6 +114,8 @@ function parseInitArgs(argv) {
110
114
  options.taskId = arg.slice('--task-id='.length);
111
115
  } else if (arg === '--new-project') {
112
116
  options.newProject = true;
117
+ } else if (arg === '--single-task') {
118
+ options.singleTask = true;
113
119
  } else if (arg === '--project') {
114
120
  i += 1;
115
121
  options.projectId = requiredValue(argv, i, '--project');
@@ -130,6 +136,9 @@ function parseInitArgs(argv) {
130
136
  if (options.projectId !== null && options.newProject) {
131
137
  throw new Error('--project and --new-project cannot be combined');
132
138
  }
139
+ if (options.singleTask && (options.projectId !== null || options.newProject)) {
140
+ throw new Error('--single-task cannot be combined with --project or --new-project');
141
+ }
133
142
 
134
143
  return options;
135
144
  }
@@ -148,10 +157,12 @@ function normalizeNameToken(value) {
148
157
  function assertNeutralProjectId(projectId, targetDir) {
149
158
  const projectToken = normalizeNameToken(projectId);
150
159
  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.
160
+ // Clause 2 (project contains workspace token) is the primary leakage vector and
161
+ // needs a lower minimum length so that short-but-meaningful workspace names (len 2+)
162
+ // are still caught. Clause 3 keeps the >=4 guard: a short project token included in
163
+ // a long workspace token is a weak signal and would produce too many false rejections.
153
164
  const overlapping = projectToken === targetToken
154
- || (targetToken.length >= 4 && projectToken.includes(targetToken))
165
+ || (targetToken.length >= 2 && projectToken.includes(targetToken))
155
166
  || (projectToken.length >= 4 && targetToken.includes(projectToken));
156
167
  if (overlapping) {
157
168
  throw new Error('--project must be a neutral name; do not derive project names from workspace or source folder names');
@@ -170,7 +181,7 @@ function resolveInitOptions(options, env = process.env, homeDir = os.homedir())
170
181
  const targetDir = path.resolve(expandTilde(options.targetDir, homeDir));
171
182
  const artifactBase = path.resolve(expandTilde(options.artifactBase, homeDir));
172
183
 
173
- const projectMode = options.projectId !== null || options.newProject === true;
184
+ const projectMode = !options.singleTask;
174
185
  const projectId = projectMode ? (options.projectId || generateProjectId()) : null;
175
186
  if (projectMode) {
176
187
  if (!PROJECT_ID_PATTERN.test(projectId)) {
@@ -252,18 +263,21 @@ function buildProjectMetadata(options) {
252
263
  };
253
264
  }
254
265
 
255
- function renderRepoStub(targetProfile) {
266
+ function renderRepoStub(options) {
267
+ const layoutDescription = options.projectId
268
+ ? 'The bootstrap task root contains per-task `contaminated/`, `clean/`, and `quarantine/` directories. The project root contains the shared `implementation/` clean destination. Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, or active `init-config.json`, `task-manifest.json`, or `clean-run-context.json` files here.'
269
+ : 'The bootstrap task root contains `contaminated/`, `clean/`, `implementation/`, and `quarantine/`. Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, or active `init-config.json`, `task-manifest.json`, or `clean-run-context.json` files here.';
256
270
  return `# Clean Room Bootstrap
257
271
 
258
272
  This repository has a clean-room bootstrap stub.
259
273
 
260
- Active clean-room run artifacts are stored outside this repository. The bootstrap task root contains \`contaminated/\`, \`clean/\`, \`implementation/\`, and \`quarantine/\`. Do not commit source roots, contaminated artifact paths, private identifiers, source-derived names, or active \`init-config.json\`, \`task-manifest.json\`, or \`clean-run-context.json\` files here.
274
+ Active clean-room run artifacts are stored outside this repository. ${layoutDescription}
261
275
 
262
276
  The final clean polish stage may create or update implementation-root \`AGENTS.md\`, \`.gitignore\`, and one local git commit through the bounded Agent 4 polish runner. That commit belongs to the clean implementation root, not to contaminated artifacts or source roots.
263
277
 
264
- Default target profile: \`${targetProfile}\`
278
+ Default target profile: \`${options.targetProfile}\`
265
279
 
266
- Start the runtime skill from your agent and provide the external output folder printed by \`clean-room-skill init\`.
280
+ Start the runtime skill from your agent and provide the external task root printed by \`clean-room-skill init\`.
267
281
  `;
268
282
  }
269
283
 
@@ -282,6 +296,26 @@ function resolveExistingProject(options) {
282
296
  throw new Error(`project root is not a directory: ${options.projectRoot}`);
283
297
  }
284
298
  if (options.force) {
299
+ // Warn when adopting a directory that lacks valid project metadata so the
300
+ // operator knows they are reusing existing tasks/ and implementation/ content.
301
+ if (!fs.existsSync(options.projectMetadataPath)) {
302
+ process.stderr.write(`warning: --force is adopting project root without ${PROJECT_METADATA_FILE}: ${options.projectRoot}\n`);
303
+ } else {
304
+ try {
305
+ const adoptErrors = [];
306
+ validateProjectMetadataObject(readJsonFile(options.projectMetadataPath, null), options.projectRoot, adoptErrors);
307
+ if (adoptErrors.length > 0) {
308
+ process.stderr.write(`warning: --force is adopting project root with invalid ${PROJECT_METADATA_FILE}:\n ${adoptErrors.join('\n ')}\n`);
309
+ }
310
+ } catch {
311
+ process.stderr.write(`warning: --force is adopting project root with unreadable ${PROJECT_METADATA_FILE}: ${options.projectRoot}\n`);
312
+ }
313
+ }
314
+ return { mode: 'existing' };
315
+ }
316
+ // During dry-run the metadata is not written, so skip validation that would
317
+ // fail on an imperfect existing project root and just preview the layout.
318
+ if (options.dryRun) {
285
319
  return { mode: 'existing' };
286
320
  }
287
321
  if (!fs.existsSync(options.projectMetadataPath)) {
@@ -376,6 +410,15 @@ function assertMetadataPath(metadata, field, expectedPath, errors, label = 'boot
376
410
  }
377
411
  }
378
412
 
413
+ function validateProjectIdValue(projectId, projectRoot, errors, label) {
414
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
415
+ errors.push(`${label} project_id must match [a-z0-9][a-z0-9-]{0,63}`);
416
+ }
417
+ if (projectId !== path.basename(projectRoot)) {
418
+ errors.push(`${label} project_id must match the project root basename`);
419
+ }
420
+ }
421
+
379
422
  function validateProjectMetadataObject(metadata, projectRoot, errors) {
380
423
  const label = 'project metadata';
381
424
  if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
@@ -390,12 +433,7 @@ function validateProjectMetadataObject(metadata, projectRoot, errors) {
390
433
  }
391
434
  const projectId = expectMetadataString(metadata, 'project_id', errors, label);
392
435
  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
- }
436
+ validateProjectIdValue(projectId, projectRoot, errors, label);
399
437
  }
400
438
  assertMetadataPath(metadata, 'project_root', projectRoot, errors, label);
401
439
  assertMetadataPath(metadata, 'implementation_root', path.join(projectRoot, BOOTSTRAP_DIRS.implementation), errors, label);
@@ -446,8 +484,10 @@ function validateBootstrapScaffold(taskRoot) {
446
484
  // project-level implementation root. Never trust the metadata paths
447
485
  // directly: derive the project root from the task root location, then
448
486
  // require the metadata to match the derived layout.
449
- const projectLayout = metadataIsObject
450
- && (metadata.layout !== undefined || metadata.project_id !== undefined || metadata.project_root !== undefined);
487
+ // Use metadata.layout === 'project' as the single authoritative signal:
488
+ // stray project_id/project_root fields on a flat task must not silently flip
489
+ // layout detection and redirect the shared implementation root.
490
+ const projectLayout = metadataIsObject && metadata.layout === 'project';
451
491
  let projectRoot = null;
452
492
  let projectId = null;
453
493
  let projectMetadataPath = null;
@@ -466,12 +506,7 @@ function validateBootstrapScaffold(taskRoot) {
466
506
  assertMetadataPath(metadata, 'project_root', projectRoot, errors);
467
507
  projectId = expectMetadataString(metadata, 'project_id', errors);
468
508
  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
- }
509
+ validateProjectIdValue(projectId, projectRoot, errors, 'bootstrap metadata');
475
510
  }
476
511
  try {
477
512
  projectMetadataPath = assertManagedPath(projectRoot, PROJECT_METADATA_FILE);
@@ -620,7 +655,7 @@ function applyBootstrap(options) {
620
655
  const metadata = `${JSON.stringify(buildBootstrapMetadata(options), null, 2)}\n`;
621
656
  writeBootstrapFile(options.metadataPath, metadata, options.force);
622
657
  if (options.force || !fs.existsSync(options.repoStubPath)) {
623
- writeBootstrapFile(options.repoStubPath, renderRepoStub(options.targetProfile), options.force);
658
+ writeBootstrapFile(options.repoStubPath, renderRepoStub(options), options.force);
624
659
  }
625
660
  }
626
661
  printInitResult(options, projectState);
@@ -634,7 +669,7 @@ function printInitResult(options, projectState = { mode: 'none' }) {
634
669
  console.log(` project: ${options.projectId} (${projectLabel})`);
635
670
  console.log(` project root: ${options.projectRoot}`);
636
671
  }
637
- console.log(` output folder: ${options.outputRoot}`);
672
+ console.log(` task root: ${options.outputRoot}`);
638
673
  console.log(` contaminated artifacts: ${options.roots.contaminated}`);
639
674
  console.log(` clean artifacts: ${options.roots.clean}`);
640
675
  if (options.projectId) {
@@ -648,6 +683,9 @@ function printInitResult(options, projectState = { mode: 'none' }) {
648
683
  console.log(` project metadata: ${options.projectMetadataPath}`);
649
684
  }
650
685
  console.log(` repo stub: ${options.repoStubPath}`);
686
+ if (options.projectId && projectState.mode === 'existing') {
687
+ console.log(' note: shared repo stub kept from first task; --target-profile for this task is in per-task metadata');
688
+ }
651
689
  console.log('');
652
690
  console.log('Next steps:');
653
691
  console.log(' Codex:');
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()) {
@@ -4,6 +4,7 @@ const { runInit } = require('./bootstrap.cjs');
4
4
  const { runDoctor } = require('./doctor.cjs');
5
5
  const { runPreflight } = require('./preflight.cjs');
6
6
  const { parseRunArgs, runCleanRoom } = require('./run.cjs');
7
+ const { packageVersion } = require('./install-artifacts.cjs');
7
8
  const { resolveInteractiveOptions } = require('./install-tui.cjs');
8
9
  const {
9
10
  operationForOptions,
@@ -22,6 +23,10 @@ const {
22
23
 
23
24
  async function main() {
24
25
  const argv = process.argv.slice(2);
26
+ if (argv.length === 1 && argv[0] === '--version') {
27
+ console.log(packageVersion());
28
+ return;
29
+ }
25
30
  if (argv[0] === 'init') {
26
31
  runInit(argv.slice(1));
27
32
  return;
@@ -117,6 +117,7 @@ Options:
117
117
  --dry-run Print actions without writing files
118
118
  --yes Non-interactive mode; unknown conflicts still abort
119
119
  --uninstall Remove manifest-managed files and clean-room hook entries
120
+ --version Print the installed clean-room-skill version
120
121
 
121
122
  Run without runtime and scope flags for interactive install or uninstall.
122
123
  Interactive runtime selection accepts names, numbers, ranges, all, or installed.
@@ -7,6 +7,10 @@ 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
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.`;
10
14
  const IMPLEMENTATION_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_IMPLEMENTATION_LOCK_WAIT_MS', 30_000);
11
15
  const IMPLEMENTATION_LOCK_POLL_MS = 100;
12
16
  const RUN_HOOK_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_RUN_HOOK_TIMEOUT_MS', 30_000);
@@ -157,6 +161,7 @@ module.exports = {
157
161
  IMPLEMENTATION_IGNORE_NAMES,
158
162
  IMPLEMENTATION_LOCK_NAME,
159
163
  IMPLEMENTATION_LOCK_POLL_MS,
164
+ IMPLEMENTATION_LOCK_STALE_PREFIX,
160
165
  IMPLEMENTATION_LOCK_WAIT_MS,
161
166
  LEDGER_NAME,
162
167
  MAX_LEDGER_ITERATIONS,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room-skill",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "bin": {
6
6
  "clean-room-skill": "bin/install.js"
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clean-room",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
5
  "author": {
6
6
  "name": "whit3rabbit"
@@ -20,8 +20,8 @@ Load or create `preflight-goal.json` first. Attended mode may continue with unre
20
20
  Gather only required setup facts:
21
21
 
22
22
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
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.
23
+ - Artifact base root, defaulting the task root to `~/Documents/CleanRoom/<project>/tasks/<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
+ - Project grouping, following the canonical `clean-room` project layout rules: `<base>/<project>/tasks/<task-id>/` with one shared `<base>/<project>/implementation/` root, a neutral project name (`proj-` plus 8 lowercase hex unless the user supplies an approved neutral name, matching `[a-z0-9][a-z0-9-]{0,63}`, never source-derived), and at most one active task per project. Use legacy flat `<base>/<task-id>/` roots only when the user explicitly chooses single-task compatibility.
25
25
  - Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
26
26
  - Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
27
27
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
@@ -98,8 +98,8 @@ Load or create `preflight-goal.json` only after this discovery step. Do not star
98
98
  Gather only the setup facts needed to decide whether the workflow may start, or invoke `init` when the user wants a dedicated setup pass:
99
99
 
100
100
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
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
+ - Artifact base root. Default the task root to `~/Documents/CleanRoom/<project>/tasks/<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
+ - Project grouping. Default to the clean-room project layout: `<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 `proj-` plus 8 lowercase hex characters; 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, and appears in paths clean roles can see. Use the legacy flat `<base>/<task-id>/` layout only when the user explicitly chooses single-task compatibility. 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.
103
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.
104
104
  - Target stack and destination constraints from `preflight-goal.json`.
105
105
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
@@ -22,9 +22,9 @@ Ask only enough to fill `preflight-goal.json`:
22
22
 
23
23
  Record every default as an assumption. Good defaults:
24
24
 
25
- - Artifact base: `~/Documents/CleanRoom/<task-id>/`.
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/`.
25
+ - Artifact base: `~/Documents/CleanRoom/<project>/tasks/<task-id>/`.
26
+ - Implementation root: `~/Documents/CleanRoom/<project>/implementation/`.
27
+ - Single-task compatibility layout: task root `~/Documents/CleanRoom/<task-id>/` with implementation root `~/Documents/CleanRoom/<task-id>/implementation/`.
28
28
  - Existing destination policy: `inspect-and-preserve`.
29
29
  - Dependency policy: allow new dependencies, prefer standard library, require approval for native/system dependencies.
30
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.
@@ -98,16 +98,16 @@ Unattended mode requires `unattended_allowed_after_preflight: true`, finite `max
98
98
 
99
99
  ### Path Naming Guards
100
100
 
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.
101
+ Default artifact roots live under `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with a shared `~/Documents/CleanRoom/<project>/implementation/` root. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters. If the user does not provide an explicitly approved neutral project ID, generate one as `proj-` plus 8 lowercase hex characters. Do not use the source folder name as the task ID or project 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.
103
+ The legacy flat `~/Documents/CleanRoom/<task-id>/` layout remains valid only for explicit single-task compatibility. Project names follow the same neutrality rules as task IDs and are never derived from source folder names.
104
104
 
105
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`.
106
106
 
107
107
  Capture:
108
108
 
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
109
+ - artifact base root, defaulting to `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with neutral project and task IDs
110
+ - `project_id` and `project_root` for the default project layout, omitted only for explicit single-task compatibility
111
111
  - source roots or fallback visual evidence roots, contaminated artifact root, clean artifact root, clean implementation roots, quarantine root, and approved public references
112
112
  - target profile
113
113
  - default model plus optional clean, contaminated, or per-role overrides
@@ -19,7 +19,7 @@ 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 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`.
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 default project layout 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. The legacy single-task root is created only when `--single-task` is passed and contains `contaminated/`, `clean/`, `implementation/`, and `quarantine/`. Treat that bootstrap output as convenience scaffolding only. It does not replace this skill's initialization workflow, and it must not be treated as an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
23
23
 
24
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
 
@@ -29,8 +29,8 @@ Collect only setup decisions that affect correctness, safety, resumability, or o
29
29
 
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
- - 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`.
32
+ - Artifact base root. Default the task root to `~/Documents/CleanRoom/<project>/tasks/<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
+ - Project grouping. Default to a clean-room project with shared `~/Documents/CleanRoom/<project>/implementation/`. When adding a task to an existing destination project, record the user-supplied `project_id` and `project_root`; otherwise generate a neutral `proj-` plus 8 lowercase hex project id. Project names follow the same neutrality rules as task IDs, match `[a-z0-9][a-z0-9-]{0,63}`, and are never derived from source folder names. Record both fields in `init-config.json` and the manifest `initialization_snapshot`. Use the legacy flat `~/Documents/CleanRoom/<task-id>/` layout only when the user explicitly chooses single-task compatibility.
34
34
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
35
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.
36
36
  - Default model plus optional overrides for contaminated roles, clean roles, or individual roles. Keep model ids as runtime-specific strings.
@@ -24,8 +24,8 @@ Do not assume target language, license policy, dependency policy, exactness poli
24
24
  Gather only required setup facts:
25
25
 
26
26
  - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
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.
27
+ - Artifact base root, defaulting the task root to `~/Documents/CleanRoom/<project>/tasks/<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
+ - Project grouping, following the canonical `clean-room` project layout rules: `<base>/<project>/tasks/<task-id>/` with one shared `<base>/<project>/implementation/` root, a neutral project name (`proj-` plus 8 lowercase hex unless the user supplies an approved neutral name, matching `[a-z0-9][a-z0-9-]{0,63}`, never source-derived), and at most one active task per project. Use legacy flat `<base>/<task-id>/` roots only when the user explicitly chooses single-task compatibility.
29
29
  - Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
30
30
  - Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
31
31
  - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.