clean-room-skill 0.3.0 → 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.3.0",
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.3.0",
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.3.0",
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"
@@ -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/lib/bootstrap.cjs CHANGED
@@ -148,10 +148,12 @@ function normalizeNameToken(value) {
148
148
  function assertNeutralProjectId(projectId, targetDir) {
149
149
  const projectToken = normalizeNameToken(projectId);
150
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.
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.
153
155
  const overlapping = projectToken === targetToken
154
- || (targetToken.length >= 4 && projectToken.includes(targetToken))
156
+ || (targetToken.length >= 2 && projectToken.includes(targetToken))
155
157
  || (projectToken.length >= 4 && targetToken.includes(projectToken));
156
158
  if (overlapping) {
157
159
  throw new Error('--project must be a neutral name; do not derive project names from workspace or source folder names');
@@ -282,6 +284,26 @@ function resolveExistingProject(options) {
282
284
  throw new Error(`project root is not a directory: ${options.projectRoot}`);
283
285
  }
284
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) {
285
307
  return { mode: 'existing' };
286
308
  }
287
309
  if (!fs.existsSync(options.projectMetadataPath)) {
@@ -376,6 +398,15 @@ function assertMetadataPath(metadata, field, expectedPath, errors, label = 'boot
376
398
  }
377
399
  }
378
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`);
407
+ }
408
+ }
409
+
379
410
  function validateProjectMetadataObject(metadata, projectRoot, errors) {
380
411
  const label = 'project metadata';
381
412
  if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
@@ -390,12 +421,7 @@ function validateProjectMetadataObject(metadata, projectRoot, errors) {
390
421
  }
391
422
  const projectId = expectMetadataString(metadata, 'project_id', errors, label);
392
423
  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
- }
424
+ validateProjectIdValue(projectId, projectRoot, errors, label);
399
425
  }
400
426
  assertMetadataPath(metadata, 'project_root', projectRoot, errors, label);
401
427
  assertMetadataPath(metadata, 'implementation_root', path.join(projectRoot, BOOTSTRAP_DIRS.implementation), errors, label);
@@ -446,8 +472,10 @@ function validateBootstrapScaffold(taskRoot) {
446
472
  // project-level implementation root. Never trust the metadata paths
447
473
  // directly: derive the project root from the task root location, then
448
474
  // require the metadata to match the derived layout.
449
- const projectLayout = metadataIsObject
450
- && (metadata.layout !== undefined || metadata.project_id !== undefined || metadata.project_root !== undefined);
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';
451
479
  let projectRoot = null;
452
480
  let projectId = null;
453
481
  let projectMetadataPath = null;
@@ -466,12 +494,7 @@ function validateBootstrapScaffold(taskRoot) {
466
494
  assertMetadataPath(metadata, 'project_root', projectRoot, errors);
467
495
  projectId = expectMetadataString(metadata, 'project_id', errors);
468
496
  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
- }
497
+ validateProjectIdValue(projectId, projectRoot, errors, 'bootstrap metadata');
475
498
  }
476
499
  try {
477
500
  projectMetadataPath = assertManagedPath(projectRoot, PROJECT_METADATA_FILE);
@@ -648,6 +671,9 @@ function printInitResult(options, projectState = { mode: 'none' }) {
648
671
  console.log(` project metadata: ${options.projectMetadataPath}`);
649
672
  }
650
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
+ }
651
677
  console.log('');
652
678
  console.log('Next steps:');
653
679
  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()) {
@@ -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.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.3.0",
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"