devlyn-cli 2.10.0 → 2.10.1

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/AGENTS.md CHANGED
@@ -36,7 +36,7 @@ ideate (optional) -> resolve -> ship
36
36
 
37
37
  Each skill's `SKILL.md` is the source of truth for flags and workflow. Do not duplicate.
38
38
 
39
- Engine roles: orchestrator = whichever CLI the user opened (this contract is symmetric with CLAUDE.md; measured status 2026-07-05: Claude Code + omp run the full phase-gated pipeline; in the iter-0061 minimal-repo trivial-add shape on this machine, Codex CLI also ran it when this AGENTS.md was present in the project (ordinary invocation, 4/4); without project AGENTS.md, the same shape silently skipped the pipeline (iter-0040 F6, 4/4). If you are Codex orchestrating `/devlyn:resolve`, the phase machinery is mandatory regardless of task size; if you will not run it, say so explicitly and stop — do not silently degrade to ad-hoc execution); executor (PLAN/IMPLEMENT/CLEANUP) defaults to `claude`, overridable per run with `--engine <name>` or durably via machine-local `.devlyn/engines.json` `{"executor": "<name>", "pair_judge_priority": ["<name>", ...]}`; pair judge = first available OTHER engine, default for VERIFY whenever available; risk probes remain conditional and --no-pair opts out. Pins fail closed (`BLOCKED:<engine>-unavailable` / `BLOCKED:invalid-engine-config`); new engines plug in by shipping `_shared/adapters/<name>.md`. `/devlyn:engines` (no args) shows the role table + detected engines; `executor <name>` / `pair <name>,...` / `clear` manage the pins. The executor pin binds the orchestrator in plain conversation too, not only inside a skill run: when you would implement directly and executor is pinned to a non-default engine, route that work through the pin — via `/devlyn:resolve` (reads it at PHASE 0) or by delegating to that engine — instead of editing as `claude`; no pin / no file → unchanged (`claude`). The pair-judge pin stays pipeline-scoped.
39
+ Engine roles: orchestrator = whichever CLI the user opened (this contract is symmetric with CLAUDE.md; measured status 2026-07-05: Claude Code + omp run the full phase-gated pipeline; in the iter-0061 minimal-repo trivial-add shape on this machine, Codex CLI also ran it when this AGENTS.md was present in the project (ordinary invocation, 4/4); without project AGENTS.md, the same shape silently skipped the pipeline (iter-0040 F6, 4/4). If you are Codex orchestrating `/devlyn:resolve`, the phase machinery is mandatory regardless of task size; if you will not run it, say so explicitly and stop — do not silently degrade to ad-hoc execution); executor (IMPLEMENT/CLEANUP + primary VERIFY judge) defaults to `claude`, overridable per run with `--engine <name>` or durably via machine-local `.devlyn/engines.json` `{"executor": "<name>", "pair_judge_priority": ["<name>", ...]}`; PLAN is orchestrator-fixed and never inherits `--engine` or the executor pin; pair judge = first available OTHER engine, default for VERIFY whenever available; risk probes remain conditional and --no-pair opts out. Pins fail closed (`BLOCKED:<engine>-unavailable` / `BLOCKED:invalid-engine-config`); new engines plug in by shipping `_shared/adapters/<name>.md`. `/devlyn:engines` (no args) shows the role table + detected engines; `executor <name>` / `pair <name>,...` / `clear` manage the pins. The executor pin binds the orchestrator in plain conversation too, not only inside a skill run: when you would implement directly and executor is pinned to a non-default engine, route that work through the pin — via `/devlyn:resolve` (reads it at PHASE 0) or by delegating to that engine — instead of editing as `claude`; no pin / no file → unchanged (`claude`). The pair-judge pin stays pipeline-scoped.
40
40
 
