clean-room-skill 0.1.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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +36 -0
  4. package/LICENSE +21 -0
  5. package/README.md +376 -0
  6. package/agents/clean-architect.md +27 -0
  7. package/agents/clean-qa-editor.md +27 -0
  8. package/agents/contaminated-manager-verifier.md +35 -0
  9. package/agents/contaminated-source-analyst.md +26 -0
  10. package/bin/install.js +535 -0
  11. package/examples/codex/.codex/agents/clean-architect.toml +17 -0
  12. package/examples/codex/.codex/agents/clean-qa-editor.toml +17 -0
  13. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +21 -0
  14. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +17 -0
  15. package/hooks/check-artifact-leakage.py +317 -0
  16. package/hooks/clean-room-hook.py +88 -0
  17. package/hooks/clean_room_paths.py +130 -0
  18. package/hooks/deny-clean-room-shell.py +30 -0
  19. package/hooks/deny-clean-source-read.py +104 -0
  20. package/hooks/deny-contaminated-clean-write.py +134 -0
  21. package/hooks/hooks.json +44 -0
  22. package/hooks/require-clean-room-env.py +127 -0
  23. package/hooks/validate-handoff-package.py +140 -0
  24. package/hooks/validate-json-schema.py +283 -0
  25. package/lib/fs-utils.cjs +123 -0
  26. package/lib/hooks.cjs +214 -0
  27. package/package.json +49 -0
  28. package/plugin.json +20 -0
  29. package/skills/attended/SKILL.md +25 -0
  30. package/skills/clean-room/SKILL.md +134 -0
  31. package/skills/clean-room/assets/behavior-spec.schema.json +367 -0
  32. package/skills/clean-room/assets/contamination-incident.schema.json +60 -0
  33. package/skills/clean-room/assets/coverage-ledger.schema.json +139 -0
  34. package/skills/clean-room/assets/evidence-ledger.schema.json +80 -0
  35. package/skills/clean-room/assets/handoff-package.schema.json +114 -0
  36. package/skills/clean-room/assets/qc-report.schema.json +248 -0
  37. package/skills/clean-room/assets/skeleton-manifest.schema.json +239 -0
  38. package/skills/clean-room/assets/source-index.schema.json +622 -0
  39. package/skills/clean-room/assets/task-manifest.schema.json +593 -0
  40. package/skills/clean-room/examples/README.md +18 -0
  41. package/skills/clean-room/examples/minimal-spec-package/behavior-spec.json +61 -0
  42. package/skills/clean-room/examples/minimal-spec-package/coverage-ledger.json +27 -0
  43. package/skills/clean-room/examples/minimal-spec-package/evidence-ledger.json +17 -0
  44. package/skills/clean-room/examples/minimal-spec-package/handoff-package.json +26 -0
  45. package/skills/clean-room/examples/minimal-spec-package/qc-report.json +25 -0
  46. package/skills/clean-room/examples/minimal-spec-package/skeleton-manifest.json +45 -0
  47. package/skills/clean-room/examples/minimal-spec-package/source-index.json +156 -0
  48. package/skills/clean-room/examples/minimal-spec-package/task-manifest.json +220 -0
  49. package/skills/clean-room/references/LEAKAGE-RULES.md +92 -0
  50. package/skills/clean-room/references/PROCESS.md +185 -0
  51. package/skills/clean-room/references/SPEC-SCHEMA.md +185 -0
  52. package/skills/clean-room/references/TARGET-LANGUAGE-GUIDE.md +43 -0
  53. package/skills/clean-room/scripts/build_source_index.py +1253 -0
  54. package/skills/clean-room/scripts/clean_room_tool_manager.py +199 -0
  55. package/skills/clean-room/scripts/clean_room_tooling.py +370 -0
  56. package/skills/unattended/SKILL.md +26 -0
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight JSON artifact validator for bundled clean-room schemas."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from clean_room_paths import checked_write_paths, load_payload, path_under_env
15
+
16
+
17
+ SCHEMA_BY_ARTIFACT = {
18
+ "task-manifest": "task-manifest.schema.json",
19
+ "behavior-spec": "behavior-spec.schema.json",
20
+ "skeleton-manifest": "skeleton-manifest.schema.json",
21
+ "qc-report": "qc-report.schema.json",
22
+ "coverage-ledger": "coverage-ledger.schema.json",
23
+ "evidence-ledger": "evidence-ledger.schema.json",
24
+ "source-index": "source-index.schema.json",
25
+ "handoff-package": "handoff-package.schema.json",
26
+ "contamination-incident": "contamination-incident.schema.json",
27
+ }
28
+ CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV = "CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST"
29
+
30
+
31
+ def schema_dir() -> Path:
32
+ configured = os.environ.get("CLEAN_ROOM_SCHEMA_DIR")
33
+ if configured:
34
+ return Path(configured).expanduser().resolve()
35
+ return Path(__file__).resolve().parents[1] / "skills" / "clean-room" / "assets"
36
+
37
+
38
+ def artifact_kind(path: Path, data: dict) -> str | None:
39
+ name = path.name.removesuffix(".json")
40
+ if name in SCHEMA_BY_ARTIFACT:
41
+ return name
42
+ if "spec_id" in data:
43
+ return "behavior-spec"
44
+ if "manifest_id" in data:
45
+ return "skeleton-manifest"
46
+ if "report_id" in data:
47
+ return "qc-report"
48
+ if "package_id" in data:
49
+ return "handoff-package"
50
+ if "incident_id" in data:
51
+ return "contamination-incident"
52
+ if "index_id" in data and data.get("domain") == "contaminated" and "recommended_batches" in data:
53
+ return "source-index"
54
+ if "ledger_id" in data:
55
+ if data.get("domain") == "contaminated" and "entries" in data:
56
+ return "evidence-ledger"
57
+ if {"source_units", "behavior_spec_refs", "coverage_status"} & data.keys():
58
+ return "coverage-ledger"
59
+ if data.get("from_domain") == "contaminated" and data.get("to_domain") == "clean" and "artifacts" in data:
60
+ return "handoff-package"
61
+ for kind in SCHEMA_BY_ARTIFACT:
62
+ if kind in name:
63
+ return kind
64
+ if "task_id" in data:
65
+ return "task-manifest"
66
+ return None
67
+
68
+
69
+ def auxiliary_json_allowed(path: Path) -> tuple[bool, list[str]]:
70
+ errors: list[str] = []
71
+ for item in os.environ.get(CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV, "").split(os.pathsep):
72
+ if not item:
73
+ continue
74
+ try:
75
+ allowed = Path(item).expanduser().resolve()
76
+ except OSError as exc:
77
+ errors.append(f"{CLEAN_ROOM_AUXILIARY_JSON_ALLOWLIST_ENV} has invalid path {item!r}: {exc}")
78
+ continue
79
+ if path == allowed:
80
+ return True, errors
81
+ return False, errors
82
+
83
+
84
+ def resolve_ref(root_schema: dict, ref: str) -> dict:
85
+ if not ref.startswith("#/"):
86
+ raise ValueError(f"unsupported external schema ref {ref}")
87
+ current: Any = root_schema
88
+ for part in ref[2:].split("/"):
89
+ key = part.replace("~1", "/").replace("~0", "~")
90
+ if not isinstance(current, dict) or key not in current:
91
+ raise ValueError(f"unresolvable schema ref {ref}")
92
+ current = current[key]
93
+ if not isinstance(current, dict):
94
+ raise ValueError(f"schema ref {ref} did not resolve to an object")
95
+ return current
96
+
97
+
98
+ def path_label(path: tuple[str | int, ...]) -> str:
99
+ if not path:
100
+ return "<root>"
101
+ return "/" + "/".join(str(part) for part in path)
102
+
103
+
104
+ def type_matches(value: Any, expected: str) -> bool:
105
+ if expected == "object":
106
+ return isinstance(value, dict)
107
+ if expected == "array":
108
+ return isinstance(value, list)
109
+ if expected == "string":
110
+ return isinstance(value, str)
111
+ if expected == "boolean":
112
+ return isinstance(value, bool)
113
+ if expected == "null":
114
+ return value is None
115
+ if expected == "integer":
116
+ return isinstance(value, int) and not isinstance(value, bool)
117
+ if expected == "number":
118
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
119
+ return True
120
+
121
+
122
+ def valid_date_time(value: str) -> bool:
123
+ candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
124
+ try:
125
+ datetime.fromisoformat(candidate)
126
+ except ValueError:
127
+ return False
128
+ return True
129
+
130
+
131
+ def validate_value(value: Any, schema: dict, root_schema: dict, path: tuple[str | int, ...] = ()) -> list[str]:
132
+ errors: list[str] = []
133
+ if "$ref" in schema:
134
+ try:
135
+ errors.extend(validate_value(value, resolve_ref(root_schema, schema["$ref"]), root_schema, path))
136
+ except ValueError as exc:
137
+ errors.append(f"{path_label(path)}: {exc}")
138
+ return errors
139
+ schema = {key: item for key, item in schema.items() if key != "$ref"}
140
+ if not schema:
141
+ return errors
142
+
143
+ all_of = schema.get("allOf")
144
+ if isinstance(all_of, list):
145
+ for index, subschema in enumerate(all_of):
146
+ if isinstance(subschema, dict):
147
+ errors.extend(validate_value(value, subschema, root_schema, path))
148
+ else:
149
+ errors.append(f"{path_label(path)}: allOf[{index}] is not a schema object")
150
+
151
+ if_schema = schema.get("if")
152
+ if isinstance(if_schema, dict) and not validate_value(value, if_schema, root_schema, path):
153
+ then_schema = schema.get("then")
154
+ if isinstance(then_schema, dict):
155
+ errors.extend(validate_value(value, then_schema, root_schema, path))
156
+
157
+ if "const" in schema and value != schema["const"]:
158
+ errors.append(f"{path_label(path)}: expected const {schema['const']!r}")
159
+ if "enum" in schema and value not in schema["enum"]:
160
+ errors.append(f"{path_label(path)}: expected one of {schema['enum']!r}")
161
+
162
+ expected_type = schema.get("type")
163
+ if isinstance(expected_type, str) and not type_matches(value, expected_type):
164
+ errors.append(f"{path_label(path)}: expected {expected_type}")
165
+ return errors
166
+ if isinstance(expected_type, list) and not any(type_matches(value, item) for item in expected_type):
167
+ errors.append(f"{path_label(path)}: expected one of types {expected_type!r}")
168
+ return errors
169
+
170
+ if isinstance(value, dict):
171
+ required = schema.get("required", [])
172
+ if isinstance(required, list):
173
+ for field in required:
174
+ if isinstance(field, str) and field not in value:
175
+ errors.append(f"{path_label(path)}: missing required field {field!r}")
176
+
177
+ properties = schema.get("properties", {})
178
+ if isinstance(properties, dict):
179
+ for field, field_schema in properties.items():
180
+ if field in value and isinstance(field_schema, dict):
181
+ errors.extend(validate_value(value[field], field_schema, root_schema, path + (field,)))
182
+ if schema.get("additionalProperties") is False:
183
+ for field in sorted(set(value) - set(properties)):
184
+ errors.append(f"{path_label(path + (field,))}: additional property is not allowed")
185
+
186
+ if isinstance(value, list):
187
+ min_items = schema.get("minItems")
188
+ if isinstance(min_items, int) and len(value) < min_items:
189
+ errors.append(f"{path_label(path)}: fewer than minItems {min_items}")
190
+ max_items = schema.get("maxItems")
191
+ if isinstance(max_items, int) and len(value) > max_items:
192
+ errors.append(f"{path_label(path)}: more than maxItems {max_items}")
193
+ if schema.get("uniqueItems") is True:
194
+ seen: set[str] = set()
195
+ for item in value:
196
+ marker = json.dumps(item, sort_keys=True, separators=(",", ":"))
197
+ if marker in seen:
198
+ errors.append(f"{path_label(path)}: duplicate item violates uniqueItems")
199
+ break
200
+ seen.add(marker)
201
+ item_schema = schema.get("items")
202
+ if isinstance(item_schema, dict):
203
+ for index, item in enumerate(value):
204
+ errors.extend(validate_value(item, item_schema, root_schema, path + (index,)))
205
+
206
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
207
+ minimum = schema.get("minimum")
208
+ if isinstance(minimum, (int, float)) and value < minimum:
209
+ errors.append(f"{path_label(path)}: less than minimum {minimum}")
210
+ maximum = schema.get("maximum")
211
+ if isinstance(maximum, (int, float)) and value > maximum:
212
+ errors.append(f"{path_label(path)}: greater than maximum {maximum}")
213
+
214
+ if isinstance(value, str):
215
+ min_length = schema.get("minLength")
216
+ if isinstance(min_length, int) and len(value) < min_length:
217
+ errors.append(f"{path_label(path)}: shorter than minLength {min_length}")
218
+ pattern = schema.get("pattern")
219
+ if isinstance(pattern, str) and re.search(pattern, value) is None:
220
+ errors.append(f"{path_label(path)}: does not match pattern {pattern!r}")
221
+ if schema.get("format") == "date-time" and not valid_date_time(value):
222
+ errors.append(f"{path_label(path)}: invalid date-time")
223
+
224
+ return errors
225
+
226
+
227
+ def main() -> int:
228
+ payload, payload_error = load_payload()
229
+ if payload_error:
230
+ print(f"clean-room schema check failed: {payload_error}", file=sys.stderr)
231
+ return 1
232
+ paths, path_errors = checked_write_paths(payload, "clean-room schema check")
233
+ if path_errors:
234
+ for error in path_errors:
235
+ print(f"clean-room schema check failed: {error}", file=sys.stderr)
236
+ return 1
237
+ for path in paths:
238
+ if path.suffix.lower() != ".json" or not path.is_file():
239
+ continue
240
+ in_clean_root = path_under_env(path, "CLEAN_ROOM_CLEAN_ROOTS")
241
+ try:
242
+ data = json.loads(path.read_text(encoding="utf-8"))
243
+ except json.JSONDecodeError as exc:
244
+ print(f"clean-room JSON parse failed for {path}: {exc}", file=sys.stderr)
245
+ return 1
246
+ if not isinstance(data, dict):
247
+ if in_clean_root:
248
+ print(f"clean-room schema check failed for {path}: clean JSON artifact must be an object", file=sys.stderr)
249
+ return 1
250
+ continue
251
+ kind = artifact_kind(path, data)
252
+ if not kind:
253
+ allowed, allowlist_errors = auxiliary_json_allowed(path)
254
+ if allowlist_errors:
255
+ for error in allowlist_errors:
256
+ print(f"clean-room schema check failed: {error}", file=sys.stderr)
257
+ return 1
258
+ if in_clean_root and not allowed:
259
+ print(f"clean-room schema check failed for {path}: unrecognized clean JSON artifact", file=sys.stderr)
260
+ return 1
261
+ continue
262
+ if in_clean_root and kind == "source-index":
263
+ print(f"clean-room schema check failed for {path}: source-index.json is contaminated-only", file=sys.stderr)
264
+ return 1
265
+ schema_path = schema_dir() / SCHEMA_BY_ARTIFACT[kind]
266
+ try:
267
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
268
+ except (OSError, json.JSONDecodeError) as exc:
269
+ print(f"clean-room schema load failed for {schema_path}: {exc}", file=sys.stderr)
270
+ return 1
271
+ errors = validate_value(data, schema, schema)
272
+ if errors:
273
+ print(f"clean-room schema check failed for {path}:", file=sys.stderr)
274
+ for error in errors[:20]:
275
+ print(f" {error}", file=sys.stderr)
276
+ if len(errors) > 20:
277
+ print(f" ... {len(errors) - 20} more error(s)", file=sys.stderr)
278
+ return 1
279
+ return 0
280
+
281
+
282
+ if __name__ == "__main__":
283
+ raise SystemExit(main())
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ function sha256Bytes(bytes) {
8
+ return crypto.createHash('sha256').update(bytes).digest('hex');
9
+ }
10
+
11
+ function fileHash(filePath) {
12
+ return sha256Bytes(fs.readFileSync(filePath));
13
+ }
14
+
15
+ function normalizeRelativePath(relPath) {
16
+ if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
17
+ return null;
18
+ }
19
+ if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
20
+ return null;
21
+ }
22
+ const normalized = relPath.replace(/\\/g, '/');
23
+ const parts = normalized.split('/');
24
+ if (parts.some((part) => part === '' || part === '.' || part === '..')) {
25
+ return null;
26
+ }
27
+ return parts.join('/');
28
+ }
29
+
30
+ function resolveInside(root, relPath) {
31
+ const normalized = normalizeRelativePath(relPath);
32
+ if (!normalized) {
33
+ throw new Error(`invalid relative path: ${relPath}`);
34
+ }
35
+ const rootPath = path.resolve(root);
36
+ const fullPath = path.resolve(rootPath, normalized);
37
+ if (fullPath !== rootPath && !fullPath.startsWith(rootPath + path.sep)) {
38
+ throw new Error(`path escapes install root: ${relPath}`);
39
+ }
40
+ return fullPath;
41
+ }
42
+
43
+ function atomicWriteFile(filePath, data, options = {}) {
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ const suffix = `${process.pid}-${Date.now()}-${crypto.randomUUID()}`;
46
+ const tmpPath = `${filePath}.tmp-${suffix}`;
47
+ try {
48
+ fs.writeFileSync(tmpPath, data, options);
49
+ fs.renameSync(tmpPath, filePath);
50
+ } catch (err) {
51
+ try {
52
+ fs.rmSync(tmpPath, { force: true });
53
+ } catch {
54
+ // Best effort cleanup only.
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ function readJsonFile(filePath, fallback) {
61
+ if (!fs.existsSync(filePath)) {
62
+ return fallback;
63
+ }
64
+ try {
65
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
66
+ } catch (err) {
67
+ throw new Error(`${filePath} is not valid JSON: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ function writeJsonFile(filePath, value) {
72
+ atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
73
+ }
74
+
75
+ function listFiles(root, options = {}) {
76
+ if (!fs.existsSync(root)) {
77
+ return [];
78
+ }
79
+ const ignoreNames = new Set(options.ignoreNames || []);
80
+ const files = [];
81
+ function walk(dir, relBase) {
82
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
83
+ for (const entry of entries) {
84
+ if (ignoreNames.has(entry.name)) {
85
+ continue;
86
+ }
87
+ const fullPath = path.join(dir, entry.name);
88
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
89
+ if (entry.isDirectory()) {
90
+ walk(fullPath, relPath);
91
+ } else if (entry.isFile()) {
92
+ files.push(relPath);
93
+ }
94
+ }
95
+ }
96
+ walk(root, '');
97
+ return files.sort();
98
+ }
99
+
100
+ function removeEmptyParents(startDir, stopDir) {
101
+ let current = path.resolve(startDir);
102
+ const stop = path.resolve(stopDir);
103
+ while (current !== stop && current.startsWith(stop + path.sep)) {
104
+ try {
105
+ fs.rmdirSync(current);
106
+ } catch {
107
+ break;
108
+ }
109
+ current = path.dirname(current);
110
+ }
111
+ }
112
+
113
+ module.exports = {
114
+ atomicWriteFile,
115
+ fileHash,
116
+ listFiles,
117
+ normalizeRelativePath,
118
+ readJsonFile,
119
+ removeEmptyParents,
120
+ resolveInside,
121
+ sha256Bytes,
122
+ writeJsonFile,
123
+ };
package/lib/hooks.cjs ADDED
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { readJsonFile, writeJsonFile } = require('./fs-utils.cjs');
7
+
8
+ const HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart']);
9
+
10
+ const CLEAN_ROOM_HOOKS = [
11
+ {
12
+ event: 'PreToolUse',
13
+ matcher: 'Bash|Shell',
14
+ checks: ['require-clean-room-env.py', 'deny-clean-room-shell.py'],
15
+ },
16
+ {
17
+ event: 'PreToolUse',
18
+ matcher: 'Read|Glob|Grep',
19
+ checks: ['require-clean-room-env.py', 'deny-clean-source-read.py'],
20
+ },
21
+ {
22
+ event: 'PreToolUse',
23
+ matcher: 'Write|Edit|MultiEdit',
24
+ checks: ['require-clean-room-env.py', 'deny-contaminated-clean-write.py'],
25
+ },
26
+ {
27
+ event: 'PostToolUse',
28
+ matcher: 'Write|Edit|MultiEdit',
29
+ checks: ['require-clean-room-env.py', 'check-artifact-leakage.py', 'validate-json-schema.py'],
30
+ },
31
+ ];
32
+
33
+ function shellQuote(value) {
34
+ const text = String(value);
35
+ if (text === '') {
36
+ return "''";
37
+ }
38
+ return `'${text.replace(/'/g, "'\"'\"'")}'`;
39
+ }
40
+
41
+ function buildHookCommand({ pythonPath, wrapperPath, mode, checks }) {
42
+ const parts = [
43
+ shellQuote(pythonPath),
44
+ shellQuote(wrapperPath),
45
+ '--mode',
46
+ mode,
47
+ ];
48
+ for (const check of checks) {
49
+ parts.push('--check', check);
50
+ }
51
+ return parts.join(' ');
52
+ }
53
+
54
+ function buildHookEntries({ pythonPath, wrapperPath, mode }) {
55
+ return CLEAN_ROOM_HOOKS.map((entry) => ({
56
+ event: entry.event,
57
+ matcher: entry.matcher,
58
+ hook: {
59
+ type: 'command',
60
+ command: buildHookCommand({
61
+ pythonPath,
62
+ wrapperPath,
63
+ mode,
64
+ checks: entry.checks,
65
+ }),
66
+ timeout: 10,
67
+ statusMessage: 'Checking clean-room guardrails',
68
+ },
69
+ }));
70
+ }
71
+
72
+ function isManagedHook(hook) {
73
+ return !!(
74
+ hook &&
75
+ typeof hook === 'object' &&
76
+ typeof hook.command === 'string' &&
77
+ hook.command.includes('clean-room-hook.py')
78
+ );
79
+ }
80
+
81
+ function hasTopLevelHookEvents(value) {
82
+ return Object.keys(value || {}).some((key) => HOOK_EVENTS.has(key));
83
+ }
84
+
85
+ function hookTableFor(value) {
86
+ if (value && typeof value.hooks === 'object' && value.hooks !== null && !Array.isArray(value.hooks)) {
87
+ return value.hooks;
88
+ }
89
+ if (hasTopLevelHookEvents(value)) {
90
+ return value;
91
+ }
92
+ if (!value.hooks || typeof value.hooks !== 'object' || Array.isArray(value.hooks)) {
93
+ value.hooks = {};
94
+ }
95
+ return value.hooks;
96
+ }
97
+
98
+ function removeManagedHookEntriesFromTable(table) {
99
+ let changed = false;
100
+ for (const event of Object.keys(table)) {
101
+ if (!Array.isArray(table[event])) {
102
+ continue;
103
+ }
104
+ const nextEntries = [];
105
+ for (const entry of table[event]) {
106
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) {
107
+ nextEntries.push(entry);
108
+ continue;
109
+ }
110
+ const hooks = entry.hooks.filter((hook) => !isManagedHook(hook));
111
+ if (hooks.length !== entry.hooks.length) {
112
+ changed = true;
113
+ }
114
+ if (hooks.length > 0) {
115
+ nextEntries.push({ ...entry, hooks });
116
+ } else {
117
+ changed = true;
118
+ }
119
+ }
120
+ if (nextEntries.length > 0) {
121
+ table[event] = nextEntries;
122
+ } else {
123
+ delete table[event];
124
+ }
125
+ if (nextEntries.length !== table[event]?.length) {
126
+ changed = true;
127
+ }
128
+ }
129
+ return changed;
130
+ }
131
+
132
+ function mergeHookEntries(configPath, entries, options = {}) {
133
+ const original = readJsonFile(configPath, {});
134
+ if (!original || typeof original !== 'object' || Array.isArray(original)) {
135
+ throw new Error(`${configPath} must contain a JSON object`);
136
+ }
137
+ const next = structuredClone(original);
138
+ const table = hookTableFor(next);
139
+ removeManagedHookEntriesFromTable(table);
140
+ for (const entry of entries) {
141
+ if (!Array.isArray(table[entry.event])) {
142
+ table[entry.event] = [];
143
+ }
144
+ table[entry.event].push({
145
+ matcher: entry.matcher,
146
+ hooks: [entry.hook],
147
+ });
148
+ }
149
+ if (!options.dryRun) {
150
+ writeJsonFile(configPath, next);
151
+ }
152
+ return next;
153
+ }
154
+
155
+ function removeHookEntries(configPath, options = {}) {
156
+ if (!fs.existsSync(configPath)) {
157
+ return null;
158
+ }
159
+ const original = readJsonFile(configPath, {});
160
+ if (!original || typeof original !== 'object' || Array.isArray(original)) {
161
+ throw new Error(`${configPath} must contain a JSON object`);
162
+ }
163
+ const next = structuredClone(original);
164
+ const table = hookTableFor(next);
165
+ const changed = removeManagedHookEntriesFromTable(table);
166
+ if (changed && !options.dryRun) {
167
+ writeJsonFile(configPath, next);
168
+ }
169
+ return changed ? next : original;
170
+ }
171
+
172
+ function renderPackageHooksJson(mode) {
173
+ const hooks = {};
174
+ for (const entry of CLEAN_ROOM_HOOKS) {
175
+ if (!Array.isArray(hooks[entry.event])) {
176
+ hooks[entry.event] = [];
177
+ }
178
+ hooks[entry.event].push({
179
+ matcher: entry.matcher,
180
+ hooks: [
181
+ {
182
+ type: 'command',
183
+ command: [
184
+ 'python3',
185
+ 'hooks/clean-room-hook.py',
186
+ '--mode',
187
+ mode,
188
+ ...entry.checks.flatMap((check) => ['--check', check]),
189
+ ].join(' '),
190
+ },
191
+ ],
192
+ });
193
+ }
194
+ return `${JSON.stringify({ hooks }, null, 2)}\n`;
195
+ }
196
+
197
+ function configPathForRuntime(runtime, targetRoot) {
198
+ if (runtime === 'codex') {
199
+ return path.join(targetRoot, 'hooks.json');
200
+ }
201
+ if (runtime === 'claude') {
202
+ return path.join(targetRoot, 'settings.json');
203
+ }
204
+ return null;
205
+ }
206
+
207
+ module.exports = {
208
+ buildHookEntries,
209
+ configPathForRuntime,
210
+ renderPackageHooksJson,
211
+ removeHookEntries,
212
+ mergeHookEntries,
213
+ shellQuote,
214
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "clean-room-skill",
3
+ "version": "0.1.0",
4
+ "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
+ "bin": {
6
+ "clean-room-skill": "bin/install.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib",
11
+ "skills",
12
+ "agents",
13
+ "hooks",
14
+ "examples",
15
+ "plugin.json",
16
+ ".codex-plugin",
17
+ ".claude-plugin",
18
+ "README.md",
19
+ "LICENSE",
20
+ "!**/__pycache__",
21
+ "!**/*.pyc",
22
+ "!**/*.pyo",
23
+ "!**/.DS_Store"
24
+ ],
25
+ "keywords": [
26
+ "clean-room",
27
+ "specification",
28
+ "reverse-engineering",
29
+ "compliance",
30
+ "codex",
31
+ "claude"
32
+ ],
33
+ "author": {
34
+ "name": "whit3rabbit"
35
+ },
36
+ "license": "MIT",
37
+ "homepage": "https://github.com/whit3rabbit/clean-room-skill",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/whit3rabbit/clean-room-skill.git"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "scripts": {
46
+ "test": "node --test",
47
+ "test:install": "node --test tests/install.test.js"
48
+ }
49
+ }
package/plugin.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "clean-room",
3
+ "version": "0.1.0",
4
+ "description": "Spec-first clean-room workflow for authorized source analysis without replacement code.",
5
+ "author": {
6
+ "name": "whit3rabbit"
7
+ },
8
+ "homepage": "https://github.com/whit3rabbit/clean-room-skill",
9
+ "repository": "https://github.com/whit3rabbit/clean-room-skill",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "clean-room",
13
+ "specification",
14
+ "reverse-engineering",
15
+ "compliance"
16
+ ],
17
+ "skills": "./skills/",
18
+ "agents": "./agents/",
19
+ "hooks": "./hooks/hooks.json"
20
+ }