clean-room-skill 0.2.3 → 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 +2 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/REFERENCE.md +24 -2
- package/lib/bootstrap.cjs +282 -30
- package/lib/fs-utils.cjs +1 -0
- package/lib/run-constants.cjs +7 -0
- package/lib/run-controller.cjs +3 -2
- package/lib/run-results.cjs +23 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/attended/SKILL.md +2 -1
- package/skills/clean-room/SKILL.md +5 -3
- 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 +2 -1
- package/skills/start-over/SKILL.md +2 -0
- package/skills/unattended/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -109,6 +109,8 @@ npx clean-room-skill@latest init
|
|
|
109
109
|
|
|
110
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.
|
|
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.
|
|
113
|
+
|
|
112
114
|
In Claude Code, invoke skills with the plugin namespace:
|
|
113
115
|
|
|
114
116
|
```text
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -33,6 +33,8 @@ Optional Docker or Podman support is limited to Agent 3 verification containers.
|
|
|
33
33
|
|
|
34
34
|
Artifact roots must not disclose private source names. New runs default to `~/Documents/CleanRoom/<task-id>/`; when no explicitly approved neutral task ID is provided, the controller generates `task-` plus 8 lowercase hex characters instead of using the source folder name.
|
|
35
35
|
|
|
36
|
+
Multiple tasks targeting the same destination may be grouped under an optional clean-room project: `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with one shared `<project>/implementation/` root. Project names follow the same neutrality rule as task IDs: a random neutral word pair (such as `amber-meadow`) or a generated `proj-` plus 8 lowercase hex characters, matching `[a-z0-9][a-z0-9-]{0,63}` and never derived from source folder names. Because the implementation root is shared, 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.
|
|
37
|
+
|
|
36
38
|

