werkmeister 0.0.0 → 0.14.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/CONVENTIONS.md ADDED
@@ -0,0 +1,55 @@
1
+ # Code conventions — `src/`
2
+
3
+ Conventions this CLI holds itself to. They exist because the runtime data in
4
+ `infra/` and `docs/standards/` is consumed as-is and much of the output is
5
+ contract-shaped: generated files, exit codes, and printed text are depended on
6
+ by other tooling, so they are not free to drift.
7
+
8
+ ## Layout
9
+
10
+ | Path | Holds |
11
+ |---|---|
12
+ | `src/main.ts` | the commander program and command registry |
13
+ | `src/commands/<name>.ts` | one module per CLI command |
14
+ | `src/lib/<name>.ts` | shared library modules |
15
+
16
+ Runtime data dirs live alongside the source: `infra/`, `docs/standards/`, `bin/`.
17
+ Path resolution: repo root = `SANDBOX_ROOT` env, else two levels up from
18
+ `dist/lib/` (`src/lib/config.ts` owns `getSandboxRoot()` / `getInfraRoot()`).
19
+
20
+ ## Language
21
+
22
+ - **ESM + NodeNext**: every relative import ends in `.js`
23
+ (`import { x } from "./config.js"`).
24
+ - Keep a module docstring at the top of each file explaining what it owns.
25
+ - Functions/vars are `camelCase`; classes/types `PascalCase`; module-level
26
+ private constants `_UPPER_SNAKE`.
27
+ - **The CLI surface is frozen**: flag names, defaults, help text, printed
28
+ output, exit codes, and JSON shapes are contracts. Changing one is a
29
+ deliberate, breaking change — not a cleanup.
30
+ - YAML via the `yaml` package (`parse`, `stringify`).
31
+ - Templates in `infra/templates/` are rendered with `nunjucks`. They are shared
32
+ verbatim with other consumers, so the environment options in `generate.ts`
33
+ (autoescape, trim/lstrip) are load-bearing — see the notes there before
34
+ touching them.
35
+ - Subprocesses go through `src/lib/proc.ts` (spawnSync-based, synchronous):
36
+ - `run(cmd: string[], opts)` → `{ status, stdout, stderr }`; `check: true` throws `ProcError`.
37
+ - `capture(cmd)` → trimmed stdout, throws on failure.
38
+ - Streaming/inherit-stdio cases: pass `stdio: "inherit"`.
39
+ - `timeout` is in **seconds**, not milliseconds.
40
+ - Filesystem work uses the sync `node:fs` API (`readFileSync` etc.) plus `node:path`.
41
+ - ANSI colors come from `src/lib/colors.ts` — the single definition site.
42
+ - `console.error` for stderr, `console.log` for stdout.
43
+ - `process.exit(n)` for exit codes. Thrown errors reach `main`, which prints
44
+ `Error: <msg>` in red and exits 1; `SANDBOX_DEBUG` re-raises with a stack.
45
+ - Interactive prompts use `node:readline/promises`, which makes the enclosing
46
+ function `async` — command `run()` signatures are `async (args) => Promise<void>`.
47
+ - No new npm dependencies beyond: commander, nunjucks, yaml.
48
+ - Tests are vitest, in `src/lib/__tests__/<name>.test.ts`.
49
+
50
+ ## Behavior rules
51
+
52
+ - Keep comments that explain *why*; drop line-by-line *what* comments.
53
+ - `gate.ts` is deliberately dependency-light (it backs a PreToolUse hook with a
54
+ cold-start budget): it must not import commander/yaml/nunjucks, and must stay
55
+ runnable directly via `node dist/lib/gate.js` — it has a `main()` guard.
package/README.md CHANGED
@@ -1,5 +1,59 @@
1
- # Werkmeister (w9r)
1
+ # sandbox
2
2
 