41
41
  Conversational handoff + loop engineering (default entry): the orchestrating model — not the user — invokes the skills. `/devlyn:queue` fronts the intent queue (`docs/specs/queue.md`): no args = status, `add <intent>`, `drain` = serial unattended drain per the contract below. Large agreed work is written to `docs/specs/<id>/spec.md` (always a spec file — the user's reviewed contract; free-form large assumes-and-logs instead of halting, zero-scope-signal goals still halt to ideate), summarized once for user review, then run hands-free via `--spec`. Outer loop per task: findings-backed verdicts (NEEDS_WORK, verify/build-gate exhaustion) get spec amendment + re-run, max 3 iterations; infrastructure / invalid-input / engine-availability / implement-empty BLOCKED verdicts surface immediately. Unattended queue drain (`docs/specs/queue.md`, strictly serial): assumptions may only take scope-narrowing, reversible, non-user-visible defaults — material ambiguity marks the item `[F] needs-review` and the drain continues.
42
42
 
package/CLAUDE.md CHANGED
@@ -40,7 +40,7 @@ Each skill's `SKILL.md` is the source of truth for its flags and workflow — do
40
40
  | Role | Default | Manual override |
41
41
  |---|---|---|
42
42
  | Orchestrator — conversation, handoff, loop driving | whichever CLI you open (contract is symmetric: CLAUDE.md ↔ AGENTS.md). Measured status 2026-07-05: Claude Code + omp run the full phase-gated pipeline; in the iter-0061 minimal-repo trivial-add shape on this machine, Codex CLI also ran it when the project carried the devlyn AGENTS.md (ordinary invocation, 4/4); without project AGENTS.md, the same shape silently skipped the pipeline (iter-0040 F6, 4/4) | switch CLIs; the file artifacts (spec/queue/state) carry over |
43
- | Executor — PLAN/IMPLEMENT/CLEANUP + primary VERIFY judge | `claude` | `--engine <name>` per run, or `/devlyn:engines executor <name>` (durable pin) |
43
+ | Executor — IMPLEMENT/CLEANUP + primary VERIFY judge; PLAN is orchestrator-fixed and never inherits `--engine` or an executor pin | `claude` | `--engine <name>` per run, or `/devlyn:engines executor <name>` (durable pin) |
44
44
  | Pair judge — default for VERIFY; conditional for risk probes | first available OTHER engine (claude↔codex) | `/devlyn:engines pair <name>,...`; `--no-pair` opts out |
45
45
 
46
46
  `/devlyn:engines` with no args shows the current role table, detected engines, and how to pin or clear — the pins live in `.devlyn/engines.json`.
package/README.md CHANGED
@@ -106,10 +106,10 @@ Stack tasks to run back-to-back without supervision. `/devlyn:queue add "<intent
106
106
  devlyn separates three engine roles:
107
107
 
108
108
  - **Orchestrator** — the CLI you opened (Claude Code, Codex, or omp) that drives the conversation and loop. The contract is symmetric (`CLAUDE.md` ↔ `AGENTS.md`), so the same phase-gated pipeline runs whichever you launch; the file artifacts (spec, queue, state) carry over if you switch.
109
- - **Executor** — PLAN / IMPLEMENT / CLEANUP plus the primary VERIFY judge. Defaults to `claude`.
109
+ - **Executor** — IMPLEMENT / CLEANUP plus the primary VERIFY judge. Defaults to `claude`. PLAN is orchestrator-fixed and never inherits `--engine` or an executor pin.
110
110
  - **Pair judge** — the first available *other* engine, default for VERIFY and conditional for risk probes.
111
111
 
112
- `--engine claude` (default) is the canonical implementation surface for PLAN, IMPLEMENT, BUILD_GATE, and CLEANUP. VERIFY/JUDGE runs pair mode by default when the OTHER engine is available.
112
+ `--engine claude` (default) is the canonical implementation surface for IMPLEMENT, BUILD_GATE, and CLEANUP; PLAN always runs on the orchestrator's native worker. VERIFY/JUDGE runs pair mode by default when the OTHER engine is available.
113
113
 
114
114
  Pin roles durably with `/devlyn:engines` (no args shows the role table + detected engines; `executor <name>` / `pair <name>,...` / `clear` manage the pins, stored machine-local in `.devlyn/engines.json`). Pins fail closed: an unavailable pinned engine stops with `BLOCKED:<engine>-unavailable`, and a name with no `_shared/adapters/<name>.md` adapter stops with `BLOCKED:invalid-engine-config`. New engines plug in by shipping an adapter file — no skill changes.
115
115
 
@@ -744,8 +744,18 @@ verify = {"commands": [], "forbidden_pattern_hits": [], "deps_added": 0,
744
744
 
745
745
  for vc in expected.get("verification_commands", []):
746
746
  try:
747
+ timeout_sec = vc.get("timeout_sec", 60)
748
+ if (
749
+ isinstance(timeout_sec, bool)
750
+ or not isinstance(timeout_sec, int)
751
+ or not 1 <= timeout_sec <= 600
752
+ ):
753
+ raise ValueError(
754
+ "timeout_sec must be an integer from 1 to 600 (bool is not allowed)"
755
+ )
747
756
  proc = subprocess.run(vc["cmd"], cwd=work, shell=True, env=verify_env,
748
- capture_output=True, text=True, timeout=60)
757
+ capture_output=True, text=True,
758
+ timeout=timeout_sec)
749
759
  out = (proc.stdout or "") + (proc.stderr or "")
750
760
  ok_exit = proc.returncode == vc.get("exit_code", 0)
751
761
  ok_contains = all(s in out for s in vc.get("stdout_contains", []))
@@ -5,7 +5,7 @@ set -euo pipefail
5
5
  ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
6
6
  TMP="$(mktemp -d)"
7
7
  BENCH_ROOT="$ROOT/benchmark/auto-resolve"
8
- trap 'rm -rf "$TMP"; rm -rf "$BENCH_ROOT/results/arg-parse-command-test" "$BENCH_ROOT/results/arg-parse-discovery-test" "$BENCH_ROOT/results/arg-parse-shadow-suite-dry-run" "$BENCH_ROOT/results/arg-parse-shadow-cli-suite-dry-run" "$BENCH_ROOT/results/arg-parse-variant-path" "$BENCH_ROOT/results/arg-parse-headroom-cli-replay" "$BENCH_ROOT/results/arg-parse-pair-cli-replay" "$BENCH_ROOT/results/arg-parse-shadow-judge" "$BENCH_ROOT/results/arg-parse-opus-bad-mapping" "$BENCH_ROOT/results/arg-parse-opus-malformed-mapping" "$BENCH_ROOT/results/arg-parse-opus-malformed-score" "$BENCH_ROOT/results/arg-parse-opus-invalid-generated-score" "$BENCH_ROOT/results/arg-parse-opus-invalid-generated-dq" "$BENCH_ROOT/results/arg-parse-opus-summary-mapping" "$BENCH_ROOT/results/arg-parse-opus-summary-null-margin"; rm -rf /tmp/bench-arg-parse-variant-path-* /tmp/bench-arg-parse-headroom-cli-replay-*' EXIT
8
+ trap 'rm -rf "$TMP"; rm -rf "$BENCH_ROOT/results/arg-parse-command-test" "$BENCH_ROOT/results/arg-parse-discovery-test" "$BENCH_ROOT/results/arg-parse-shadow-suite-dry-run" "$BENCH_ROOT/results/arg-parse-shadow-cli-suite-dry-run" "$BENCH_ROOT/results/arg-parse-variant-path" "$BENCH_ROOT/results/arg-parse-timeout-budget" "$BENCH_ROOT/results/arg-parse-headroom-cli-replay" "$BENCH_ROOT/results/arg-parse-pair-cli-replay" "$BENCH_ROOT/results/arg-parse-shadow-judge" "$BENCH_ROOT/results/arg-parse-opus-bad-mapping" "$BENCH_ROOT/results/arg-parse-opus-malformed-mapping" "$BENCH_ROOT/results/arg-parse-opus-malformed-score" "$BENCH_ROOT/results/arg-parse-opus-invalid-generated-score" "$BENCH_ROOT/results/arg-parse-opus-invalid-generated-dq" "$BENCH_ROOT/results/arg-parse-opus-summary-mapping" "$BENCH_ROOT/results/arg-parse-opus-summary-null-margin" "$BENCH_ROOT/shadow-fixtures/arg-parse-timeout-budget"; rm -rf /tmp/bench-arg-parse-variant-path-* /tmp/bench-arg-parse-timeout-budget-* /tmp/bench-arg-parse-headroom-cli-replay-*' EXIT
9
9
 
10
10
  expect_fail_contains() {
11
11
  local name="$1"
@@ -849,6 +849,88 @@ if grep -Fq -- '--engine auto' \
849
849
  exit 1
850
850
  fi
851
851
  mkdir -p "$BENCH_ROOT/shadow-fixtures"
852
+ rm -rf "$BENCH_ROOT/shadow-fixtures/arg-parse-timeout-budget" \
853
+ "$BENCH_ROOT/results/arg-parse-timeout-budget"
854
+ cp -R "$BENCH_ROOT/fixtures/F1-cli-trivial-flag" \
855
+ "$BENCH_ROOT/shadow-fixtures/arg-parse-timeout-budget"
856
+ python3 - "$BENCH_ROOT/shadow-fixtures/arg-parse-timeout-budget/expected.json" <<'PY'
857
+ import json
858
+ import pathlib
859
+ import sys
860
+
861
+ path = pathlib.Path(sys.argv[1])
862
+ expected = json.loads(path.read_text())
863
+ expected["verification_commands"] = [
864
+ {"cmd": "true"},
865
+ {"cmd": "true", "timeout_sec": 1},
866
+ {"cmd": "true", "timeout_sec": 600},
867
+ {"cmd": "true", "timeout_sec": True},
868
+ {"cmd": "true", "timeout_sec": 601},
869
+ {
870
+ "cmd": "python3 -c \"import time; time.sleep(2)\"",
871
+ "timeout_sec": 1,
872
+ },
873
+ {
874
+ "cmd": "python3 -c \"import time; time.sleep(1)\"",
875
+ "timeout_sec": 5,
876
+ },
877
+ ]
878
+ path.write_text(json.dumps(expected, indent=2) + "\n")
879
+ PY
880
+ bash "$ROOT/benchmark/auto-resolve/scripts/run-fixture.sh" \
881
+ --fixture arg-parse-timeout-budget \
882
+ --arm bare \
883
+ --run-id arg-parse-timeout-budget \
884
+ --dry-run > "$TMP/timeout-budget-dry-run.out" 2>&1
885
+ python3 - "$BENCH_ROOT/results/arg-parse-timeout-budget/arg-parse-timeout-budget/bare/verify.json" <<'PY'
886
+ import json
887
+ import pathlib
888
+ import sys
889
+
890
+ commands = json.loads(pathlib.Path(sys.argv[1]).read_text())["commands"]
891
+ assert commands[0]["pass"] is True, commands
892
+ assert commands[1]["pass"] is True, commands
893
+ assert commands[2]["pass"] is True, commands
894
+ expected_error = (
895
+ "error:ValueError:timeout_sec must be an integer from 1 to 600 "
896
+ "(bool is not allowed)"
897
+ )
898
+ assert commands[3]["pass"] is False and commands[3]["reason"] == expected_error, commands
899
+ assert commands[4]["pass"] is False and commands[4]["reason"] == expected_error, commands
900
+ assert commands[5]["reason"] == "timeout", commands
901
+ assert commands[6]["pass"] is True, commands
902
+ PY
903
+
904
+ TIMEOUT_LINT_ROOT="$TMP/timeout-lint-fixtures"
905
+ mkdir -p "$TIMEOUT_LINT_ROOT"
906
+ cp -R "$BENCH_ROOT/fixtures/F1-cli-trivial-flag" \
907
+ "$TIMEOUT_LINT_ROOT/F1-cli-trivial-flag"
908
+ python3 - "$TIMEOUT_LINT_ROOT/F1-cli-trivial-flag/expected.json" <<'PY'
909
+ import json
910
+ import pathlib
911
+ import sys
912
+
913
+ path = pathlib.Path(sys.argv[1])
914
+ expected = json.loads(path.read_text())
915
+ expected["verification_commands"][0]["timeout_sec"] = 600
916
+ path.write_text(json.dumps(expected, indent=2) + "\n")
917
+ PY
918
+ DEVLYN_FIXTURES_DIR="$TIMEOUT_LINT_ROOT" DEVLYN_LINT_FIXTURES_NO_JSONSCHEMA=1 \
919
+ bash "$ROOT/scripts/lint-fixtures.sh" > "$TMP/timeout-lint-pass.out" 2>&1
920
+ python3 - "$TIMEOUT_LINT_ROOT/F1-cli-trivial-flag/expected.json" <<'PY'
921
+ import json
922
+ import pathlib
923
+ import sys
924
+
925
+ path = pathlib.Path(sys.argv[1])
926
+ expected = json.loads(path.read_text())
927
+ expected["verification_commands"][0]["timeout_sec"] = True
928
+ path.write_text(json.dumps(expected, indent=2) + "\n")
929
+ PY
930
+ expect_fail_contains timeout-lint-bool \
931
+ "timeout_sec must be an integer from 1 to 600" \
932
+ env DEVLYN_FIXTURES_DIR="$TIMEOUT_LINT_ROOT" DEVLYN_LINT_FIXTURES_NO_JSONSCHEMA=1 \
933
+ bash "$ROOT/scripts/lint-fixtures.sh"
852
934
  rm -rf "$BENCH_ROOT/shadow-fixtures/arg-parse-nan-metadata" \
853
935
  "$BENCH_ROOT/shadow-fixtures/arg-parse-nan-expected" \
854
936
  "$BENCH_ROOT/results/arg-parse-nan-metadata" \
package/bin/devlyn.js CHANGED
@@ -16,6 +16,7 @@ const PKG = require('../package.json');
16
16
  // ui-*) stay Claude-core-only, matching the pre-existing Codex bundle.
17
17
  const DEVLYN_CORE_SKILLS = ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui', 'devlyn:engines', 'devlyn:queue', '_shared'];
18
18
  const DEVLYN_SKILL_DIR_STAMP = '__DEVLYN_SKILL_DIR__';
19
+ const DEVLYN_INSTALL_MARKER = '.devlyn-install.json';
19
20
 
20
21
  // Cross-agent shared skills directory read by BOTH oh-my-pi and Pi. Verified
21
22
  // from the omp binary's skill-provider strings ("skills from .agents/skills —
@@ -354,6 +355,39 @@ function copyRecursive(src, dest, baseDir) {
354
355
  }
355
356
  }
356
357
 
358
+ function clearInstallMarker(skillsDir) {
359
+ fs.rmSync(path.join(skillsDir, DEVLYN_INSTALL_MARKER), { force: true });
360
+ }
361
+
362
+ function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
363
+ const missing = skillNames.filter((skillName) =>
364
+ !fs.existsSync(path.join(sourceSkillsDir, skillName))
365
+ || !fs.existsSync(path.join(skillsDir, skillName)));
366
+ if (missing.length > 0) {
367
+ throw new Error(`Incomplete devlyn skill install; missing: ${missing.join(', ')}`);
368
+ }
369
+ }
370
+
371
+ function writeInstallMarker(skillsDir) {
372
+ const markerPath = path.join(skillsDir, DEVLYN_INSTALL_MARKER);
373
+ const tempPath = `${markerPath}.${process.pid}.tmp`;
374
+ const marker = {
375
+ schemaVersion: 1,
376
+ package: PKG.name,
377
+ version: PKG.version,
378
+ };
379
+ try {
380
+ fs.writeFileSync(tempPath, JSON.stringify(marker, null, 2) + '\n', {
381
+ encoding: 'utf8',
382
+ flag: 'wx',
383
+ mode: 0o600,
384
+ });
385
+ fs.renameSync(tempPath, markerPath);
386
+ } finally {
387
+ fs.rmSync(tempPath, { force: true });
388
+ }
389
+ }
390
+
357
391
  function shellDoubleQuoteDefault(value) {
358
392
  if (value.includes('\n')) {
359
393
  throw new Error(`Cannot stamp skill path with newline: ${value}`);
@@ -594,10 +628,10 @@ function installSkillsForCLI(cliKey) {
594
628
  if (!cli || !cli.skillsDir || !cli.skillsToInstall) return 0;
595
629
 
596
630
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
597
- if (!fs.existsSync(sourceSkillsDir)) return 0;
598
631
  if (!fs.existsSync(cli.skillsDir)) {
599
632
  fs.mkdirSync(cli.skillsDir, { recursive: true });
600
633
  }
634
+ clearInstallMarker(cli.skillsDir);
601
635
 
602
636
  const removed = cleanupDeprecated(path.dirname(cli.skillsDir));
603
637
  if (removed > 0) {
@@ -619,6 +653,8 @@ function installSkillsForCLI(cliKey) {
619
653
  copied++;
620
654
  log(` → ${cli.skillsDir.replace(os.homedir(), '~')}/${skillName}`, 'dim');
621
655
  }
656
+ assertCompleteSkillInstall(sourceSkillsDir, cli.skillsDir, cli.skillsToInstall);
657
+ writeInstallMarker(cli.skillsDir);
622
658
  return copied;
623
659
  }
624
660
 
@@ -726,15 +762,20 @@ function installAgentsForAllDetected() {
726
762
  // is chosen. Behavior is unchanged from the previous unconditional core install.
727
763
  function installClaudeCore() {
728
764
  const targetDir = getTargetDir();
765
+ const skillsDir = path.join(targetDir, 'skills');
729
766
  log('\n📁 Installing Claude Code config to .claude/', 'green');
767
+ if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
768
+ clearInstallMarker(skillsDir);
730
769
  const refreshed = cleanManagedSkillDirs(
731
770
  path.join(CONFIG_SOURCE, 'skills'),
732
- path.join(targetDir, 'skills'),
771
+ skillsDir,
733
772
  );
734
773
  if (refreshed > 0) {
735
774
  log(` 🔄 Refreshing ${refreshed} managed skill director${refreshed === 1 ? 'y' : 'ies'}`, 'dim');
736
775
  }
737
776
  copyRecursive(CONFIG_SOURCE, targetDir, targetDir);
777
+ assertCompleteSkillInstall(path.join(CONFIG_SOURCE, 'skills'), skillsDir, DEVLYN_CORE_SKILLS);
778
+ writeInstallMarker(skillsDir);
738
779
 
739
780
  // Remove deprecated files from previous versions
740
781
  const removed = cleanupDeprecated(targetDir);
@@ -751,17 +792,21 @@ function installClaudeCore() {
751
792
  log(' → CLAUDE.md', 'dim');
752
793
  }
753
794
 
754
- // Add .devlyn/ (pipeline state directory) to .gitignore
795
+ // Keep installer-managed pipeline state and install metadata out of git.
755
796
  const gitignorePath = path.join(process.cwd(), '.gitignore');
756
- const gitignoreEntry = '.devlyn/';
797
+ const gitignoreEntries = ['.devlyn/', '.claude/skills/.devlyn-install.json'];
757
798
  let gitignoreContent = fs.existsSync(gitignorePath)
758
799
  ? fs.readFileSync(gitignorePath, 'utf8')
759
800
  : '';
760
- if (!gitignoreContent.split('\n').some((line) => line.trim() === gitignoreEntry || line.trim() === '.devlyn')) {
801
+ const gitignoreLines = gitignoreContent.split('\n').map((line) => line.trim());
802
+ const missingGitignoreEntries = gitignoreEntries.filter((entry) =>
803
+ !gitignoreLines.includes(entry) && !(entry === '.devlyn/' && gitignoreLines.includes('.devlyn')));
804
+ if (missingGitignoreEntries.length > 0) {
761
805
  const prefix = gitignoreContent && !gitignoreContent.endsWith('\n') ? '\n' : '';
762
- const header = gitignoreContent ? '\n# devlyn-cli pipeline state\n' : '# devlyn-cli pipeline state\n';
763
- fs.writeFileSync(gitignorePath, gitignoreContent + prefix + header + gitignoreEntry + '\n');
764
- log(' → .gitignore (added .devlyn/)', 'dim');
806
+ const hasManagedHeader = gitignoreLines.includes('# devlyn-cli pipeline state');
807
+ const header = hasManagedHeader ? '' : gitignoreContent ? '\n# devlyn-cli pipeline state\n' : '# devlyn-cli pipeline state\n';
808
+ fs.writeFileSync(gitignorePath, gitignoreContent + prefix + header + missingGitignoreEntries.join('\n') + '\n');
809
+ log(` → .gitignore (added ${missingGitignoreEntries.join(', ')})`, 'dim');
765
810
  }
766
811
 
767
812
  // Enable agent teams in project settings
@@ -5,7 +5,7 @@
5
5
  executor: no
6
6
  pair_judge: yes
7
7
 
8
- Certification status: the pair-judge seat is wired, isolated, reachable, and probe-capable, but not emission-certified; P-0079-B measured 4/6, and P-0079-F failed emission because a narration preamble was welded to output line 1; a durable `pair grok` pin remains gated on follow-up emission registration and the standing seat-fitness rule.
8
+ Certification status: the pair-judge seat is wired, reachable, and probe-capable, but not emission-certified; isolation has three independent invariants — tool callability, MCP connection/enumeration, and context injection — and only P-0080-C can certify them; the doctor cannot. Before this change, the shipped recipe connected 65 MCP tools and injected both an MCP reminder and a plugin-skills reminder into the judge's conversation; a durable `pair grok` pin remains gated on follow-up emission registration and the standing seat-fitness rule.
9
9
 
10
10
  ## Invocation
11
11
 
@@ -24,10 +24,15 @@ escalation remains a reported solo skip).
24
24
  #
25
25
  # [skills]
26
26
  # ignore = ["~/.agents", "~/.claude", "~/.cursor"]
27
+ # plus $ISO_HOME/hooks/anchor-guard.json. grok reads that file as JSON, not
28
+ # through a shell, so its "command" must carry an ALREADY-EXPANDED path:
29
+ # {"hooks":{"PreToolUse":[{"hooks":[{"type":"command",
30
+ # "command":"python3 /abs/path/to/_shared/grok-anchor-guard.py","timeout":10}]}]}}
27
31
  # $NEUTRAL = empty dir outside any git repo
28
32
  # $REPO = absolute repo root (redirects MUST be absolute — cwd is $NEUTRAL)
29
33
  cd "$NEUTRAL"
30
- GROK_HOME="$ISO_HOME" \
34
+ HOME="$NEUTRAL" ZDOTDIR="$NEUTRAL" GROK_HOME="$ISO_HOME" \
35
+ DEVLYN_PROBE_ANCHOR="<the bare anchor, derived exactly as the --allow rule below>" \
31
36
  GROK_CLAUDE_MCPS_ENABLED=false GROK_CURSOR_MCPS_ENABLED=false \
32
37
  GROK_CLAUDE_SKILLS_ENABLED=false GROK_CURSOR_SKILLS_ENABLED=false \
33
38
  GROK_CLAUDE_HOOKS_ENABLED=false GROK_CURSOR_HOOKS_ENABLED=false \
@@ -37,13 +42,18 @@ python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- grok -p "<judge prompt>" \
37
42
  --permission-mode dontAsk --no-memory \
38
43
  --tools "read_file,grep,list_dir,run_terminal_cmd" \
39
44
  --disallowed-tools "Agent,use_tool,search_tool" \
40
- --allow 'Bash(<repo probe command family>)' \
45
+ --allow 'Bash(<the bare anchor>)' \
41
46
  --reasoning-effort medium \
47
+ --output-format json \
42
48
  > "$REPO/.devlyn/grok-judge.stdout" 2> "$REPO/.devlyn/grok-judge.stderr"
43
49
  ```
44
50
 
45
51
  Copy `auth.json` into `$ISO_HOME` for every run and seed its `config.toml` with
46
- the shown `[plugins]` and `[skills] ignore` entries.
52
+ the shown `[plugins]` and `[skills] ignore` entries and its
53
+ `hooks/anchor-guard.json` with the shown `PreToolUse` entry. Before launch, create
54
+ `$NEUTRAL/.zshenv` containing `export HOME=<the operator's real home>`.
55
+ Both overrides are needed because grok scans `~/.claude.json` from `$HOME`,
56
+ while zsh reads rc files from `$ZDOTDIR`, which is exported in some environments.
47
57
  `$NEUTRAL` and `$ISO_HOME` must be on paths that do not embed a project or repo identifier.
48
58
  `--permission-mode plan` is forbidden headless because it silently blocks
49
59
  all tools and returns an empty review; `--always-approve` is banned because it
@@ -51,8 +61,11 @@ auto-approves writes. `--tools` and `--disallowed-tools` are headless-only (the
51
61
  TUI warns and ignores them); when both are present, the disallow list wins and
52
62
  removes tools entirely rather than merely gating execution.
53
63
 
54
- The `--allow` rule must cover the full probe-command family the judge may need,
55
- and the judge prompt must name the exact permitted command(s). Derive the prompt
64
+ Never use `--json-schema` for the pair judge: measured runs degraded the
65
+ tool-use loop (2/3 made zero tool calls and fabricated findings), and a
66
+ `Cancelled` run emitted a PASS-shaped payload after its schema had failed.
67
+
68
+ The judge prompt must name the exact permitted command(s). Derive the prompt
56
69
  anchor and allow-list string identically from one source, in this precedence:
57
70
  the solo-headroom hypothesis's backticked observable command when present;
58
71
  otherwise the backticked commands in the spec's `## Verification` bullets;
@@ -60,17 +73,26 @@ otherwise the repo's existing test/CLI runner. If none exists, the probe
60
73
  obligation does not arise and a static review is correct for that spec.
61
74
  The documented `Bash(<command>)` form matches the exact command or its prefix;
62
75
  the trailing ` *` form fails a no-argument command.
76
+ Under `dontAsk`, every allow shape tested for a chained command — including an
77
+ exact full-string rule for the chain — returned `PermissionCancelled`, so the
78
+ mandatory dominance-loss anchor must run unchained with the allow rule scoped
79
+ to that bare anchor.
63
80
 
64
- Grok's deny semantics are not Claude-parity: an out-of-allowlist command silently
65
- truncates the review at exit 0. Treat the resulting collector rejection as
66
- `BLOCKED` for `verify.pair.emission-contract`, never PASS. After a non-timeout
67
- spawn, run:
81
+ Grok's deny semantics are not Claude-parity: reaching the permission mode's
82
+ auto-deny silently truncates the review at exit 0. On the configured, measured
83
+ path the `PreToolUse` hook above keeps the judge from reaching it — the hook runs
84
+ at step 1, before the rules and before the mode policy, and returns the denial as
85
+ a model-visible `tool_result`.
86
+ It admits the bare anchor and anchor-plus-argv, and vetoes any character that
87
+ could start a second command, so `verify.md`'s bounded input variations survive
88
+ while chaining does not. The hook fails open, so the collector rejection remains
89
+ the fail-closed floor: treat it as `BLOCKED` for
90
+ `verify.pair.emission-contract`, never PASS. After a non-timeout spawn, run:
68
91
 
69
92
  ```bash
70
93
  python3 "$DEVLYN_SHARED_DIR/collect-codex-findings.py" \
71
94
  --devlyn-dir "$REPO/.devlyn" \
72
- --stdout-file grok-judge.stdout \
73
- --summary-out grok-judge.summary.json
95
+ --stdout-file grok-judge.stdout
74
96
  ```
75
97
 
76
98
  On exit 124, write `.devlyn/verify.pair.timeout.json` with