|
|
37
39
|
|
|
38
40
|
The initialization wizard and `require-clean-room-env.py` audit clean, implementation, and contaminated artifact root names. They fail closed when a path contains a source root basename or meaningful non-generic tokens from that basename, while filtering generic terms such as `src`, `app`, `test`, `repo`, and `workspace`.
|
package/docs/REFERENCE.md
CHANGED
|
@@ -164,6 +164,7 @@ Usage:
|
|
|
164
164
|
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
|
+
npx clean-room-skill@latest init --project amber-meadow --task-id task-1234abcd
|
|
167
168
|
```
|
|
168
169
|
|
|
169
170
|
Options:
|
|
@@ -173,11 +174,13 @@ Options:
|
|
|
173
174
|
| `--target-dir <path>` | Repository to initialize; default is current directory. |
|
|
174
175
|
| `--artifact-base <path>` | External CleanRoom base; default is `~/Documents/CleanRoom`. |
|
|
175
176
|
| `--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`. |
|
|
176
179
|
| `--target-profile <name>` | `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`. |
|
|
177
180
|
| `--dry-run` | Print actions without writing files. |
|
|
178
181
|
| `--force` | Overwrite existing bootstrap metadata and repo stub. |
|
|
179
182
|
|
|
180
|
-
By default, `init` creates
|
|
183
|
+
By default, `init` creates a single-task layout under `<artifact-base>/<task-id>/`:
|
|
181
184
|
|
|
182
185
|
- `contaminated/`
|
|
183
186
|
- `clean/`
|
|
@@ -186,6 +189,22 @@ By default, `init` creates:
|
|
|
186
189
|
- `clean-room-bootstrap.json`
|
|
187
190
|
- `.clean-room/README.md` in the target repository
|
|
188
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:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
<artifact-base>/<project>/
|
|
196
|
+
├── clean-room-project.json
|
|
197
|
+
├── implementation/ (shared by all tasks)
|
|
198
|
+
└── tasks/
|
|
199
|
+
└── <task-id>/
|
|
200
|
+
├── contaminated/
|
|
201
|
+
├── clean/
|
|
202
|
+
├── quarantine/
|
|
203
|
+
└── clean-room-bootstrap.json
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
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
|
+
|
|
189
208
|
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.
|
|
190
209
|
|
|
191
210
|
## Preflight CLI
|
|
@@ -198,6 +217,7 @@ Usage:
|
|
|
198
217
|
npx clean-room-skill@latest preflight --template --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
|
|
199
218
|
npx clean-room-skill@latest preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-1234abcd/contaminated/preflight-goal.json
|
|
200
219
|
npx clean-room-skill@latest preflight --template --bootstrap ~/Documents/CleanRoom/task-1234abcd
|
|
220
|
+
npx clean-room-skill@latest preflight --template --bootstrap ~/Documents/CleanRoom/amber-meadow/tasks/task-1234abcd
|
|
201
221
|
```
|
|
202
222
|
|
|
203
223
|
Options:
|
|
@@ -358,11 +378,13 @@ The runner exports `CLEAN_ROOM_SESSION_BRIEF_PATH`, `CLEAN_ROOM_ROLE_SESSION_ID`
|
|
|
358
378
|
| `python3 is required to install clean-room hooks` | Python missing or not on `PATH` | Install Python 3 or use `--hooks=copy-only`. |
|
|
359
379
|
| `safe hooks are installed; clean-room init/onboarding must set role environment variables` | Safe mode default | Start the clean-room init/onboarding flow, or use strict hooks in a dedicated profile. |
|
|
360
380
|
| `install lock is held` | Another install or uninstall is mutating the same target root | Wait for the other process to finish; stale locks are handled conservatively. |
|
|
381
|
+
| `clean-room run lock is held` | Another run is active for the same task's contaminated root, or a crashed run left a recent lock | Wait for the other run to finish. Locks from dead processes age out and are moved aside automatically; if no run is active, remove `.clean-room-run.lock` from the contaminated root and retry. |
|
|
382
|
+
| `clean-room implementation lock is held` | Another task in the same clean-room project is running against the shared implementation root, or a crashed run left a recent lock | Wait for the other run to finish. Locks from dead processes age out and are moved aside automatically; if no run is active, remove `.clean-room-implementation.lock` from the implementation root and retry. |
|
|
361
383
|
| Hook config write failed after files copied | Partial installer state | Fix the filesystem error, then re-run the same installer command. |
|
|
362
384
|
| Install manifest remains `installing` | The previous install did not complete | Re-run the same installer command for that runtime and target root. |
|
|
363
385
|
| `clean-room-skill` is not found | The CLI is not globally installed or the runtime PATH does not include it | Use `npx clean-room-skill@latest ...` immediately; do not search plugin caches for package internals. |
|
|
364
386
|
| `--schema-dir` reports missing schemas | The override points at a stale plugin cache, clean root, or non-directory path | Omit `--schema-dir` to use bundled schemas. Pass it only for a real directory containing `task-manifest.schema.json`. Do not use `/dev/null`. |
|
|
365
|
-
| `task manifest not found` for a task root | The runner needs the contaminated-side manifest file | Pass `~/Documents/CleanRoom/<task-id>/contaminated/task-manifest.json
|
|
387
|
+
| `task manifest not found` for a task root | The runner needs the contaminated-side manifest file | Pass `~/Documents/CleanRoom/<task-id>/contaminated/task-manifest.json` (or `~/Documents/CleanRoom/<project>/tasks/<task-id>/contaminated/task-manifest.json` in the project layout), not the task root or clean root. |
|
|
366
388
|
| `preflight goal not found` | `task-manifest.json` references a missing or misplaced contaminated-side preflight file | Restore `preflight-goal.json` under the contaminated artifact root, update `preflight_goal_ref` and `preflight_goal_sha256`, then retry `--dry-run`. |
|
|
367
389
|
| `clean-room run` rejects the manifest | Invalid or incomplete unattended loop metadata | Fix `controller_policy`, `loop_context.foundation_unit_ref`, and `approved_scope_refs`, then retry `--dry-run`. |
|
|
368
390
|
| `clean-room artifact validation failed` lists stale JSON files | Old or hand-written clean-room artifacts are still under contaminated or clean artifact roots | Update those artifacts to current schemas or move stale/legacy JSON to quarantine, then retry `--dry-run`. |
|
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-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
|
@@ -54,6 +54,7 @@ const {
|
|
|
54
54
|
loadLedger,
|
|
55
55
|
noProgressResult,
|
|
56
56
|
stageFailureResult,
|
|
57
|
+
withImplementationRootLocks,
|
|
57
58
|
withRunLock,
|
|
58
59
|
writeLedger,
|
|
59
60
|
writeResult,
|
|
@@ -211,7 +212,7 @@ async function runCleanRoom(options, context = {}) {
|
|
|
211
212
|
? { agentConfig: null, configDir: process.cwd() }
|
|
212
213
|
: resolveAgentConfig(options, context, roots, manifest, agentConfigPath);
|
|
213
214
|
|
|
214
|
-
return withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
|
|
215
|
+
return withImplementationRootLocks(roots.implementationRoots, options.dryRun, () => withRunLock(roots.contaminatedRoot, options.dryRun, async () => {
|
|
215
216
|
const coverageLedgerPath = path.join(roots.contaminatedRoot, 'coverage-ledger.json');
|
|
216
217
|
const coverageLedger = validateRunState(options, taskManifestPath, roots, manifest, coverageLedgerPath);
|
|
217
218
|
const selectedUnit = selectUnit(manifest, coverageLedger);
|
|
@@ -374,7 +375,7 @@ async function runCleanRoom(options, context = {}) {
|
|
|
374
375
|
validateFoundationCoverageGate(resultManifest, finalCoverageLedger);
|
|
375
376
|
console.log(`clean-room run: ${terminalResult.result}`);
|
|
376
377
|
return terminalResult;
|
|
377
|
-
});
|
|
378
|
+
}));
|
|
378
379
|
}
|
|
379
380
|
|
|
380
381
|
module.exports = {
|
package/lib/run-results.cjs
CHANGED
|
@@ -10,6 +10,9 @@ const {
|
|
|
10
10
|
} = require('./fs-utils.cjs');
|
|
11
11
|
const {
|
|
12
12
|
CLEAN_RUN_CONTEXT_NAME,
|
|
13
|
+
IMPLEMENTATION_LOCK_NAME,
|
|
14
|
+
IMPLEMENTATION_LOCK_POLL_MS,
|
|
15
|
+
IMPLEMENTATION_LOCK_WAIT_MS,
|
|
13
16
|
MAX_LEDGER_ITERATIONS,
|
|
14
17
|
POLISH_REPORT_NAME,
|
|
15
18
|
RUN_LOCK_NAME,
|
|
@@ -431,6 +434,25 @@ async function withRunLock(contaminatedRoot, dryRun, fn) {
|
|
|
431
434
|
}, fn);
|
|
432
435
|
}
|
|
433
436
|
|
|
437
|
+
async function withImplementationRootLocks(implementationRoots, dryRun, fn) {
|
|
438
|
+
if (dryRun) return fn();
|
|
439
|
+
// Realpath dedupe: root-separation checks do not compare implementation
|
|
440
|
+
// roots against each other, so symlink-aliased duplicates would otherwise
|
|
441
|
+
// self-deadlock on their own live-pid lock. Sorting gives every run the
|
|
442
|
+
// same global acquisition order, preventing AB/BA deadlocks.
|
|
443
|
+
const roots = [...new Set(implementationRoots.map((root) => {
|
|
444
|
+
fs.mkdirSync(root, { recursive: true });
|
|
445
|
+
return fs.realpathSync(root);
|
|
446
|
+
}))].sort();
|
|
447
|
+
const run = roots.reduceRight((next, root) => () => withDirectoryLock({
|
|
448
|
+
lockPath: path.join(root, IMPLEMENTATION_LOCK_NAME),
|
|
449
|
+
waitMs: IMPLEMENTATION_LOCK_WAIT_MS,
|
|
450
|
+
pollMs: IMPLEMENTATION_LOCK_POLL_MS,
|
|
451
|
+
label: 'clean-room implementation lock',
|
|
452
|
+
}, next), fn);
|
|
453
|
+
return run();
|
|
454
|
+
}
|
|
455
|
+
|
|
434
456
|
module.exports = {
|
|
435
457
|
buildResult,
|
|
436
458
|
completeResultOrSpecDelta,
|
|
@@ -439,6 +461,7 @@ module.exports = {
|
|
|
439
461
|
loadLedger,
|
|
440
462
|
noProgressResult,
|
|
441
463
|
stageFailureResult,
|
|
464
|
+
withImplementationRootLocks,
|
|
442
465
|
withRunLock,
|
|
443
466
|
writeLedger,
|
|
444
467
|
writeResult,
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/skills/attended/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ In Pi, this entry point is invoked as `/skill:attended`.
|
|
|
13
13
|
|
|
14
14
|
Use the canonical `clean-room` skill workflow and references in this plugin. Preserve the same clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
|
|
15
15
|
|
|
16
|
-
Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` candidates. If a valid `task-manifest.json` exists, route to `resume-cr`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
|
|
16
|
+
Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` (legacy) and `~/Documents/CleanRoom/*/tasks/task-*` (project layout) candidates. If a valid `task-manifest.json` exists, route to `resume-cr`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
|
|
17
17
|
|
|
18
18
|
Load or create `preflight-goal.json` first. Attended mode may continue with unresolved questions only when they are recorded as `open_questions`; blocking questions become pause gates before affected work starts.
|
|
19
19
|
|
|
@@ -21,6 +21,7 @@ Gather only required setup facts:
|
|
|
21
21
|
|
|
22
22
|
- Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
|
|
23
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.
|
|
24
25
|
- Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
|
|
25
26
|
- Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
|
|
26
27
|
- Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
|
|
@@ -80,15 +80,16 @@ Discovery order:
|
|
|
80
80
|
|
|
81
81
|
1. Resolve explicit user-provided paths first. Accept a task root, `task-manifest.json`, `preflight-goal.json`, or `clean-room-bootstrap.json`.
|
|
82
82
|
2. Inspect configured clean-room roots from the current request or environment, including `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, `CLEAN_ROOM_CLEAN_ROOTS`, and `CLEAN_ROOM_IMPLEMENTATION_ROOTS` when present.
|
|
83
|
-
3. Scan `~/Documents/CleanRoom/task-*` as a bounded fallback. Inspect only immediate
|
|
83
|
+
3. Scan `~/Documents/CleanRoom/task-*` (legacy single-task layout) and `~/Documents/CleanRoom/*/clean-room-project.json` plus `~/Documents/CleanRoom/*/tasks/task-*` (project layout) as a bounded fallback. Inspect only immediate project directories, their `tasks/` children, and expected artifact names.
|
|
84
84
|
|
|
85
|
-
If more than one candidate run is found without an explicit user path, list the candidate task roots and stop for explicit selection. Do not choose the newest candidate automatically.
|
|
85
|
+
If more than one candidate run is found without an explicit user path, list the candidate task roots grouped by project and stop for explicit selection. Do not choose the newest candidate automatically.
|
|
86
86
|
|
|
87
87
|
Classify the selected candidate before starting the wizard:
|
|
88
88
|
|
|
89
89
|
- Valid `task-manifest.json`: route to `resume-cr` and continue from the earliest incomplete gate.
|
|
90
90
|
- Valid canonical `preflight-goal.json` without `task-manifest.json`: continue at source/destination discovery and manifest creation. Do not ask the preflight wizard again.
|
|
91
91
|
- `clean-room-bootstrap.json` only: run preflight using the bootstrap roots.
|
|
92
|
+
- `clean-room-project.json` with no task directories under `tasks/`: treat as an empty project and offer to create its first task inside that project.
|
|
92
93
|
- Invalid `preflight-goal.json`: stop, report canonical schema or required-field errors, and do not create a replacement preflight.
|
|
93
94
|
- No artifacts found: start the normal preflight wizard.
|
|
94
95
|
|
|
@@ -98,6 +99,7 @@ Gather only the setup facts needed to decide whether the workflow may start, or
|
|
|
98
99
|
|
|
99
100
|
- Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
|
|
100
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
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.
|
|
102
104
|
- Target stack and destination constraints from `preflight-goal.json`.
|
|
103
105
|
- Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
|
|
@@ -107,7 +109,7 @@ Gather only the setup facts needed to decide whether the workflow may start, or
|
|
|
107
109
|
- Run state. New runs use `generation: 1`, current `started_at`, and `restart_reason: user-requested`.
|
|
108
110
|
- Role hook environment block. Derive `CLEAN_ROOM_ROLE`, `CLEAN_ROOM_SOURCE_ROOTS`, `CLEAN_ROOM_CONTAMINATED_ARTIFACT_ROOTS`, `CLEAN_ROOM_CLEAN_ROOTS`, `CLEAN_ROOM_IMPLEMENTATION_ROOTS`, `CLEAN_ROOM_ALLOWED_READ_ROOTS`, `CLEAN_ROOM_SCHEMA_DIR`, and optional hook-only denylist variables from the approved roots before launching any role session. Do not ask the user to export `CLEAN_ROOM_HOOK_ENFORCE` for normal safe-hook runs.
|
|
109
111
|
|
|
110
|
-
Before indexing or artifact generation, confirm that source roots, contaminated artifact roots, clean artifact roots, clean implementation roots, approved public reference roots, and schema directory are separate paths, and that clean/contaminated/implementation root path names are not source-derived. Stop if authorization is unclear, if clean and contaminated roots overlap, if implementation roots overlap any other trust-domain root, or if artifact/root paths contain source root basenames or meaningful non-generic source-name tokens. Agent 2, Agent 3, and Agent 4 must not receive source mounts or the full task manifest.
|
|
112
|
+
Before indexing or artifact generation, confirm that source roots, contaminated artifact roots, clean artifact roots, clean implementation roots, approved public reference roots, and schema directory are separate paths, and that clean/contaminated/implementation root path names are not source-derived. In project layout the implementation root is the shared project-level folder; per-task contaminated, clean, and quarantine roots stay under the task root, and the project name itself must pass the same source-derived-name checks. Stop if authorization is unclear, if clean and contaminated roots overlap, if implementation roots overlap any other trust-domain root, or if artifact/root paths contain source root basenames or meaningful non-generic source-name tokens. Agent 2, Agent 3, and Agent 4 must not receive source mounts or the full task manifest.
|
|
111
113
|
|
|
112
114
|
For `attended` mode, record a `controller_policy` that pauses for human review at scope gate, clean handoff, terminal implementation deltas, blocked units, and final coverage. Include stop conditions for `authorization-missing`, `scope-change`, `contamination-suspected`, `schema-validation-failed`, `leakage-scan-failed`, `unit-blocked`, `implementation-complete`, and `coverage-complete`; attended mode does not add an iteration-limit stop unless the user explicitly sets one.
|
|
113
115
|
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
"type": "string",
|
|
28
28
|
"minLength": 1
|
|
29
29
|
},
|
|
30
|
+
"project_id": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
|
|
33
|
+
},
|
|
34
|
+
"project_root": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"minLength": 1
|
|
37
|
+
},
|
|
30
38
|
"root_preferences": {
|
|
31
39
|
"type": "object",
|
|
32
40
|
"additionalProperties": false,
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
"type": "string",
|
|
34
34
|
"minLength": 1
|
|
35
35
|
},
|
|
36
|
+
"project_id": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
|
|
39
|
+
},
|
|
40
|
+
"project_root": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1
|
|
43
|
+
},
|
|
36
44
|
"target_identifier": {
|
|
37
45
|
"type": "string",
|
|
38
46
|
"minLength": 1
|
|
@@ -1215,6 +1223,14 @@
|
|
|
1215
1223
|
"type": "string",
|
|
1216
1224
|
"minLength": 1
|
|
1217
1225
|
},
|
|
1226
|
+
"project_id": {
|
|
1227
|
+
"type": "string",
|
|
1228
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
|
|
1229
|
+
},
|
|
1230
|
+
"project_root": {
|
|
1231
|
+
"type": "string",
|
|
1232
|
+
"minLength": 1
|
|
1233
|
+
},
|
|
1218
1234
|
"target_profile": {
|
|
1219
1235
|
"enum": [
|
|
1220
1236
|
"openspec-delta",
|
|
@@ -47,6 +47,8 @@ Treat implementation identifiers as contaminated by default. Package names, name
|
|
|
47
47
|
|
|
48
48
|
Public compatibility surface means the name is externally documented, required by an existing integration, visible in a public protocol or file format, or explicitly required by the destination scope. If a name is retained, place it in `public_surface` or `public_contracts` with `name`, `kind`, `visibility`, and a concrete compatibility reason. Valid `visibility` values are `public`, `destination`, `protocol`, and `user-required`. Do not mention source-private names in summaries, claims, tests, open questions, skeleton areas, QC findings, or delta tickets.
|
|
49
49
|
|
|
50
|
+
Task IDs and project names are clean-visible path components: they appear in roots that clean roles read and in paths recorded by clean artifacts. Both must stay neutral. Use generated `task-` plus 8 lowercase hex IDs, and for projects a random neutral word pair or `proj-` plus 8 lowercase hex, matching `[a-z0-9][a-z0-9-]{0,63}`. Never derive a task ID or project name from source folder basenames or meaningful source-name tokens.
|
|
51
|
+
|
|
50
52
|
The contaminated side should maintain a private identifier denylist for guardrail scanning when practical. The denylist is line-oriented, ignores blank lines and `#` comments, and is bounded to 1,000,000 bytes per file, 20,000 total terms, and 512 characters per term. Keep that list out of clean/source-denied readable roots and do not paste its contents into clean artifacts or model-visible reports.
|
|
51
53
|
|
|
52
54
|
Agent 1.5 may use the denylist only through hook scanning. Do not include denylist terms in the neutral sanitizer brief, sanitizer prompts, sanitizer reports, clean artifacts, or model-visible feedback.
|
|
@@ -24,6 +24,7 @@ Record every default as an assumption. Good defaults:
|
|
|
24
24
|
|
|
25
25
|
- Artifact base: `~/Documents/CleanRoom/<task-id>/`.
|
|
26
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/`.
|
|
27
28
|
- Existing destination policy: `inspect-and-preserve`.
|
|
28
29
|
- Dependency policy: allow new dependencies, prefer standard library, require approval for native/system dependencies.
|
|
29
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.
|
|
@@ -20,7 +20,9 @@ Use separate locations for each trust domain:
|
|
|
20
20
|
|
|
21
21
|
Clean, contaminated, and implementation paths must remain neutral. If the user does not provide an explicitly approved neutral task ID, generate one as `task-` plus 8 lowercase hex characters and use it under `~/Documents/CleanRoom/`.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Multiple tasks targeting the same destination may be grouped under an optional clean-room project: `~/Documents/CleanRoom/<project>/tasks/<task-id>/` with one shared `<project>/implementation/` root. Project names follow the same neutrality rules as task IDs: a random neutral word pair (such as `amber-meadow`) or `proj-` plus 8 lowercase hex characters, matching `[a-z0-9][a-z0-9-]{0,63}`. Because the shared implementation root serves every task in the project, run at most one active task per project at a time; root-separation checks for one task cannot see a sibling task's concurrent clean implementation session. `clean-room-skill run` enforces this with an advisory `.clean-room-implementation.lock` in each implementation root, but manually launched role sessions outside the runner must still respect the rule.
|
|
24
|
+
|
|
25
|
+
Do not derive task IDs, project names, clean roots, contaminated artifact roots, or implementation roots from source folder names. The initialization wizard and environment preflight reject artifact paths that contain a source root basename or meaningful non-generic tokens from that basename.
|
|
24
26
|
|
|
25
27
|
Prefer separate agent profiles or homes when the host supports them. Do not rely on one chat context with role labels as the only separation control.
|
|
26
28
|
|
|
@@ -212,6 +214,7 @@ Clean polish reviewer:
|
|
|
212
214
|
2. Initialization gate:
|
|
213
215
|
- Record reusable preferences in `init-config.json` when requested.
|
|
214
216
|
- Default the artifact base root to `~/Documents/CleanRoom/<task-id>/` unless the user selects another separated location. Generate a neutral `task-` plus 8 lowercase hex characters when the user does not provide an explicitly approved neutral task ID.
|
|
217
|
+
- When grouping tasks under a project, place the task root at `~/Documents/CleanRoom/<project>/tasks/<task-id>/`, share the project-level `implementation/` root, and record `project_id` and `project_root` in `init-config.json` and the manifest `initialization_snapshot`.
|
|
215
218
|
- Reject clean, contaminated, or implementation roots that mirror source root basenames or meaningful non-generic source-name tokens.
|
|
216
219
|
- Record model preferences as a default model plus optional domain or role overrides.
|
|
217
220
|
- Split user rules into clean-safe and contaminated-only rules.
|
|
@@ -100,11 +100,14 @@ Unattended mode requires `unattended_allowed_after_preflight: true`, finite `max
|
|
|
100
100
|
|
|
101
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.
|
|
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.
|
|
104
|
+
|
|
103
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`.
|
|
104
106
|
|
|
105
107
|
Capture:
|
|
106
108
|
|
|
107
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
|
|
108
111
|
- source roots or fallback visual evidence roots, contaminated artifact root, clean artifact root, clean implementation roots, quarantine root, and approved public references
|
|
109
112
|
- target profile
|
|
110
113
|
- default model plus optional clean, contaminated, or per-role overrides
|
package/skills/init/SKILL.md
CHANGED
|
@@ -19,9 +19,9 @@ 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 task root
|
|
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`.
|
|
23
23
|
|
|
24
|
-
When using an existing CLI bootstrap, check `clean-room-bootstrap.json`, `contaminated/`, `clean/`, `
|
|
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
|
|
|
26
26
|
## Gather
|
|
27
27
|
|
|
@@ -30,6 +30,7 @@ Collect only setup decisions that affect correctness, safety, resumability, or o
|
|
|
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
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`.
|
|
33
34
|
- Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
|
|
34
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.
|
|
35
36
|
- Default model plus optional overrides for contaminated roles, clean roles, or individual roles. Keep model ids as runtime-specific strings.
|
|
@@ -11,7 +11,7 @@ Create or validate `preflight-goal.json` before active clean-room artifacts star
|
|
|
11
11
|
|
|
12
12
|
Use the canonical `clean-room` workflow and read `skills/clean-room/references/PREFLIGHT.md` when collecting missing goal details. Preserve the clean-room boundary: `preflight-goal.json` is a controller/contaminated-side artifact and must not be placed in clean-role readable roots.
|
|
13
13
|
|
|
14
|
-
If the user provides output from CLI `clean-room-skill init` (or `npx clean-room-skill@latest init` if the binary is not available), check the generated bootstrap scaffold before creating or copying `preflight-goal.json`: `clean-room-bootstrap.json`, `contaminated/`, `clean/`,
|
|
14
|
+
If the user provides output from CLI `clean-room-skill init` (or `npx clean-room-skill@latest init` if the binary is not available), check the generated bootstrap scaffold before creating or copying `preflight-goal.json`: `clean-room-bootstrap.json`, `contaminated/`, `clean/`, the implementation root, `quarantine/`, and the target repo `.clean-room/README.md` must exist and agree. In the project layout the task root sits at `<base>/<project>/tasks/<task-id>/`, the implementation root is the shared project-level `implementation/`, and `clean-room-project.json` must exist at the project root. Treat that scaffold as convenience output only; it is not an active `preflight-goal.json`, `init-config.json`, `task-manifest.json`, or `clean-run-context.json`.
|
|
15
15
|
|
|
16
16
|
## Required Contract
|
|
17
17
|
|
|
@@ -52,6 +52,7 @@ Use the CLI (`clean-room-skill` if installed, or `npx clean-room-skill@latest` a
|
|
|
52
52
|
clean-room-skill preflight --template --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
|
|
53
53
|
clean-room-skill preflight --input ./preflight-goal.json --output ~/Documents/CleanRoom/task-xxxxxxxx/contaminated/preflight-goal.json
|
|
54
54
|
clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/task-xxxxxxxx
|
|
55
|
+
clean-room-skill preflight --template --bootstrap ~/Documents/CleanRoom/<project>/tasks/task-xxxxxxxx
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
`--template` writes an attended draft with blocking open questions. It does not support unattended mode. Use `--input` for completed contracts. `--bootstrap` accepts either the generated task root or `clean-room-bootstrap.json`, writes to the generated contaminated artifact root after scaffold validation, and requires completed input contracts to match the bootstrap artifact and implementation roots.
|
|
@@ -15,7 +15,7 @@ If `task-manifest.json` records `controller_policy.mode: "unattended"` in Claude
|
|
|
15
15
|
|
|
16
16
|
## Load Order
|
|
17
17
|
|
|
18
|
-
Load these artifacts from the paths recorded in `task-manifest.json` and the configured root environment. Treat missing optional artifacts as blockers only when the current gate requires them.
|
|
18
|
+
Load these artifacts from the paths recorded in `task-manifest.json` and the configured root environment. Treat missing optional artifacts as blockers only when the current gate requires them. A task may live at `<base>/<project>/tasks/<task-id>/` with a shared project-level implementation root; trust the absolute paths recorded in `task-manifest.json` `artifact_paths` and never re-derive the layout from folder positions.
|
|
19
19
|
|
|
20
20
|
- `task-manifest.json`
|
|
21
21
|
- `preflight-goal.json`, when referenced by `task-manifest.json`, only on the contaminated/controller side
|
|
@@ -46,6 +46,7 @@ Before choosing work:
|
|
|
46
46
|
- Confirm authorization still covers the recorded source scope and allowed actions.
|
|
47
47
|
- Report `run_state` when present; do not infer generation from chat history when it is missing.
|
|
48
48
|
- Trust `initialization_snapshot` before any reusable `init-config.json`. If they differ, report drift and stop before changing roots, model policy, schema profile, or rule classification.
|
|
49
|
+
- When `initialization_snapshot` records `project_id` or `project_root`, confirm the on-disk `clean-room-project.json` at the project root still agrees with them and with the shared implementation root. Report drift and stop on mismatch.
|
|
49
50
|
- Preserve the existing `controller_policy`; missing policy means `attended`.
|
|
50
51
|
- Stop if new-run artifacts lack `preflight_goal_ref`, `preflight_goal_sha256`, or the required `handoff_sequence`. Treat this as legacy or incomplete preflight state and ask for a reviewed preflight goal before resuming.
|
|
51
52
|
- Validate referenced `preflight-goal.json` before using goal, stack, dependency, license, exactness, output, or hygiene decisions.
|
|
@@ -24,6 +24,7 @@ Archive or quarantine previous artifacts before creating new ones:
|
|
|
24
24
|
- Do not mix contaminated and clean archives.
|
|
25
25
|
- Do not delete artifacts.
|
|
26
26
|
- Do not overwrite an existing archive path; create a unique archive directory.
|
|
27
|
+
- In the project layout, archive scope is the task folder only. Never archive, move, or quarantine the project root, `clean-room-project.json`, or the shared project-level `implementation/` root; sibling tasks depend on them, and implementation content is destination state, not per-task artifact state.
|
|
27
28
|
- Preserve existing `preflight-goal.json`, `task-manifest.json`, ledgers, handoff packages, behavior specs, skeleton manifests, implementation plans, implementation reports, QC reports, incident records, and open delta tickets.
|
|
28
29
|
|
|
29
30
|
If safe archive targets cannot be proven from `task-manifest.json`, root environment, or explicit user input, stop before moving anything.
|
|
@@ -37,6 +38,7 @@ Start from the preflight gate, not from prior QC:
|
|
|
37
38
|
- Reconfirm source roots or visual roots, contaminated artifact roots, clean roots, implementation roots, and clean allowed-read roots are separated, and that root path names are not source-derived.
|
|
38
39
|
- Preserve source or visual roots and authorization only when they are still valid for the requested restart.
|
|
39
40
|
- Create a fresh neutral `task_id` by default. Use `task-` plus 8 lowercase hex characters unless the user provides an explicitly approved neutral ID. Do not derive the new ID or output directory names from source folder names.
|
|
41
|
+
- In the project layout, the new task joins the same project by default. Starting a new project instead requires an explicit user choice and a fresh neutral project name (random word pair or `proj-` plus 8 lowercase hex, never source-derived).
|
|
40
42
|
- Record `run_state.generation`, `run_state.started_at`, optional `run_state.previous_generation_ref`, and `run_state.restart_reason`.
|
|
41
43
|
- Recreate `clean-run-context.json` from the new effective preflight and initialization choices; do not carry forward an old clean context by default.
|
|
42
44
|
- Rebuild `source-index.json`, or `visual-index.json` for visual fallback runs, unless the user explicitly says the source or visual scope is unchanged and a recorded old index hash can still be validated.
|
|
@@ -13,7 +13,7 @@ In Pi, this entry point is invoked as `/skill:unattended`.
|
|
|
13
13
|
|
|
14
14
|
Use the canonical `clean-room` skill workflow and references in this plugin. Read `skills/clean-room/references/CONTROLLER-LOOP.md` before defining unattended loop behavior. Preserve the same clean-room boundary, role separation, artifact schemas, leakage rules, implementation-root rules, and hook expectations.
|
|
15
15
|
|
|
16
|
-
Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` candidates. If a valid `task-manifest.json` exists, route to `resume-cr`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
|
|
16
|
+
Before asking setup or preflight questions, use the canonical `clean-room` "Run State Discovery Before Wizard" rules. Resolve explicit artifact paths first, then configured clean-room roots, then bounded `~/Documents/CleanRoom/task-*` (legacy) and `~/Documents/CleanRoom/*/tasks/task-*` (project layout) candidates. If a valid `task-manifest.json` exists, route to `resume-cr`. If a valid canonical `preflight-goal.json` exists without a manifest, continue at source/destination discovery and manifest creation. If a preflight artifact exists but is invalid, stop with schema errors instead of restarting preflight. If multiple candidates are found without an explicit path, list them and stop for selection.
|
|
17
17
|
|
|
18
18
|
When resuming a valid unattended `task-manifest.json` in Claude Code, prefer launching the durable runner with `clean-room-skill run --task-manifest <path> --agent-runtime claude`. If `clean-room-skill` is not on `PATH`, immediately use `npx clean-room-skill@latest run --task-manifest <path> --agent-runtime claude` instead of searching for the installed package. Do not search plugin cache paths for schema files, and do not pass `--schema-dir /dev/null`. The runner uses bundled schemas by default; pass `--schema-dir` only when the user provides a real schema directory. The main conversation must not perform Agent 1, Agent 2, Agent 3, or Agent 4 work. Do not ask to continue while `controller_policy.mode` is `unattended`, the iteration budget remains, and approved pending or gap units remain. If Claude role-agent dispatch or the runner is unavailable, stop with `BLOCKERS: Claude role-agent dispatch unavailable` instead of falling back to main-chat execution.
|
|
19
19
|
|
|
@@ -25,6 +25,7 @@ Gather only required setup facts:
|
|
|
25
25
|
|
|
26
26
|
- Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
|
|
27
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.
|
|
28
29
|
- Source roots, contaminated artifact root, clean artifact root, clean implementation root, quarantine root, and optional public or destination reference roots.
|
|
29
30
|
- Target stack, destination constraints, dependency/license policy, exactness policy, feature policy, code hygiene policy, and output policy from `preflight-goal.json`.
|
|
30
31
|
- Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
|