3
- Name reserved. Werkmeister is the new name of the multi-feature concurrent development
4
- orchestrator currently published as `@jbbz-sandbox/sandbox-cli`. This placeholder will
5
- be replaced by the real package when the rename ships.
3
+ A multi-feature concurrent development orchestrator: run up to 12 isolated
4
+ feature environments side by side, each with its own containers, ports, and
5
+ ingress hostnames.
6
+
7
+ ## Quick start
8
+
9
+ ```sh
10
+ npm install
11
+ npm run build
12
+ ./sandbox --help
13
+ ```
14
+
15
+ `./sandbox` resolves a node runtime (SANDBOX_NODE → PATH → newest nvm) and execs
16
+ `dist/main.js`. `sandbox hooks gate` bypasses the CLI import chain via
17
+ `dist/lib/gate.js` for a fast PreToolUse cold start.
18
+
19
+ ## Tab completion
20
+
21
+ `sandbox __complete` answers bash's `complete -C` protocol (COMP_LINE/COMP_POINT
22
+ in, candidates on stdout), so both shells share one code path and there is no
23
+ generated completion script to keep in sync — candidates come off the commander
24
+ program itself.
25
+
26
+ `sandbox setup` installs it into `~/.zshrc` as a managed block (idempotent;
27
+ re-running replaces its own block and nothing else). To wire it up by hand, or
28
+ in bash, which setup does not touch:
29
+
30
+ ```sh
31
+ autoload -Uz compinit && compinit # zsh only; skip if your rc runs it
32
+ autoload -Uz +X bashcompinit && bashcompinit
33
+ complete -C 'sandbox __complete' sandbox
34
+ ```
35
+
36
+ Completes subcommand names, a command's long flags, and any positional as a
37
+ feature name (`infra` plus the workspaces under the workspaces root).
38
+
39
+ ## Layout
40
+
41
+ - `src/main.ts` — commander program (subcommand registry + dispatch)
42
+ - `src/commands/` — one module per subcommand (`run(args)` entrypoints)
43
+ - `src/lib/` — shared logic
44
+ - `infra/`, `docs/standards/`, `bin/` — runtime data the CLI consumes as-is
45
+ - `CONVENTIONS.md` — the code conventions used throughout
46
+ - `docs/design.md` — scope and decisions
47
+
48
+ ## Notes
49
+
50
+ - CLI parsing is commander; templates in `infra/templates/*.njk` render through
51
+ nunjucks with a small compat layer (`{% do %}`, dict truthiness,
52
+ `map(attribute=…)`) so they stay shared verbatim with other consumers
53
+ - `sandbox mcp` runs the headless MCP server over stdio. Its source is
54
+ `apps/mcp`, bundled into `dist/mcp.js` by `esbuild.mjs` so it ships in the npm
55
+ package. `sandbox dashboard` runs the richer workspaces dashboard from
56
+ `apps/sandbox-dashboard` (Jira, PRs, MCP Apps UI), which is checkout-only;
57
+ where that app is absent the verb falls back to the bundled headless server
58
+ - interactive prompts and process-spawn edges are async (`run()` is
59
+ `async` throughout); core logic stays synchronous
@@ -0,0 +1,254 @@
1
+ #!/bin/bash
2
+ # Generate the self-signed TLS certificate the sandbox ingress terminates.
3
+ #
4
+ # The hostnames are NOT hardcoded here. They come from the sandbox ingress
5
+ # config (`ingress.local_domain` / `ingress.remote_domain` in
6
+ # ~/.sandbox/config.yaml, crossed with the routed services in
7
+ # ~/.sandbox/services.yaml) and are passed in by `sandbox setup` via --domain.
8
+ # That way the certificate SANs, the /etc/hosts entries and the Caddy site
9
+ # blocks all derive from one list instead of three hand-synced copies.
10
+ #
11
+ # Standalone use (no CLI): pass --domain yourself, or set WERKMEISTER_INGRESS_HOSTS
12
+ # to a comma/space-separated list. `sandbox setup` is the normal entry point.
13
+ # WERKMEISTER_HOSTS_FILE overrides /etc/hosts (tests). Each WERKMEISTER_* falls back to SANDBOX_*.
14
+ #
15
+ # Usage:
16
+ # generate-certs.sh [--domain HOST]... [--certs-dir DIR]
17
+ # [--no-trust] [--no-hosts] [--force] [--check]
18
+ #
19
+ # --domain HOST Hostname to cover (repeatable). Also seeds /etc/hosts.
20
+ # --certs-dir DIR Output directory [$SANDBOX_CERTS_DIR, else ~/.docker/certs]
21
+ # --no-trust Skip adding the cert to the macOS System keychain (no sudo)
22
+ # --no-hosts Skip the /etc/hosts update (no sudo)
23
+ # --force Regenerate even if a certificate already exists
24
+ # --check Report only; exit 1 if the cert or /etc/hosts drifted
25
+ #
26
+ # Exit codes: 0 ok / already current, 1 missing-or-stale (--check) or failure.
27
+
28
+ set -euo pipefail
29
+
30
+ CERTS_DIR="${WERKMEISTER_CERTS_DIR:-${SANDBOX_CERTS_DIR:-$HOME/.docker/certs}}"
31
+ HOSTS_FILE="${WERKMEISTER_HOSTS_FILE:-${SANDBOX_HOSTS_FILE:-/etc/hosts}}"
32
+ HOSTS_BEGIN="# BEGIN sandbox ingress"
33
+ HOSTS_END="# END sandbox ingress"
34
+ DO_TRUST=true
35
+ DO_HOSTS=true
36
+ FORCE=false
37
+ CHECK_ONLY=false
38
+ DOMAINS=()
39
+
40
+ # Seed from the env fallback (comma or whitespace separated) before parsing
41
+ # flags, so an explicit --domain adds to it rather than being overridden.
42
+ INGRESS_HOSTS="${WERKMEISTER_INGRESS_HOSTS:-${SANDBOX_INGRESS_HOSTS:-}}"
43
+ if [ -n "$INGRESS_HOSTS" ]; then
44
+ IFS=', ' read -r -a DOMAINS <<< "$INGRESS_HOSTS"
45
+ fi
46
+
47
+ while [ $# -gt 0 ]; do
48
+ case "$1" in
49
+ --domain) DOMAINS+=("$2"); shift 2 ;;
50
+ --domain=*) DOMAINS+=("${1#*=}"); shift ;;
51
+ --certs-dir) CERTS_DIR="$2"; shift 2 ;;
52
+ --certs-dir=*) CERTS_DIR="${1#*=}"; shift ;;
53
+ --no-trust) DO_TRUST=false; shift ;;
54
+ --no-hosts) DO_HOSTS=false; shift ;;
55
+ --force) FORCE=true; shift ;;
56
+ --check) CHECK_ONLY=true; shift ;;
57
+ -h|--help) sed -n '2,26p' "$0"; exit 0 ;;
58
+ *) echo "Unknown argument: $1" >&2; exit 1 ;;
59
+ esac
60
+ done
61
+
62
+ CRT="$CERTS_DIR/localhost.crt"
63
+ KEY="$CERTS_DIR/localhost.key"
64
+
65
+ # --- SAN list --------------------------------------------------------------
66
+ # localhost always; then each requested host plus a wildcard for its parent
67
+ # domain, so a later service on the same domain is already covered.
68
+ SANS=("DNS:localhost" "DNS:*.localhost" "IP:127.0.0.1")
69
+ seen_wildcards=""
70
+ for domain in ${DOMAINS+"${DOMAINS[@]}"}; do
71
+ [ -n "$domain" ] || continue
72
+ SANS+=("DNS:$domain")
73
+ parent="${domain#*.}"
74
+ # Only wildcard a real parent domain (a.b.c -> *.b.c), never a bare TLD.
75
+ if [ "$parent" != "$domain" ] && [ "${parent#*.}" != "$parent" ]; then
76
+ case " $seen_wildcards " in
77
+ *" $parent "*) ;;
78
+ *) SANS+=("DNS:*.$parent"); SANS+=("DNS:$parent")
79
+ seen_wildcards="$seen_wildcards $parent" ;;
80
+ esac
81
+ fi
82
+ done
83
+
84
+ SAN_STRING="$(IFS=,; echo "${SANS[*]}")"
85
+
86
+ # Lines in sorted set $1 that are not in sorted set $2, space-separated.
87
+ only_in() { comm -23 <(printf '%s\n' "$1") <(printf '%s\n' "$2") | sed '/^$/d' | tr '\n' ' '; }
88
+
89
+ # --- check mode ------------------------------------------------------------
90
+ # Stale means the explicit SANs differ from the registry either way: a missing
91
+ # host appears when a service is added, an extra one when a service is removed.
92
+ missing_sans=()
93
+ extra_sans=""
94
+ if [ -f "$CRT" ]; then
95
+ cert_text="$(openssl x509 -in "$CRT" -noout -ext subjectAltName 2>/dev/null || true)"
96
+ for domain in ${DOMAINS+"${DOMAINS[@]}"}; do
97
+ [ -n "$domain" ] || continue
98
+ parent="${domain#*.}"
99
+ if ! grep -qF "DNS:$domain" <<< "$cert_text" \
100
+ && ! grep -qF "DNS:*.$parent" <<< "$cert_text"; then
101
+ missing_sans+=("$domain")
102
+ fi
103
+ done
104
+ extra_sans="$(only_in \
105
+ "$(grep -oE 'DNS:[^,[:space:]]+' <<< "$cert_text" | sort -u || true)" \
106
+ "$(printf '%s\n' "${SANS[@]}" | grep '^DNS:' | sort -u)")"
107
+ extra_sans="${extra_sans//DNS:/}"
108
+ fi
109
+
110
+ # Hosts entries this script owns: everything inside the managed block, plus bare
111
+ # `127.0.0.1` lines whose every name sits under an ingress domain — the lines
112
+ # older versions appended before the block existed. Anything else is the user's.
113
+ # `hosts_scan names` prints the owned names; `hosts_scan rest` prints the file
114
+ # without them.
115
+ hosts_scan() {
116
+ [ -f "$HOSTS_FILE" ] || return 0
117
+ awk -v mode="$1" -v parents="$seen_wildcards" -v b="$HOSTS_BEGIN" -v e="$HOSTS_END" '
118
+ BEGIN { n = split(parents, p, " ") }
119
+ function ingress(h, i, s) {
120
+ for (i = 1; i <= n; i++) {
121
+ s = "." p[i]
122
+ if (length(h) > length(s) && substr(h, length(h) - length(s) + 1) == s) return 1
123
+ }
124
+ return 0
125
+ }
126
+ function emit( i) { if (mode == "names") for (i = 2; i <= NF; i++) print $i }
127
+ $0 == b { inblock = 1; next }
128
+ $0 == e { inblock = 0; next }
129
+ inblock { emit(); next }
130
+ $1 == "127.0.0.1" && NF > 1 {
131
+ owned = 1
132
+ for (i = 2; i <= NF; i++) if (!ingress($i)) owned = 0
133
+ if (owned) { emit(); next }
134
+ }
135
+ mode == "rest" { print }
136
+ ' "$HOSTS_FILE"
137
+ }
138
+
139
+ wanted_hosts="$(printf '%s\n' ${DOMAINS+"${DOMAINS[@]}"} | sed '/^$/d' | sort -u)"
140
+ if [ "$DO_HOSTS" = true ]; then
141
+ owned_hosts="$(hosts_scan names | sort -u)"
142
+ extra_hosts="$(only_in "$owned_hosts" "$wanted_hosts")"
143
+ missing_hosts="$(only_in "$wanted_hosts" "$owned_hosts")"
144
+ fi
145
+
146
+ if [ "$CHECK_ONLY" = true ]; then
147
+ drift=false
148
+ if [ ! -f "$CRT" ] || [ ! -f "$KEY" ]; then
149
+ echo "missing: no certificate at $CRT"
150
+ drift=true
151
+ else
152
+ if [ ${#missing_sans[@]} -gt 0 ]; then
153
+ echo "stale: certificate does not cover ${missing_sans[*]}"
154
+ drift=true
155
+ fi
156
+ if [ -n "$extra_sans" ]; then
157
+ echo "stale: certificate has extra SANs ${extra_sans% }"
158
+ drift=true
159
+ fi
160
+ fi
161
+ if [ -n "${missing_hosts:-}" ]; then
162
+ echo "stale: $HOSTS_FILE is missing ${missing_hosts% }"
163
+ drift=true
164
+ fi
165
+ if [ -n "${extra_hosts:-}" ]; then
166
+ echo "stale: $HOSTS_FILE has extra entries ${extra_hosts% }"
167
+ drift=true
168
+ fi
169
+ [ "$drift" = true ] && exit 1
170
+ echo "ok: $CRT covers ${#DOMAINS[@]} ingress hostname(s)"
171
+ exit 0
172
+ fi
173
+
174
+ # --- generate --------------------------------------------------------------
175
+ mkdir -p "$CERTS_DIR"
176
+
177
+ if [ -f "$CRT" ] && [ -f "$KEY" ] && [ "$FORCE" != true ]; then
178
+ if [ ${#missing_sans[@]} -gt 0 ]; then
179
+ echo "Certificate at $CRT does not cover: ${missing_sans[*]}"
180
+ echo "Regenerating to add the missing hostnames..."
181
+ elif [ -n "$extra_sans" ]; then
182
+ echo "Certificate at $CRT has SANs no longer routed: ${extra_sans% }"
183
+ echo "Regenerating to drop them..."
184
+ else
185
+ echo "Certificates already exist in $CERTS_DIR and cover every ingress hostname."
186
+ echo "To regenerate anyway: $0 --force"
187
+ # Still reconcile /etc/hosts below — a new or removed service changes
188
+ # its hosts entry even when the wildcard SAN already covered it.
189
+ FORCE=skip_generate
190
+ fi
191
+ fi
192
+
193
+ if [ "$FORCE" != "skip_generate" ]; then
194
+ echo "Generating self-signed SSL certificate..."
195
+ echo " SANs: $SAN_STRING"
196
+
197
+ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
198
+ -keyout "$KEY" \
199
+ -out "$CRT" \
200
+ -subj "/C=US/ST=State/L=City/O=Dev/CN=localhost" \
201
+ -addext "subjectAltName=$SAN_STRING"
202
+
203
+ chmod 600 "$KEY"
204
+
205
+ echo ""
206
+ echo "SSL certificate generated:"
207
+ echo " Certificate: $CRT"
208
+ echo " Private Key: $KEY"
209
+ echo ""
210
+ echo "Note: Docker Compose mounts these into Caddy via \$SANDBOX_CERTS_DIR."
211
+
212
+ # Trust the certificate on macOS.
213
+ if [ "$DO_TRUST" = true ] && [[ "$OSTYPE" == "darwin"* ]]; then
214
+ echo ""
215
+ echo "Trusting the certificate in the System keychain (requires sudo)..."
216
+ if sudo security add-trusted-cert -d -r trustRoot \
217
+ -k /Library/Keychains/System.keychain "$CRT"; then
218
+ echo "Certificate trusted. Restart your browser for it to take effect."
219
+ else
220
+ echo "Failed to trust the certificate automatically. To do it manually:"
221
+ echo " sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CRT"
222
+ fi
223
+ elif [ "$DO_TRUST" = true ]; then
224
+ echo ""
225
+ echo "Not macOS — trust $CRT with your platform's certificate store to"
226
+ echo "avoid browser warnings."
227
+ fi
228
+ fi
229
+
230
+ # --- /etc/hosts ------------------------------------------------------------
231
+ # Rebuild the managed block from the registry so removed services drop out;
232
+ # lines outside it are left exactly as they were.
233
+ if [ "$DO_HOSTS" = true ] && [ ${#DOMAINS[@]} -gt 0 ]; then
234
+ echo ""
235
+ echo "Pointing ingress hostnames at 127.0.0.1 in $HOSTS_FILE..."
236
+ rendered="$(
237
+ hosts_scan rest
238
+ echo "$HOSTS_BEGIN"
239
+ while read -r domain; do echo "127.0.0.1 $domain"; done <<< "$wanted_hosts"
240
+ echo "$HOSTS_END"
241
+ )"
242
+ if [ -f "$HOSTS_FILE" ] && [ "$rendered" = "$(cat "$HOSTS_FILE")" ]; then
243
+ echo "No changes needed to $HOSTS_FILE."
244
+ else
245
+ for domain in $missing_hosts; do echo " + $domain"; done
246
+ for domain in $extra_hosts; do echo " - $domain"; done
247
+ if [ -w "$HOSTS_FILE" ]; then
248
+ printf '%s\n' "$rendered" > "$HOSTS_FILE"
249
+ else
250
+ printf '%s\n' "$rendered" | sudo tee "$HOSTS_FILE" > /dev/null
251
+ fi
252
+ echo "$HOSTS_FILE updated."
253
+ fi
254
+ fi
@@ -0,0 +1,278 @@
1
+ import { createRequire as __sandboxCreateRequire } from "node:module";
2
+ const require = __sandboxCreateRequire(import.meta.url);
3
+
4
+ // ../../packages/sandbox-core/dist/gate.js
5
+ import * as fs2 from "node:fs";
6
+ import * as path2 from "node:path";
7
+
8
+ // ../../packages/sandbox-core/dist/dot_dir.js
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ var DOT_DIRS = [".werkmeister", ".sandbox"];
12
+ function dotDir(parent) {
13
+ const found = DOT_DIRS.find((d) => fs.existsSync(path.join(parent, d)));
14
+ return path.join(parent, found ?? DOT_DIRS[0]);
15
+ }
16
+
17
+ // ../../packages/sandbox-core/dist/gate.js
18
+ var _PRECEDENCE_TEXT = "Precedence: these standards' rules win over conflicting task framing \u2014 if a rule and the task clash, follow the rule and say so (invariant: apply it and write a `decision` note; rule: surface the conflict in one line, then apply). This gate fires once; your retry will proceed.";
19
+ var _PHASE_ENTRY_TEXT = "Standards gate: invoke `sb-implement` before coding \u2014 it loads the universal base (ponytail + comments + engineering) and the workspace notes. Then retry this edit.";
20
+ function _translate(pat) {
21
+ let res = "";
22
+ let i = 0;
23
+ const n = pat.length;
24
+ while (i < n) {
25
+ const c = pat[i];
26
+ i += 1;
27
+ if (c === "*") {
28
+ res += ".*";
29
+ } else if (c === "?") {
30
+ res += ".";
31
+ } else if (c === "[") {
32
+ let j = i;
33
+ if (j < n && pat[j] === "!")
34
+ j += 1;
35
+ if (j < n && pat[j] === "]")
36
+ j += 1;
37
+ while (j < n && pat[j] !== "]")
38
+ j += 1;
39
+ if (j >= n) {
40
+ res += "\\[";
41
+ } else {
42
+ let stuff = pat.slice(i, j).replace(/\\/g, "\\\\");
43
+ i = j + 1;
44
+ if (stuff.startsWith("!"))
45
+ stuff = "^" + stuff.slice(1);
46
+ else if (stuff.startsWith("^"))
47
+ stuff = "\\" + stuff;
48
+ res += `[${stuff}]`;
49
+ }
50
+ } else {
51
+ res += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+ }
54
+ return `^(?:${res})$`;
55
+ }
56
+ function fnmatch(name, pat) {
57
+ return new RegExp(_translate(pat), "s").test(name);
58
+ }
59
+ function findWorkspaceRoot(start) {
60
+ let directory = path2.resolve(start);
61
+ while (true) {
62
+ if (_isDir(dotDir(directory))) {
63
+ return directory;
64
+ }
65
+ const parent = path2.dirname(directory);
66
+ if (parent === directory) {
67
+ return null;
68
+ }
69
+ directory = parent;
70
+ }
71
+ }
72
+ function _isDir(p) {
73
+ try {
74
+ return fs2.statSync(p).isDirectory();
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ function _isFile(p) {
80
+ try {
81
+ return fs2.statSync(p).isFile();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ function _isDict(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+ function loadManifest(workspace) {
90
+ const manifestPath = path2.join(dotDir(workspace), "standards.json");
91
+ if (!_isFile(manifestPath)) {
92
+ return null;
93
+ }
94
+ let data;
95
+ try {
96
+ data = JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
97
+ } catch {
98
+ return null;
99
+ }
100
+ return _isDict(data) ? data : null;
101
+ }
102
+ function _statePath(workspace, sessionId) {
103
+ return path2.join(dotDir(workspace), ".gate", `${sessionId}.json`);
104
+ }
105
+ function _loadState(workspace, sessionId) {
106
+ const statePath = _statePath(workspace, sessionId);
107
+ if (!_isFile(statePath)) {
108
+ return {};
109
+ }
110
+ let data;
111
+ try {
112
+ data = JSON.parse(fs2.readFileSync(statePath, "utf8"));
113
+ } catch {
114
+ return null;
115
+ }
116
+ return _isDict(data) ? data : null;
117
+ }
118
+ function _saveState(workspace, sessionId, state) {
119
+ try {
120
+ const statePath = _statePath(workspace, sessionId);
121
+ fs2.mkdirSync(path2.dirname(statePath), { recursive: true });
122
+ fs2.writeFileSync(statePath, JSON.stringify(state));
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ function _sorted(values) {
129
+ return [...values].sort();
130
+ }
131
+ function _noteStatus(text) {
132
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
133
+ if (!block) {
134
+ return void 0;
135
+ }
136
+ const status = /^status:\s*["']?([^\s"'#]+)/m.exec(block[1]);
137
+ return status ? status[1].toLowerCase() : null;
138
+ }
139
+ function _phaseGateSatisfied(workspace, gate) {
140
+ const note = String(gate["note"] ?? "");
141
+ if (!/^[a-z]+$/.test(note)) {
142
+ return true;
143
+ }
144
+ const want = gate["status"] ? String(gate["status"]).toLowerCase() : null;
145
+ const dir = path2.join(dotDir(workspace), "notes");
146
+ let names;
147
+ try {
148
+ names = fs2.readdirSync(dir);
149
+ } catch (exc) {
150
+ return exc.code !== "ENOENT";
151
+ }
152
+ const pattern = new RegExp(`^\\d{3}-${note}-.+\\.md$`);
153
+ for (const name of names) {
154
+ if (!pattern.test(name)) {
155
+ continue;
156
+ }
157
+ let text;
158
+ try {
159
+ text = fs2.readFileSync(path2.join(dir, name), "utf8");
160
+ } catch {
161
+ return true;
162
+ }
163
+ const status = _noteStatus(text);
164
+ if (status === void 0) {
165
+ return true;
166
+ }
167
+ if (status === "superseded" || status === "graduated") {
168
+ continue;
169
+ }
170
+ if (want === null || status === want) {
171
+ return true;
172
+ }
173
+ }
174
+ return false;
175
+ }
176
+ function _phaseGateText(gate) {
177
+ const note = String(gate["note"]);
178
+ if (gate["status"]) {
179
+ const status = String(gate["status"]);
180
+ return `Phase gate: no \`${note}\` note in \`.sandbox/notes/\` has \`status: ${status}\`, so source edits wait for an approved design. Invoke \`sb-research\`, present the design to the user, and when they approve write \`NNN-${note}-<slug>.md\` with \`status: ${status}\` recording what was approved. Then retry this edit. Edits under \`.sandbox/\` are not gated.`;
181
+ }
182
+ return `Phase gate: no \`${note}\` note in \`.sandbox/notes/\` names the root cause yet. Invoke \`sb-research\`, reproduce the problem, and write \`NNN-${note}-<slug>.md\` stating the root cause. Then retry this edit. Edits under \`.sandbox/\` are not gated.`;
183
+ }
184
+ function decide(workspaceStart, sessionId, filePath) {
185
+ if (!sessionId || sessionId.includes("/") || sessionId.includes("..")) {
186
+ return null;
187
+ }
188
+ const workspace = findWorkspaceRoot(workspaceStart);
189
+ if (workspace === null) {
190
+ return null;
191
+ }
192
+ const manifest = loadManifest(workspace);
193
+ if (manifest === null) {
194
+ return null;
195
+ }
196
+ const state = _loadState(workspace, sessionId);
197
+ if (state === null) {
198
+ return null;
199
+ }
200
+ const relPath = filePath ? path2.relative(workspace, filePath) : "";
201
+ const phaseGate = manifest["phase_gate"];
202
+ if (_isDict(phaseGate) && relPath && relPath !== ".." && !relPath.startsWith(".." + path2.sep) && !path2.isAbsolute(relPath) && !DOT_DIRS.some((d) => relPath.startsWith(d + path2.sep)) && !_phaseGateSatisfied(workspace, phaseGate)) {
203
+ return _phaseGateText(phaseGate);
204
+ }
205
+ const delivered = new Set(Array.isArray(state["delivered"]) ? state["delivered"] : []);
206
+ const needsPhaseEntry = !state["phase_entry_delivered"];
207
+ let domain = null;
208
+ const domains = _isDict(manifest["domains"]) ? manifest["domains"] : {};
209
+ for (const name of _sorted(Object.keys(domains))) {
210
+ const meta = domains[name];
211
+ if (delivered.has(name) || !_isDict(meta)) {
212
+ continue;
213
+ }
214
+ const globs = Array.isArray(meta["applies_to"]) ? meta["applies_to"] : [];
215
+ if (globs.some((glob) => fnmatch(relPath, String(glob)))) {
216
+ domain = name;
217
+ break;
218
+ }
219
+ }
220
+ if (!needsPhaseEntry && domain === null) {
221
+ return null;
222
+ }
223
+ const parts = [];
224
+ if (needsPhaseEntry) {
225
+ parts.push(_PHASE_ENTRY_TEXT);
226
+ }
227
+ if (domain !== null) {
228
+ const meta = domains[domain];
229
+ const summary = String(_isDict(meta) && meta["summary"] || "").trim();
230
+ let domainText = `Standards gate: you are editing a path governed by the \`${domain}\` standard, which has not been loaded this session. Invoke the \`sb-${domain}-standard\` skill, then retry this edit.`;
231
+ if (summary) {
232
+ domainText += ` Summary: ${summary}.`;
233
+ }
234
+ parts.push(domainText);
235
+ }
236
+ parts.push(_PRECEDENCE_TEXT);
237
+ const newState = {
238
+ phase_entry_delivered: true,
239
+ delivered: _sorted(domain ? /* @__PURE__ */ new Set([...delivered, domain]) : delivered)
240
+ };
241
+ if (!_saveState(workspace, sessionId, newState)) {
242
+ return null;
243
+ }
244
+ return parts.join("\n");
245
+ }
246
+ function main() {
247
+ let data;
248
+ try {
249
+ const raw = process.stdin.isTTY ? "" : fs2.readFileSync(0, "utf8");
250
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
251
+ data = _isDict(parsed) ? parsed : {};
252
+ } catch {
253
+ data = {};
254
+ }
255
+ const sessionId = String(data["session_id"] || "");
256
+ const toolInput = data["tool_input"];
257
+ const filePath = String(_isDict(toolInput) && toolInput["file_path"] || "");
258
+ const start = String(data["cwd"] || process.cwd());
259
+ let reason;
260
+ try {
261
+ reason = decide(start, sessionId, filePath);
262
+ } catch {
263
+ reason = null;
264
+ }
265
+ if (reason) {
266
+ console.log(JSON.stringify({
267
+ hookSpecificOutput: {
268
+ hookEventName: "PreToolUse",
269
+ permissionDecision: "deny",
270
+ permissionDecisionReason: reason
271
+ }
272
+ }));
273
+ }
274
+ }
275
+
276
+ // src/lib/gate.ts
277
+ main();
278
+ //# sourceMappingURL=gate.js.map