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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/docs/ARCHITECTURE.md +3 -1
- package/lib/bootstrap.cjs +43 -17
- package/lib/fs-utils.cjs +4 -0
- package/lib/run-constants.cjs +5 -0
- package/lib/run-progress.cjs +3 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
-
//
|
|
152
|
-
//
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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()) {
|
package/lib/run-constants.cjs
CHANGED
|
@@ -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,
|
package/lib/run-progress.cjs
CHANGED
|
@@ -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