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,199 @@
1
+ #!/usr/bin/env python3
2
+ """Explicit local tool setup for clean-room source-index preflight."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from typing import Any
11
+
12
+ import clean_room_tooling
13
+
14
+
15
+ LOCAL_INSTALL_TIMEOUT_SECONDS = 600
16
+ NPM_TOOLS = {
17
+ "ast-grep": {
18
+ "package": "@ast-grep/cli",
19
+ "source": "https://www.npmjs.com/package/@ast-grep/cli",
20
+ "bin": "ast-grep",
21
+ },
22
+ }
23
+ STATUS_TOOLS = [
24
+ "node",
25
+ "npm",
26
+ "ast-grep",
27
+ "sg",
28
+ "ctags",
29
+ "universal-ctags",
30
+ "scip",
31
+ ]
32
+ STATUS_PACKAGES = ["@ast-grep/cli"]
33
+
34
+
35
+ def parse_args() -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(
37
+ description="Report or explicitly install local clean-room source-index helper tools into the user cache."
38
+ )
39
+ parser.add_argument("--status", action="store_true", help="Print tool discovery status as JSON")
40
+ parser.add_argument(
41
+ "--install-local",
42
+ choices=sorted(NPM_TOOLS),
43
+ help="Install one approved npm-backed tool into ~/.cache/re-skills/clean-room-tools/npm",
44
+ )
45
+ parser.add_argument(
46
+ "--version",
47
+ help="Exact npm package version to install. Required with --install-local.",
48
+ )
49
+ parser.add_argument(
50
+ "--allow-npm-scripts",
51
+ action="store_true",
52
+ help="Allow npm lifecycle scripts during local install. Default is --ignore-scripts.",
53
+ )
54
+ parser.add_argument(
55
+ "--allow-working-project-tools",
56
+ action="store_true",
57
+ help="Include project-local .local/bin, .bin, node_modules/.bin, and npm prefix/global tools in --status output.",
58
+ )
59
+ parser.add_argument(
60
+ "--probe-tools",
61
+ action="store_true",
62
+ help="Execute discovered tools with version commands in --status output. Default is stat-only.",
63
+ )
64
+ args = parser.parse_args()
65
+ if not args.status and not args.install_local:
66
+ parser.error("choose --status or --install-local")
67
+ if args.install_local and not args.version:
68
+ parser.error("--install-local requires --version")
69
+ return args
70
+
71
+
72
+ def write_json(data: dict[str, Any]) -> None:
73
+ json.dump(data, sys.stdout, indent=2, sort_keys=True)
74
+ sys.stdout.write("\n")
75
+
76
+
77
+ def package_status(allow_project_tools: bool = False) -> dict[str, Any]:
78
+ status: dict[str, Any] = {}
79
+ for package in STATUS_PACKAGES:
80
+ parts = package.split("/")
81
+ checked = [
82
+ (root / "node_modules").joinpath(*parts)
83
+ for root in clean_room_tooling.node_resolver_roots(allow_project_tools)
84
+ ]
85
+ found = next((path for path in checked if path.exists()), None)
86
+ status[package] = (
87
+ clean_room_tooling.observed(found.as_posix())
88
+ if found
89
+ else clean_room_tooling.unknown(
90
+ "package unavailable",
91
+ value={"checked_locations": [path.as_posix() for path in checked]},
92
+ )
93
+ )
94
+ return status
95
+
96
+
97
+ def status_report(allow_project_tools: bool = False, probe_tools: bool = False) -> dict[str, Any]:
98
+ dependency_report = clean_room_tooling.dependency_report(allow_project_tools, probe_tools)
99
+ return {
100
+ "schema_version": 1,
101
+ "policy": dependency_report["external_tools_policy"],
102
+ "tool_probe_mode": dependency_report["tool_probe_mode"],
103
+ "tool_trust_mode": clean_room_tooling.tool_trust_mode(allow_project_tools),
104
+ "local_cache": clean_room_tooling.observed(clean_room_tooling.USER_TOOLS_DIR.as_posix()),
105
+ "installable_local_tools": {
106
+ name: {"package": item["package"], "source": item["source"]}
107
+ for name, item in sorted(NPM_TOOLS.items())
108
+ },
109
+ "tools": {
110
+ name: clean_room_tooling.executable_status(
111
+ name,
112
+ allow_project_tools=allow_project_tools,
113
+ probe_tools=probe_tools,
114
+ )
115
+ for name in STATUS_TOOLS
116
+ },
117
+ "node_packages": package_status(allow_project_tools),
118
+ }
119
+
120
+
121
+ def npm_package_spec(package: str, version: str) -> str:
122
+ return f"{package}@{version}"
123
+
124
+
125
+ def install_npm_tool(tool: str, version: str, allow_npm_scripts: bool = False) -> dict[str, Any]:
126
+ spec = NPM_TOOLS[tool]
127
+ npm = clean_room_tooling.find_executable("npm")
128
+ if npm is None:
129
+ return clean_room_tooling.error_fact(
130
+ "npm unavailable; install Node/npm first or set NPM_BIN",
131
+ evidence={"checked_locations": clean_room_tooling.checked_executable_locations("npm")},
132
+ )
133
+
134
+ prefix = clean_room_tooling.USER_NPM_PREFIX
135
+ prefix.mkdir(parents=True, exist_ok=True)
136
+ package_spec = npm_package_spec(str(spec["package"]), version)
137
+ argv = [
138
+ npm.path.as_posix(),
139
+ "install",
140
+ "--prefix",
141
+ prefix.as_posix(),
142
+ "--save-exact",
143
+ ]
144
+ if not allow_npm_scripts:
145
+ argv.append("--ignore-scripts")
146
+ argv.append(package_spec)
147
+
148
+ completed = subprocess.run(
149
+ argv,
150
+ capture_output=True,
151
+ text=True,
152
+ timeout=LOCAL_INSTALL_TIMEOUT_SECONDS,
153
+ check=False,
154
+ )
155
+ status = "observed" if completed.returncode == 0 else "error"
156
+ result: dict[str, Any] = {
157
+ "schema_version": 1,
158
+ "tool": tool,
159
+ "source": spec["source"],
160
+ "version": version,
161
+ "package_spec": package_spec,
162
+ "install_root": clean_room_tooling.observed(prefix.as_posix()),
163
+ "install_trust_mode": clean_room_tooling.observed(
164
+ "explicit-version",
165
+ evidence={"npm_lifecycle_scripts": "allowed" if allow_npm_scripts else "ignored"},
166
+ ),
167
+ "command": clean_room_tooling.fact(
168
+ status,
169
+ {
170
+ "argv": argv,
171
+ "returncode": completed.returncode,
172
+ "stdout": completed.stdout.strip(),
173
+ "stderr": completed.stderr.strip(),
174
+ },
175
+ ),
176
+ }
177
+ resolved = clean_room_tooling.find_executable(str(spec["bin"]))
178
+ result["resolved_tool"] = (
179
+ clean_room_tooling.observed({"path": resolved.path.as_posix(), "source": resolved.source})
180
+ if resolved
181
+ else clean_room_tooling.unknown("tool installed but executable was not resolved")
182
+ )
183
+ return result
184
+
185
+
186
+ def main() -> int:
187
+ args = parse_args()
188
+ if args.status:
189
+ write_json(status_report(args.allow_working_project_tools, args.probe_tools))
190
+ if args.install_local:
191
+ result = install_npm_tool(args.install_local, args.version, args.allow_npm_scripts)
192
+ write_json(result)
193
+ if result.get("command", {}).get("status") == "error":
194
+ return 1
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
@@ -0,0 +1,370 @@
1
+ """Tool discovery helpers for clean-room source-index preflight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ from typing import Any
10
+
11
+
12
+ COMMAND_TIMEOUT_SECONDS = 30
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ PLUGIN_ROOT = Path(__file__).resolve().parents[3]
15
+ SKILL_TOOLS_DIR = SKILL_ROOT / "tools"
16
+ USER_TOOLS_DIR = Path.home() / ".cache" / "re-skills" / "clean-room-tools"
17
+ USER_NPM_PREFIX = USER_TOOLS_DIR / "npm"
18
+ PROJECT_TOOLS_ENV = "RE_SKILLS_TRUST_PROJECT_TOOLS"
19
+ TRUSTED_PATH_PREFIXES = (
20
+ Path("/bin"),
21
+ Path("/usr/bin"),
22
+ Path("/usr/sbin"),
23
+ Path("/sbin"),
24
+ Path("/Library/Apple/usr/bin"),
25
+ Path("/opt/homebrew"),
26
+ Path("/usr/local"),
27
+ )
28
+
29
+ TOOL_ENV = {
30
+ "ast-grep": "AST_GREP_BIN",
31
+ "ctags": "CTAGS_BIN",
32
+ "node": "NODE_BIN",
33
+ "npm": "NPM_BIN",
34
+ "scip": "SCIP_BIN",
35
+ "sg": "SG_BIN",
36
+ "universal-ctags": "UNIVERSAL_CTAGS_BIN",
37
+ }
38
+
39
+ BREW_HINTS = {
40
+ "ast-grep": "Offer to run: brew install ast-grep",
41
+ "ctags": "Offer to run: brew install universal-ctags",
42
+ "node": "Offer to run: brew install node",
43
+ "npm": "Offer to run: brew install node",
44
+ "scip": "Install scip only when source-index export is explicitly needed.",
45
+ "sg": "Offer to run: brew install ast-grep",
46
+ "universal-ctags": "Offer to run: brew install universal-ctags",
47
+ }
48
+
49
+ LOCAL_HINTS = {
50
+ "ast-grep": "Or run scripts/clean_room_tool_manager.py --install-local ast-grep --version <exact-version>.",
51
+ "ctags": "Or place ctags under ~/.cache/re-skills/clean-room-tools/bin/ or set CTAGS_BIN.",
52
+ "node": "Or place node under ~/.cache/re-skills/clean-room-tools/bin/ or set NODE_BIN.",
53
+ "npm": "Or place npm under ~/.cache/re-skills/clean-room-tools/bin/ or set NPM_BIN.",
54
+ "scip": "Or place scip under ~/.cache/re-skills/clean-room-tools/bin/ or set SCIP_BIN.",
55
+ "sg": "Or place sg under ~/.cache/re-skills/clean-room-tools/bin/ or set SG_BIN.",
56
+ "universal-ctags": "Or place universal-ctags under ~/.cache/re-skills/clean-room-tools/bin/ or set UNIVERSAL_CTAGS_BIN.",
57
+ }
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ResolvedTool:
62
+ path: Path
63
+ source: str
64
+
65
+
66
+ def path_is_relative_to(path: Path, root: Path) -> bool:
67
+ try:
68
+ path.relative_to(root)
69
+ except ValueError:
70
+ return False
71
+ return True
72
+
73
+
74
+ def project_tools_allowed(allow_project_tools: bool = False) -> bool:
75
+ env_value = os.environ.get(PROJECT_TOOLS_ENV, "")
76
+ return allow_project_tools or env_value.lower() in {"1", "true", "yes", "on"}
77
+
78
+
79
+ def trust_mode_name(allow_project_tools: bool = False) -> str:
80
+ return "project-tools" if project_tools_allowed(allow_project_tools) else "trusted-only"
81
+
82
+
83
+ def fact(status: str, value: Any = None, evidence: Any = None, note: str | None = None) -> dict[str, Any]:
84
+ item: dict[str, Any] = {"status": status, "value": value}
85
+ if evidence is not None:
86
+ item["evidence"] = evidence
87
+ if note:
88
+ item["note"] = note
89
+ return item
90
+
91
+
92
+ def observed(value: Any, evidence: Any = None, note: str | None = None) -> dict[str, Any]:
93
+ return fact("observed", value, evidence, note)
94
+
95
+
96
+ def unknown(note: str, value: Any = None) -> dict[str, Any]:
97
+ return fact("unknown", value, note=note)
98
+
99
+
100
+ def error_fact(message: str, evidence: Any = None) -> dict[str, Any]:
101
+ return fact("error", None, evidence=evidence, note=message)
102
+
103
+
104
+ def run_command(argv: list[str], timeout: int = COMMAND_TIMEOUT_SECONDS) -> dict[str, Any]:
105
+ try:
106
+ completed = subprocess.run(
107
+ argv,
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=timeout,
111
+ check=False,
112
+ )
113
+ except subprocess.TimeoutExpired:
114
+ return error_fact(f"command timed out after {timeout} seconds", evidence=argv)
115
+ except Exception as exc:
116
+ return error_fact(str(exc), evidence=argv)
117
+
118
+ return fact(
119
+ "observed" if completed.returncode == 0 else "error",
120
+ {
121
+ "argv": argv,
122
+ "returncode": completed.returncode,
123
+ "stdout": completed.stdout.strip(),
124
+ "stderr": completed.stderr.strip(),
125
+ },
126
+ )
127
+
128
+
129
+ def probe_command(argv: list[str], timeout: int = COMMAND_TIMEOUT_SECONDS) -> str | None:
130
+ try:
131
+ completed = subprocess.run(
132
+ argv,
133
+ capture_output=True,
134
+ text=True,
135
+ timeout=timeout,
136
+ check=False,
137
+ )
138
+ except Exception:
139
+ return None
140
+ if completed.returncode != 0:
141
+ return None
142
+ value = completed.stdout.strip()
143
+ return value or None
144
+
145
+
146
+ def local_executable_candidates(name: str) -> list[tuple[Path, str]]:
147
+ candidates: list[tuple[Path, str]] = []
148
+ for root, source in [(USER_TOOLS_DIR, "user-cache"), (SKILL_TOOLS_DIR, "skill-local")]:
149
+ candidates.extend(
150
+ [
151
+ (root / name, source),
152
+ (root / "bin" / name, source),
153
+ (root / name / "bin" / name, source),
154
+ (root / "npm" / "node_modules" / ".bin" / name, source),
155
+ ]
156
+ )
157
+ return candidates
158
+
159
+
160
+ def project_executable_candidates(name: str) -> list[tuple[Path, str]]:
161
+ return [
162
+ (Path.cwd() / ".local" / "bin" / name, "working-project-local"),
163
+ (Path.cwd() / ".bin" / name, "working-project-bin"),
164
+ (Path.cwd() / "node_modules" / ".bin" / name, "working-project-node-modules"),
165
+ (SKILL_ROOT / "node_modules" / ".bin" / name, "skill-node-modules"),
166
+ (PLUGIN_ROOT / "node_modules" / ".bin" / name, "plugin-local"),
167
+ ]
168
+
169
+
170
+ def trusted_path_candidates(name: str) -> list[tuple[Path, str]]:
171
+ candidates: list[tuple[Path, str]] = []
172
+ for raw_dir in os.environ.get("PATH", "").split(os.pathsep):
173
+ if not raw_dir:
174
+ continue
175
+ directory = Path(raw_dir).expanduser()
176
+ candidate = directory / name
177
+ try:
178
+ normalized_dir = directory.resolve(strict=False)
179
+ except RuntimeError:
180
+ normalized_dir = directory.absolute()
181
+ if any(path_is_relative_to(normalized_dir, prefix) for prefix in TRUSTED_PATH_PREFIXES):
182
+ candidates.append((candidate, "trusted-path"))
183
+ return candidates
184
+
185
+
186
+ def npm_path(command: list[str], allow_project_tools: bool = False) -> Path | None:
187
+ npm = find_executable("npm", allow_project_tools)
188
+ if npm is None:
189
+ return None
190
+ value = probe_command([npm.path.as_posix(), *command])
191
+ if value is None:
192
+ return None
193
+ return Path(value).expanduser()
194
+
195
+
196
+ def executable_candidates(
197
+ name: str,
198
+ allow_project_tools: bool = False,
199
+ probe_tools: bool = False,
200
+ ) -> list[tuple[Path, str]]:
201
+ candidates: list[tuple[Path, str]] = []
202
+ env_name = TOOL_ENV.get(name)
203
+ if env_name:
204
+ env_value = os.environ.get(env_name)
205
+ if env_value:
206
+ candidates.append((Path(env_value), env_name))
207
+
208
+ candidates.extend(local_executable_candidates(name))
209
+
210
+ if project_tools_allowed(allow_project_tools):
211
+ candidates.extend(project_executable_candidates(name))
212
+ if probe_tools:
213
+ prefix = npm_path(["prefix"], allow_project_tools=False)
214
+ if prefix is not None:
215
+ candidates.append((prefix / "node_modules" / ".bin" / name, "npm-prefix"))
216
+ root = npm_path(["root"], allow_project_tools=False)
217
+ if root is not None:
218
+ candidates.append((root / ".bin" / name, "npm-prefix"))
219
+ global_prefix = npm_path(["prefix", "-g"], allow_project_tools=False)
220
+ if global_prefix is not None:
221
+ candidates.append((global_prefix / "bin" / name, "npm-global"))
222
+
223
+ candidates.extend(trusted_path_candidates(name))
224
+ return candidates
225
+
226
+
227
+ def find_executable(
228
+ name: str,
229
+ allow_project_tools: bool = False,
230
+ probe_tools: bool = False,
231
+ ) -> ResolvedTool | None:
232
+ seen: set[Path] = set()
233
+ for candidate, source in executable_candidates(name, allow_project_tools, probe_tools):
234
+ candidate = candidate.expanduser()
235
+ try:
236
+ normalized = candidate.resolve(strict=False)
237
+ except RuntimeError:
238
+ normalized = candidate.absolute()
239
+ if normalized in seen:
240
+ continue
241
+ seen.add(normalized)
242
+ if candidate.is_file() and os.access(candidate, os.X_OK):
243
+ return ResolvedTool(candidate.resolve(), source)
244
+ return None
245
+
246
+
247
+ def checked_executable_locations(
248
+ name: str,
249
+ allow_project_tools: bool = False,
250
+ probe_tools: bool = False,
251
+ ) -> list[str]:
252
+ locations: list[str] = []
253
+ env_name = TOOL_ENV.get(name)
254
+ if env_name:
255
+ env_value = os.environ.get(env_name)
256
+ locations.append(f"${env_name}" if not env_value else f"${env_name}={env_value}")
257
+ locations.extend(path.as_posix() for path, _source in local_executable_candidates(name))
258
+ if project_tools_allowed(allow_project_tools):
259
+ locations.extend(path.as_posix() for path, _source in project_executable_candidates(name))
260
+ locations.append("npm prefix/global bins" if probe_tools else "npm prefix/global bins require --probe-tools")
261
+ locations.append("trusted PATH allowlist")
262
+ return locations
263
+
264
+
265
+ def missing_tool_hint(name: str) -> str:
266
+ brew = BREW_HINTS.get(name, f"Install {name} with Homebrew if available.")
267
+ local = LOCAL_HINTS.get(name, f"Or place {name} under {USER_TOOLS_DIR.as_posix()} or set an explicit env var.")
268
+ return f"{name} unavailable. {brew}. {local}"
269
+
270
+
271
+ def executable_status(
272
+ name: str,
273
+ version_args: list[str] | None = None,
274
+ allow_project_tools: bool = False,
275
+ probe_tools: bool = False,
276
+ ) -> dict[str, Any]:
277
+ resolved = find_executable(name, allow_project_tools, probe_tools)
278
+ if resolved is None:
279
+ return unknown(
280
+ missing_tool_hint(name),
281
+ value={
282
+ "checked_locations": checked_executable_locations(name, allow_project_tools, probe_tools),
283
+ "brew_option": BREW_HINTS.get(name),
284
+ "local_option": LOCAL_HINTS.get(name),
285
+ "tool_trust_mode": trust_mode_name(allow_project_tools),
286
+ },
287
+ )
288
+ if not probe_tools:
289
+ return observed(
290
+ {
291
+ "path": resolved.path.as_posix(),
292
+ "source": resolved.source,
293
+ "version": unknown("not probed; pass --probe-tools to execute version commands"),
294
+ }
295
+ )
296
+ argv = [resolved.path.as_posix(), *(version_args or ["--version"])]
297
+ return observed(
298
+ {
299
+ "path": resolved.path.as_posix(),
300
+ "source": resolved.source,
301
+ "version": run_command(argv),
302
+ }
303
+ )
304
+
305
+
306
+ def node_resolver_roots(allow_project_tools: bool = False) -> list[Path]:
307
+ roots = [
308
+ USER_NPM_PREFIX,
309
+ USER_TOOLS_DIR,
310
+ SKILL_TOOLS_DIR,
311
+ ]
312
+ if project_tools_allowed(allow_project_tools):
313
+ roots.extend([Path.cwd(), SKILL_ROOT, PLUGIN_ROOT])
314
+ return roots
315
+
316
+
317
+ def tool_trust_mode(allow_project_tools: bool = False) -> dict[str, Any]:
318
+ return observed(
319
+ trust_mode_name(allow_project_tools),
320
+ evidence={
321
+ "explicit_allow_project_tools": bool(allow_project_tools),
322
+ "env": PROJECT_TOOLS_ENV if os.environ.get(PROJECT_TOOLS_ENV) else None,
323
+ },
324
+ )
325
+
326
+
327
+ def dependency_report(allow_project_tools: bool = False, probe_tools: bool = False) -> dict[str, Any]:
328
+ npm_prefix = (
329
+ npm_path(["prefix"], allow_project_tools=False)
330
+ if probe_tools and project_tools_allowed(allow_project_tools)
331
+ else None
332
+ )
333
+ npm_root = (
334
+ npm_path(["root"], allow_project_tools=False)
335
+ if probe_tools and project_tools_allowed(allow_project_tools)
336
+ else None
337
+ )
338
+ return {
339
+ "external_tools_policy": observed(
340
+ "Source-index preflight detects optional AST and indexing tools with filesystem checks by default. "
341
+ "It executes version probes only when --probe-tools is set, and does not consider project-local "
342
+ f"executables unless --allow-working-project-tools or {PROJECT_TOOLS_ENV}=1 is set."
343
+ ),
344
+ "tool_trust_mode": tool_trust_mode(allow_project_tools),
345
+ "tool_locations": observed(
346
+ {
347
+ "user_cache": USER_TOOLS_DIR.as_posix(),
348
+ "user_npm_prefix": USER_NPM_PREFIX.as_posix(),
349
+ "skill_local_tools": SKILL_TOOLS_DIR.as_posix(),
350
+ "trusted_path_prefixes": [path.as_posix() for path in TRUSTED_PATH_PREFIXES],
351
+ "project_local_candidates": [
352
+ ".local/bin",
353
+ ".bin",
354
+ "node_modules/.bin",
355
+ ],
356
+ }
357
+ ),
358
+ "tool_probe_mode": observed("execute-version" if probe_tools else "stat-only"),
359
+ "node": executable_status("node", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
360
+ "npm": executable_status("npm", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
361
+ "ast_grep": executable_status("ast-grep", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
362
+ "sg": executable_status("sg", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
363
+ "ctags": executable_status("ctags", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
364
+ "universal_ctags": executable_status(
365
+ "universal-ctags", allow_project_tools=allow_project_tools, probe_tools=probe_tools
366
+ ),
367
+ "scip": executable_status("scip", allow_project_tools=allow_project_tools, probe_tools=probe_tools),
368
+ "npm_prefix": observed(npm_prefix.as_posix()) if npm_prefix else unknown("npm prefix not probed"),
369
+ "npm_root": observed(npm_root.as_posix()) if npm_root else unknown("npm root not probed"),
370
+ }
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: unattended
3
+ description: Starts the Clean Room startup wizard in bounded unattended controller mode for authorized spec-only source-to-spec work with finite loop limits and safety stops.
4
+ argument-hint: [authorized source scope, separated output roots, optional max iterations]
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Clean Room Unattended
9
+
10
+ Start the clean-room startup wizard with `controller_policy.mode` fixed to `unattended`.
11
+
12
+ Use the canonical `clean-room` skill workflow and references in this plugin. Preserve the same spec-only boundary, role separation, artifact schemas, leakage rules, and hook expectations.
13
+
14
+ Gather only required setup facts:
15
+
16
+ - Authorization statement, requester, allowed actions, prohibited actions, and evidence handling.
17
+ - Source roots, contaminated artifact root, clean root, and optional public or destination reference roots.
18
+ - Target language or destination constraints, if known.
19
+ - Target schema profile: `openspec-delta`, `gsd-planning-package`, `speckit-feature-folder`, or `kiro-spec-folder`.
20
+ - Finite maximum iteration count. Use `10` when the user does not provide a value.
21
+
22
+ Before indexing or artifact generation, confirm that source roots, contaminated artifact roots, and clean roots are separate paths. Stop if authorization is unclear, if the requested output includes replacement implementation code, or if clean and contaminated roots overlap.
23
+
24
+ Record `controller_policy.mode` as `unattended`, `max_units_per_iteration` as `1`, and `max_iterations` as the selected finite value. Include these stop conditions: `authorization-missing`, `scope-change`, `contamination-suspected`, `schema-validation-failed`, `leakage-scan-failed`, `unit-blocked`, `coverage-complete`, and `iteration-limit-reached`.
25
+
26
+ For multi-file source scope, guide agent zero/controller to run `skills/clean-room/scripts/build_source_index.py` as preflight outside clean-room role sessions. Store `source-index.json` only under the contaminated artifact root and never include it in clean handoff packages.