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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/agents/clean-architect.md +4 -0
- package/agents/clean-implementer-verifier-shell.md +4 -0
- package/agents/clean-polish-reviewer.md +4 -0
- package/agents/clean-qa-editor.md +4 -0
- package/agents/contaminated-handoff-sanitizer.md +4 -0
- package/agents/contaminated-manager-verifier.md +4 -0
- package/agents/contaminated-source-analyst.md +4 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/REFERENCE.md +28 -1
- package/lib/bootstrap.cjs +282 -30
- package/lib/fs-utils.cjs +1 -0
- package/lib/run-cli.cjs +1 -1
- package/lib/run-constants.cjs +7 -0
- package/lib/run-controller.cjs +38 -3
- package/lib/run-hooks.cjs +44 -11
- package/lib/run-results.cjs +23 -0
- package/lib/run-roots.cjs +56 -11
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/attended/SKILL.md +2 -1
- package/skills/clean-room/SKILL.md +6 -4
- package/skills/clean-room/assets/init-config.schema.json +8 -0
- package/skills/clean-room/assets/task-manifest.schema.json +16 -0
- package/skills/clean-room/references/LEAKAGE-RULES.md +2 -0
- package/skills/clean-room/references/PREFLIGHT.md +1 -0
- package/skills/clean-room/references/PROCESS.md +4 -1
- package/skills/clean-room/references/SPEC-SCHEMA.md +3 -0
- package/skills/init/SKILL.md +3 -2
- package/skills/preflight/SKILL.md +2 -1
- package/skills/resume-cr/SKILL.md +3 -2
- package/skills/start-over/SKILL.md +2 -0
- 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
|
-
|
|
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,
|
|
130
|
-
clean: path.join(outputRoot,
|
|
131
|
-
implementation:
|
|
132
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
`);
|
package/lib/run-constants.cjs
CHANGED
|
@@ -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,
|
package/lib/run-controller.cjs
CHANGED
|
@@ -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(
|
|
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 = {
|