devlyn-cli 4.3.0 → 4.3.4

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 (65) hide show
  1. package/bin/cleanup-history.json +76 -1
  2. package/bin/devlyn.js +172 -48
  3. package/bin/skill-history.json +133 -2
  4. package/config/skills/_shared/harness-store.py +8 -2
  5. package/config/skills/_shared/platform-support.py +12 -0
  6. package/config/skills/_shared/task-completion.md +6 -3
  7. package/config/skills/_shared/warm-build-dirs.py +97 -2
  8. package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
  9. package/harness/AGENTS.md +37 -0
  10. package/harness/CLAUDE.md +37 -0
  11. package/native/claude/.claude-plugin/plugin.json +6 -0
  12. package/native/claude/skills/_shared/.ruff.toml +2 -0
  13. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  14. package/native/claude/skills/_shared/adapters/codex.md +14 -0
  15. package/native/claude/skills/_shared/adapters/omp.md +6 -0
  16. package/native/claude/skills/_shared/codex-monitored.sh +263 -0
  17. package/native/claude/skills/_shared/engine-doctor.sh +70 -0
  18. package/native/claude/skills/_shared/engine-preflight.md +29 -0
  19. package/native/claude/skills/_shared/expected-contract.py +242 -0
  20. package/native/claude/skills/_shared/expected.schema.json +120 -0
  21. package/native/claude/skills/_shared/harness-store.py +207 -0
  22. package/native/claude/skills/_shared/invocation-receipt.py +499 -0
  23. package/native/claude/skills/_shared/platform-support.py +407 -0
  24. package/native/claude/skills/_shared/role-config.py +247 -0
  25. package/native/claude/skills/_shared/runtime-principles.md +35 -0
  26. package/native/claude/skills/_shared/task-complete.py +2865 -0
  27. package/native/claude/skills/_shared/task-completion.md +249 -0
  28. package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
  29. package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
  30. package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
  31. package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
  32. package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
  33. package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
  34. package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
  35. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  36. package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
  37. package/native/codex/.agents/plugins/marketplace.json +12 -0
  38. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
  39. package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
  40. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  41. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
  43. package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
  44. package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
  45. package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
  46. package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
  47. package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
  48. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +207 -0
  49. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
  50. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +407 -0
  51. package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
  52. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
  53. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
  54. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
  55. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
  56. package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
  57. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
  58. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
  59. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
  60. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
  61. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
  62. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  63. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
  64. package/package.json +1 -1
  65. package/release.json +1630 -0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # engine-doctor.sh — read-only detection for /devlyn-engines' no-arg output.
3
+ #
4
+ # WHY (iter-0050): users need to see what's actually on the machine, not
5
+ # just what's pinned. This script never writes .devlyn/engines.json, never
6
+ # installs anything, never changes pin validation — it only reports.
7
+ #
8
+ # macOS-safe bash 3.2: no associative arrays, no mapfile/readarray.
9
+ set -uo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
12
+ ADAPTERS_DIR="$SCRIPT_DIR/adapters"
13
+
14
+ TARGETS=(claude codex omp pi)
15
+ KINDS=(cli-engine cli-engine cli-engine orchestrator-only)
16
+ BINARIES=(claude codex omp "")
17
+ INSTALL_HINTS=(
18
+ "see https://docs.anthropic.com/en/docs/claude-code"
19
+ "npm install -g @openai/codex"
20
+ "brew install can1357/tap/omp"
21
+ ""
22
+ )
23
+
24
+ check_binary() {
25
+ # $1 = binary name, "" means no known binary to probe
26
+ [ -n "$1" ] || { printf 'unknown'; return; }
27
+ if command -v "$1" >/dev/null 2>&1; then printf 'yes'; else printf 'no'; fi
28
+ }
29
+
30
+ check_adapter() {
31
+ # $1 = target name
32
+ if [ -f "$ADAPTERS_DIR/$1.md" ]; then printf 'yes'; else printf 'no'; fi
33
+ }
34
+
35
+ check_executor() {
36
+ # $1 = adapter file path. An adapter may declare the fixed ASCII field
37
+ # `executor: no` under `## Role eligibility`; otherwise it can execute.
38
+ [ -f "$1" ] || { printf 'n/a'; return; }
39
+ if grep -q '^executor: no' "$1" 2>/dev/null; then printf 'no'; else printf 'yes'; fi
40
+ }
41
+
42
+ printf '%-8s %-17s %-8s %-8s %-9s %s\n' \
43
+ 'target' 'kind' 'binary' 'adapter' 'executor' 'note'
44
+
45
+ for i in "${!TARGETS[@]}"; do
46
+ target="${TARGETS[$i]}"
47
+ kind="${KINDS[$i]}"
48
+ binary="$(check_binary "${BINARIES[$i]}")"
49
+ adapter="$(check_adapter "$target")"
50
+ executor="$(check_executor "$ADAPTERS_DIR/$target.md")"
51
+
52
+ note='-'
53
+ case "$kind" in
54
+ cli-engine)
55
+ if [ "$binary" = 'no' ]; then
56
+ note="not installed; ${INSTALL_HINTS[$i]}"
57
+ elif [ "$adapter" = 'no' ]; then
58
+ note="binary present, no adapter — reinstall devlyn-cli, whose releases ship the adapters"
59
+ elif [ "$executor" = 'no' ]; then
60
+ note='adapter declares executor: no'
61
+ fi
62
+ ;;
63
+ orchestrator-only)
64
+ note='informational only; not a routable engine — no verified CLI binary or adapter'
65
+ ;;
66
+ esac
67
+
68
+ printf '%-8s %-17s %-8s %-8s %-9s %s\n' \
69
+ "$target" "$kind" "$binary" "$adapter" "$executor" "$note"
70
+ done
@@ -0,0 +1,29 @@
1
+ # Shared — Executor Pre-flight
2
+
3
+ Used by `/devlyn-ideate` and `/devlyn-engines`: one rule for which engine executes implementation work and what happens when it is missing.
4
+
5
+ ## Selection
6
+
7
+ The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there. Pi and Grok have no executor adapter, so a drain from them needs a claude, codex or omp pin. Keys earlier releases wrote for retired pipeline roles (`pair_judge_priority`, `roles`) stay as written and select nothing: a worker profile never becomes the executor.
8
+
9
+ Before dispatching to the executor, run:
10
+
11
+ ```sh
12
+ python3 "$DEVLYN_SHARED_DIR/role-config.py" --workdir <project> --default-engine <this CLI> --select
13
+ ```
14
+
15
+ A pin is a promise. When the pinned engine's CLI is unavailable, the command exits 1 with `BLOCKED:<engine>-unavailable`: stop, preserve the evidence, and show the setup guidance — install and authenticate that CLI, verify `<engine> --version`, rerun. Never substitute another engine. A malformed file, or a name without an executor-eligible adapter, is `BLOCKED:invalid-engine-config`. CLI presence is not authentication, and status never launches a model.
16
+
17
+ ## Engines
18
+
19
+ An engine is pinnable when `_shared/adapters/<name>.md` exists and does not declare `executor: no` under `## Role eligibility`. Adapters ship with devlyn-cli releases, and a reinstall replaces `_shared`. An adapter's `## Invocation` says how to start it. On native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper.
20
+
21
+ ## Reporting a blocked engine
22
+
23
+ The final report names the selected engine, the verdict and the setup steps; it never reports a downgraded success:
24
+
25
+ ```
26
+ Engine: codex (pinned executor)
27
+ Verdict: BLOCKED:codex-unavailable
28
+ Setup: install/configure Codex CLI; run the current Codex auth/login flow; verify `codex --version`; rerun.
29
+ ```
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """Strict JSON and `spec.expected.json` contract validation for the ideate loop's package check and acceptance runner."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import unittest
8
+
9
+
10
+ def reject_json_constant(token: str) -> None:
11
+ raise ValueError(f"invalid JSON numeric constant: {token}")
12
+
13
+
14
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
15
+ result = {}
16
+ for key, value in pairs:
17
+ if key in result:
18
+ raise ValueError(f"duplicate JSON key: {key}")
19
+ result[key] = value
20
+ return result
21
+
22
+
23
+ def loads_strict_json(text: str):
24
+ return json.loads(
25
+ text,
26
+ parse_constant=reject_json_constant,
27
+ object_pairs_hook=reject_duplicate_keys,
28
+ )
29
+
30
+
31
+ EXPECTED_TOP_LEVEL_KEYS = {
32
+ "verification_commands",
33
+ "forbidden_patterns",
34
+ "required_files",
35
+ "forbidden_files",
36
+ "max_deps_added",
37
+ "pure_design",
38
+ }
39
+ EXPECTED_VERIFICATION_COMMAND_KEYS = {
40
+ "cmd",
41
+ "argv",
42
+ "exit_code",
43
+ "timeout_sec",
44
+ "stdout_contains",
45
+ "stdout_not_contains",
46
+ "contract_refs",
47
+ }
48
+ DEFAULT_TIMEOUT_SEC = 60
49
+
50
+
51
+ def verification_timeout_sec(command: dict) -> int:
52
+ return command.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
53
+
54
+
55
+ def validate_command(command: object, label: str) -> str | None:
56
+ """Validate one command; `argv` and `cmd` are mutually exclusive."""
57
+ if not isinstance(command, dict):
58
+ return f"{label} must be an object"
59
+ if "argv" in command:
60
+ if "cmd" in command:
61
+ return f"{label} requires exactly one of cmd or argv"
62
+ value = command["argv"]
63
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value) or not value[0]:
64
+ return f"{label}.argv must be a string array with non-empty argv[0]"
65
+ else:
66
+ cmd = command.get("cmd")
67
+ if not isinstance(cmd, str) or not cmd.strip():
68
+ return f"{label}.cmd must be a non-empty string"
69
+ ec = command.get("exit_code", 0)
70
+ if isinstance(ec, bool) or not isinstance(ec, int):
71
+ return f"{label}.exit_code must be int (not bool)"
72
+ timeout_sec = command.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
73
+ if isinstance(timeout_sec, bool) or not isinstance(timeout_sec, int) or not 1 <= timeout_sec <= 600:
74
+ return f"{label}.timeout_sec must be int from 1 to 600 (not bool)"
75
+ for k in ("stdout_contains", "stdout_not_contains"):
76
+ v = command.get(k, [])
77
+ if not isinstance(v, list) or not all(isinstance(s, str) and s for s in v):
78
+ return f"{label}.{k} must be a list of non-empty strings"
79
+ return None
80
+
81
+
82
+ def validate_shape(data) -> str | None:
83
+ """Return None if `data` has a non-empty, well-formed `verification_commands`
84
+ list; else a human-readable error string.
85
+
86
+ Each object requires exactly one of a non-empty string `cmd` or a
87
+ string-array `argv`; `exit_code` defaults to 0 and must be a
88
+ non-bool int; `timeout_sec` defaults to DEFAULT_TIMEOUT_SEC and must be a
89
+ non-bool int from 1 through 600; `stdout_contains` and `stdout_not_contains`
90
+ default to empty lists of non-empty strings. Bool is rejected explicitly
91
+ because Python's `bool` subclasses `int`.
92
+ """
93
+ if not isinstance(data, dict):
94
+ return "top-level must be a JSON object"
95
+ cmds = data.get("verification_commands")
96
+ if not isinstance(cmds, list):
97
+ return "verification_commands must be a list"
98
+ if not cmds:
99
+ return "verification_commands must contain at least one entry"
100
+ for i, c in enumerate(cmds):
101
+ err = validate_command(c, f"verification_commands[{i}]")
102
+ if err:
103
+ return err
104
+ return None
105
+
106
+
107
+ def validate_string_list(data: object, key: str) -> str | None:
108
+ value = data.get(key, []) if isinstance(data, dict) else None
109
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
110
+ return f"{key} must be a list of non-empty strings"
111
+ return None
112
+
113
+
114
+ def validate_expected_shape(data) -> str | None:
115
+ """Return None if shape matches the sibling spec.expected.json schema.
116
+
117
+ Keep this dependency-free: it mirrors `_shared/expected.schema.json` enough
118
+ to catch malformed contracts before a consumer executes them.
119
+ """
120
+ if not isinstance(data, dict):
121
+ return "top-level must be a JSON object"
122
+ unknown = sorted(set(data) - EXPECTED_TOP_LEVEL_KEYS)
123
+ if unknown:
124
+ return f"unknown top-level key(s): {', '.join(unknown)}"
125
+ if "verification_commands" in data:
126
+ commands = data["verification_commands"]
127
+ if not isinstance(commands, list):
128
+ return "verification_commands must be a list"
129
+ if commands:
130
+ err = validate_shape({"verification_commands": commands})
131
+ if err:
132
+ return err
133
+ for i, command in enumerate(commands):
134
+ unknown_command_keys = sorted(set(command) - EXPECTED_VERIFICATION_COMMAND_KEYS)
135
+ if unknown_command_keys:
136
+ return (
137
+ f"verification_commands[{i}] unknown key(s): "
138
+ f"{', '.join(unknown_command_keys)}"
139
+ )
140
+ contract_refs = command.get("contract_refs", [])
141
+ if not isinstance(contract_refs, list) or not all(
142
+ isinstance(item, str) and item for item in contract_refs
143
+ ):
144
+ return f"verification_commands[{i}].contract_refs must be a list of non-empty strings"
145
+ for key in ("required_files", "forbidden_files"):
146
+ err = validate_string_list(data, key)
147
+ if err:
148
+ return err
149
+ max_deps = data.get("max_deps_added", 0)
150
+ if isinstance(max_deps, bool) or not isinstance(max_deps, int) or max_deps < 0:
151
+ return "max_deps_added must be a non-negative integer"
152
+ if "pure_design" in data and not isinstance(data["pure_design"], bool):
153
+ return "pure_design must be a boolean"
154
+ patterns = data.get("forbidden_patterns", [])
155
+ if not isinstance(patterns, list):
156
+ return "forbidden_patterns must be a list"
157
+ for i, pattern in enumerate(patterns):
158
+ if not isinstance(pattern, dict):
159
+ return f"forbidden_patterns[{i}] must be an object"
160
+ unknown_pattern_keys = sorted(set(pattern) - {"pattern", "description", "files", "severity"})
161
+ if unknown_pattern_keys:
162
+ return (
163
+ f"forbidden_patterns[{i}] unknown key(s): "
164
+ f"{', '.join(unknown_pattern_keys)}"
165
+ )
166
+ for key in ("pattern", "description", "severity"):
167
+ value = pattern.get(key)
168
+ if not isinstance(value, str) or not value:
169
+ return f"forbidden_patterns[{i}].{key} must be a non-empty string"
170
+ if pattern["severity"] not in {"disqualifier", "warning"}:
171
+ return f"forbidden_patterns[{i}].severity must be disqualifier or warning"
172
+ files = pattern.get("files", [])
173
+ if not isinstance(files, list) or not all(isinstance(item, str) and item for item in files):
174
+ return f"forbidden_patterns[{i}].files must be a list of non-empty strings"
175
+ return None
176
+
177
+
178
+ def slice_diff_to_files(diff_text: str, files: list[str]) -> str:
179
+ if not files:
180
+ return diff_text
181
+ out: list[str] = []
182
+ keep = False
183
+ for line in diff_text.splitlines(keepends=True):
184
+ if line.startswith("diff --git "):
185
+ keep = any(path in line for path in files)
186
+ if keep:
187
+ out.append(line)
188
+ return "".join(out)
189
+
190
+
191
+ class ContractTests(unittest.TestCase):
192
+ def test_strict_json(self):
193
+ for text, message in (('{"a":1,"a":2}', "duplicate JSON key"), ('{"a":NaN}', "invalid JSON numeric constant")):
194
+ with self.assertRaisesRegex(ValueError, message):
195
+ loads_strict_json(text)
196
+
197
+ def test_cmd_and_argv_are_mutually_exclusive(self):
198
+ ok = {"verification_commands": [{"argv": ["python3", "-c", "pass"], "contract_refs": ["R1"]}, {"cmd": "true"}]}
199
+ self.assertIsNone(validate_expected_shape(ok))
200
+ for command, message in (
201
+ ({"cmd": "true", "argv": ["true"]}, "exactly one of cmd or argv"),
202
+ ({"argv": []}, "argv must be a string array"),
203
+ ({"argv": ["", "x"]}, "argv must be a string array"),
204
+ ({"argv": ["x", 1]}, "argv must be a string array"),
205
+ ({}, "cmd must be a non-empty string"),
206
+ ):
207
+ self.assertIn(message, validate_expected_shape({"verification_commands": [command]}))
208
+
209
+ def test_command_fields(self):
210
+ for command, message in (
211
+ ({"cmd": "x", "exit_code": True}, "exit_code must be int"),
212
+ ({"cmd": "x", "timeout_sec": 601}, "timeout_sec must be int from 1 to 600"),
213
+ ({"cmd": "x", "stdout_contains": "x"}, "stdout_contains must be a list of non-empty strings"),
214
+ ({"cmd": "x", "stdout_contains": [""]}, "stdout_contains must be a list of non-empty strings"),
215
+ ({"cmd": "x", "stdout_not_contains": [""]}, "stdout_not_contains must be a list of non-empty strings"),
216
+ ({"cmd": "x", "contract_refs": [""]}, "contract_refs must be a list of non-empty strings"),
217
+ ({"cmd": "x", "extra": 1}, "unknown key(s): extra"),
218
+ ):
219
+ self.assertIn(message, validate_expected_shape({"verification_commands": [command]}))
220
+ self.assertEqual(validate_expected_shape([]), "top-level must be a JSON object")
221
+ self.assertIn("unknown top-level key(s): process_evidence", validate_expected_shape({"process_evidence": []}))
222
+
223
+ def test_diff_helpers(self):
224
+ diff = ('diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1,3 +1,5 @@\n'
225
+ ' {\n "dependencies": {\n+ "left-pad": "1.0.0",\n+ "is-odd": "3.0.0"\n }\n }\n'
226
+ 'diff --git a/other b/other\n+x\n')
227
+ self.assertNotIn("other", slice_diff_to_files(diff, ["package.json"]))
228
+ self.assertEqual(slice_diff_to_files(diff, []), diff)
229
+
230
+
231
+ def main() -> int:
232
+ if sys.argv[1:] != ["--self-test"]:
233
+ print("usage: expected-contract.py --self-test", file=sys.stderr)
234
+ return 2
235
+ result = unittest.TextTestRunner(verbosity=1).run(unittest.defaultTestLoader.loadTestsFromTestCase(ContractTests))
236
+ if result.wasSuccessful():
237
+ print(f"expected-contract self-test: PASS ({result.testsRun} tests)")
238
+ return 0 if result.wasSuccessful() else 1
239
+
240
+
241
+ if __name__ == "__main__":
242
+ sys.exit(main())
@@ -0,0 +1,120 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/fysoul17/devlyn-cli/config/skills/_shared/expected.schema.json",
4
+ "title": "spec.expected.json — mechanical acceptance contract",
5
+ "description": "Load-bearing LLM-agnostic decoupler for the devlyn-cli harness. Defines the machine-readable acceptance criteria a spec ships alongside spec.md. Stable across model upgrades — when Opus 5 / GPT-6 / Qwen / Gemini land, this schema does not move; only the per-model adapter files in _shared/adapters/ do.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "verification_commands": {
10
+ "type": "array",
11
+ "description": "Each command must pass on the task's committed source. At least one entry is required when the spec has any observable runtime check (CLI, test command, HTTP request).",
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "oneOf": [
16
+ { "required": ["cmd"], "not": { "required": ["argv"] } },
17
+ { "required": ["argv"], "not": { "required": ["cmd"] } }
18
+ ],
19
+ "properties": {
20
+ "cmd": {
21
+ "type": "string",
22
+ "description": "Shell command, executed via `subprocess.run(..., shell=True)` from the build's working directory.",
23
+ "minLength": 1
24
+ },
25
+ "argv": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "items": { "type": "string" },
29
+ "description": "Exact argv executed without a shell; argv[0] must be non-empty. Preferred for generated and Windows contracts."
30
+ },
31
+ "exit_code": {
32
+ "type": "integer",
33
+ "description": "Required exit code. Default 0 if omitted.",
34
+ "default": 0
35
+ },
36
+ "timeout_sec": {
37
+ "type": "integer",
38
+ "minimum": 1,
39
+ "maximum": 600,
40
+ "description": "Per-command timeout in seconds. Default 60 if omitted; maximum 600.",
41
+ "default": 60
42
+ },
43
+ "stdout_contains": {
44
+ "type": "array",
45
+ "description": "Each substring must appear verbatim in (stdout + stderr) for pass.",
46
+ "items": { "type": "string", "minLength": 1 },
47
+ "default": []
48
+ },
49
+ "stdout_not_contains": {
50
+ "type": "array",
51
+ "description": "None of these substrings may appear in (stdout + stderr) for pass.",
52
+ "items": { "type": "string", "minLength": 1 },
53
+ "default": []
54
+ },
55
+ "contract_refs": {
56
+ "type": "array",
57
+ "description": "Requirements this command verifies. Ideate loop tasks name requirement IDs (R1, R2, ...) from the task Requirements and require at least one.",
58
+ "items": { "type": "string", "minLength": 1 },
59
+ "default": []
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "forbidden_patterns": {
65
+ "type": "array",
66
+ "description": "Regex patterns scanned across the task's diff.",
67
+ "items": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": ["pattern", "description", "severity"],
71
+ "properties": {
72
+ "pattern": {
73
+ "type": "string",
74
+ "description": "Python re.search-compatible regex. Anchored implicitly by the surrounding regex, NOT by ^ / $ unless intended.",
75
+ "minLength": 1
76
+ },
77
+ "description": {
78
+ "type": "string",
79
+ "description": "Human-readable explanation of what the pattern catches and why it's forbidden.",
80
+ "minLength": 1
81
+ },
82
+ "files": {
83
+ "type": "array",
84
+ "description": "Optional allow-list of files (substrings of diff --git lines). When present, scan is sliced to hunks touching only these files.",
85
+ "items": { "type": "string", "minLength": 1 },
86
+ "default": []
87
+ },
88
+ "severity": {
89
+ "type": "string",
90
+ "enum": ["disqualifier", "warning"],
91
+ "description": "disqualifier blocks acceptance; warning is recorded."
92
+ }
93
+ }
94
+ }
95
+ },
96
+ "required_files": {
97
+ "type": "array",
98
+ "description": "Files specified by literal paths that must exist in the committed source; globs are not expanded.",
99
+ "items": { "type": "string", "minLength": 1 },
100
+ "default": []
101
+ },
102
+ "forbidden_files": {
103
+ "type": "array",
104
+ "description": "Exact Git-relative paths (forward slashes, no leading ./) that must NOT appear in the diff, including both rename names; globs and directory prefixes are not expanded.",
105
+ "items": { "type": "string", "minLength": 1 },
106
+ "default": []
107
+ },
108
+ "max_deps_added": {
109
+ "type": "integer",
110
+ "description": "Hard cap on package names the root package.json's dependencies and devDependencies gain over the task inputs; a move between them adds none.",
111
+ "minimum": 0,
112
+ "default": 0
113
+ },
114
+ "pure_design": {
115
+ "type": "boolean",
116
+ "description": "Declares that every Requirement is pure-design (no observable runtime check). Lets verification_commands stay empty without failing shape validation. Contradictory (and rejected) when true alongside a non-empty verification_commands.",
117
+ "default": false
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,207 @@
1
+ """Release identity and queue resource checks. Readers never write or create locks.
2
+
3
+ The release verifier is CLI-1's bin/release.js, not a second manifest verifier.
4
+ A Codex projection has .devlyn-harness-projection.json at its plugin root:
5
+ {schemaVersion: 1, release: ReleaseRef, source: "native/codex/plugins/devlyn"}.
6
+ Its complete regular-file tree (except that marker) must match that release subtree.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import runpy
16
+ import subprocess
17
+ import sys
18
+
19
+ PROJECTION = ".devlyn-harness-projection.json"
20
+ CODEX_SOURCE = "native/codex/plugins/devlyn"
21
+
22
+
23
+ class HarnessError(Exception):
24
+ pass
25
+
26
+
27
+ def require(condition, message):
28
+ if not condition:
29
+ raise HarnessError(message)
30
+
31
+
32
+ def read_json(path):
33
+ try:
34
+ return json.loads(Path(path).read_bytes())
35
+ except (OSError, ValueError) as exc:
36
+ raise HarnessError(f"{path}: {exc}") from exc
37
+
38
+
39
+ def file_identity(path):
40
+ path = Path(path)
41
+ info = path.lstat()
42
+ require(path.is_file() and not path.is_symlink() and info.st_nlink == 1, f"not a regular resource: {path}")
43
+ return {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
44
+
45
+
46
+ def release_ref(value):
47
+ require(isinstance(value, dict) and set(value) == {"package", "version", "digest", "root"}
48
+ and value["package"] == "devlyn-cli"
49
+ and all(isinstance(value[k], str) and value[k] and "\0" not in value[k] for k in ("version", "digest", "root"))
50
+ and re.fullmatch(r"[a-f0-9]{64}", value["digest"]) and Path(value["root"]).is_absolute(), "malformed release reference")
51
+ return value
52
+
53
+
54
+ def verify_release(root):
55
+ root = Path(root)
56
+ # As harness-status verifies it: an npm-installed package keeps npm's own .bin shims, which are checked.
57
+ script = "const r=require(process.argv[1]);r.runJson(()=>r.verifyRelease(process.argv[2],{npmInstall:true}));"
58
+ try:
59
+ node = runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["managed_node"]() or "node"
60
+ result = subprocess.run([node, "-e", script, str(root / "bin/release.js"), str(root)], capture_output=True, text=True, encoding="utf-8")
61
+ except (OSError, ValueError) as exc:
62
+ raise HarnessError(f"release verifier could not start: {exc}") from exc
63
+ require(result.returncode == 0, f"release verification failed: {root}: {result.stderr.strip()}")
64
+ try:
65
+ release = release_ref(json.loads(result.stdout)["data"]["release"])
66
+ except (KeyError, ValueError, TypeError) as exc:
67
+ raise HarnessError(f"invalid release verifier result: {root}: {exc}") from exc
68
+ manifest = read_json(root / "release.json")
69
+ defaults = file_identity(root / manifest["principles"]["defaults"])
70
+ return {"schemaVersion": 1, "release": release, "defaults": defaults}
71
+
72
+
73
+ def walk_error(error):
74
+ raise error # os.walk otherwise silently skips unreadable projection directories.
75
+
76
+
77
+ def resolve(skills):
78
+ """Resolve only this invoking skill's real ancestry; never search another install."""
79
+ skill = (Path(skills) / "devlyn-ideate").resolve()
80
+ for parent in (skill, *skill.parents):
81
+ marker = parent / PROJECTION
82
+ if marker.exists() or marker.is_symlink():
83
+ file_identity(marker)
84
+ projection = read_json(marker)
85
+ require(isinstance(projection, dict) and set(projection) == {"schemaVersion", "release", "source"}
86
+ and type(projection["schemaVersion"]) is int and projection["schemaVersion"] == 1
87
+ and projection["source"] == CODEX_SOURCE, f"malformed projection: {marker}")
88
+ ref = release_ref(projection["release"])
89
+ binding = verify_release(ref["root"])
90
+ require(binding["release"] == ref, f"projection release changed: {marker}")
91
+ source = Path(ref["root"]) / CODEX_SOURCE
92
+ require(skill == parent / "skills/devlyn-ideate", f"unexpected projection skill: {skill}")
93
+ expected = {p.relative_to(source) for p in source.rglob("*") if p.is_file()}
94
+ actual = set()
95
+ for directory, dirs, files in os.walk(parent, onerror=walk_error):
96
+ for name in dirs:
97
+ require(not (Path(directory) / name).is_symlink(), f"projection directory link: {directory}/{name}")
98
+ for name in files:
99
+ file = Path(directory) / name
100
+ if file == marker:
101
+ continue
102
+ rel = file.relative_to(parent)
103
+ actual.add(rel)
104
+ require(rel in expected and file_identity(file)["sha256"] == file_identity(source / rel)["sha256"]
105
+ and (os.name == "nt" or bool(file.stat().st_mode & 0o111) == bool((source / rel).stat().st_mode & 0o111)),
106
+ f"projection resource changed: {file}")
107
+ require(actual == expected, f"projection file list changed: {parent}")
108
+ return binding
109
+ manifest = parent / "release.json"
110
+ if (manifest.exists() or manifest.is_symlink()) and skill in [parent / p / "devlyn-ideate" for p in
111
+ ("config/skills", "native/claude/skills", CODEX_SOURCE + "/skills")]:
112
+ return verify_release(parent)
113
+ return None
114
+
115
+
116
+ def read_binding(common):
117
+ path = Path(common) / "devlyn-loops/harness.json"
118
+ if not path.exists() and not path.is_symlink():
119
+ return None
120
+ file_identity(path)
121
+ binding = read_json(path)
122
+ require(isinstance(binding, dict) and set(binding) == {"schemaVersion", "release", "defaults"}
123
+ and type(binding["schemaVersion"]) is int and binding["schemaVersion"] == 1, f"incompatible queue binding: {path}")
124
+ release_ref(binding["release"])
125
+ resource(binding["defaults"])
126
+ return binding
127
+
128
+
129
+ def resource(value):
130
+ require(isinstance(value, dict) and set(value) == {"path", "sha256"}
131
+ and isinstance(value["path"], str) and "\0" not in value["path"] and Path(value["path"]).is_absolute()
132
+ and isinstance(value["sha256"], str) and re.fullmatch(r"[a-f0-9]{64}", value["sha256"]), "malformed resource binding")
133
+
134
+
135
+ def recovery_argv(binding, argv):
136
+ return [sys.executable, str(Path(binding["release"]["root"]) / "config/skills/devlyn-ideate/scripts/queue.py"), *argv]
137
+
138
+
139
+ def check_binding(binding, invoking):
140
+ if binding is None:
141
+ return
142
+ require(invoking is not None and binding["release"] == invoking["release"], "queue-release-mismatch")
143
+ require(binding == invoking, "queue defaults changed; restore the bound release")
144
+
145
+
146
+ def packet_schema(packet):
147
+ require(isinstance(packet, dict), "task packet must be an object")
148
+ version = packet.get("schema_version", 1)
149
+ require(type(version) is int and version in (1, 2), f"incompatible task packet version: {version!r}")
150
+ if version == 1:
151
+ return
152
+ require("harness" in packet and isinstance(packet.get("methodology"), dict), "malformed packet v2 harness/methodology")
153
+ methodology = packet["methodology"]
154
+ require(set(methodology) == {"defaults", "projectInstructions"} and isinstance(methodology["projectInstructions"], list),
155
+ "malformed packet v2 methodology")
156
+ for entry in methodology["projectInstructions"]:
157
+ resource(entry)
158
+ if packet["harness"] is None:
159
+ defaults = methodology["defaults"]
160
+ require(packet.get("workerPrompt") is None and (defaults is None or (isinstance(defaults, dict) and defaults.get("source") == "skill")),
161
+ "legacy packet has release resources")
162
+ if defaults is not None:
163
+ resource({k: v for k, v in defaults.items() if k != "source"})
164
+ return
165
+ release_ref(packet["harness"])
166
+ defaults = methodology["defaults"]
167
+ require(isinstance(defaults, dict) and defaults.get("source") == "release", "packet has no verified release defaults source")
168
+ resource({k: v for k, v in defaults.items() if k != "source"})
169
+ resource(packet.get("workerPrompt"))
170
+
171
+
172
+ def verify_packet(packet, path, skills):
173
+ packet_schema(packet)
174
+ if packet.get("schema_version", 1) == 1:
175
+ return # Preserve v1 execution without upgrading its resources.
176
+ receipt = packet.get("receipt")
177
+ require(isinstance(receipt, str) and "\0" not in receipt and Path(receipt).is_absolute()
178
+ and Path(receipt).name == "receipt.json" and Path(receipt).parent.parent.name == "devlyn-completion"
179
+ and Path(receipt) == Path(path).with_name("receipt.json"),
180
+ "malformed packet receipt path")
181
+ binding = read_binding(Path(receipt).parent.parent.parent)
182
+ if packet["harness"] is None:
183
+ require(binding is None, "bound task packet has no harness/defaults source")
184
+ # An unbound installation's own defaults change with its normal updates: never gate acceptance or delivery on
185
+ # their bytes (queue.py checks only their presence, before a spawn).
186
+ return
187
+ require(binding is not None, "packet queue binding is missing; restore harness.json")
188
+ invoking = resolve(skills)
189
+ ref = packet["harness"]
190
+ require(invoking is not None and invoking["release"] == ref,
191
+ f"packet-release-mismatch; use {Path(ref['root']) / 'config/skills/devlyn-ideate/scripts/queue.py'}")
192
+ check_binding(binding, invoking)
193
+ defaults = {"source": "release", **binding["defaults"]}
194
+ require(packet["methodology"]["defaults"] == defaults, "packet defaults differ from queue binding")
195
+ expected_runner = [str(Path(ref["root"]) / "config/skills/devlyn-ideate/scripts/acceptance.py"),
196
+ "run", "--packet", str(path)]
197
+ require(isinstance(packet.get("runner"), list) and packet["runner"][1:] == expected_runner, "packet runner differs from bound release")
198
+ prompt = packet["workerPrompt"]
199
+ require(prompt["path"] == str(Path(path).parent / "worker-prompt.md") and file_identity(prompt["path"]) == prompt,
200
+ "worker prompt changed or missing; restore the bound resource")
201
+ for entry in packet["methodology"]["projectInstructions"]:
202
+ name = Path(entry["path"]).name
203
+ require(name in ("CLAUDE.md", "AGENTS.md") and entry["path"] == str(Path(packet["worktree"]) / name),
204
+ "project instruction path escapes packet worktree")
205
+ committed = subprocess.run(["git", "-C", packet["worktree"], "cat-file", "blob", f"{packet['inputs_sha']}:{name}"], capture_output=True)
206
+ require(committed.returncode == 0 and hashlib.sha256(committed.stdout).hexdigest() == entry["sha256"],
207
+ f"project instruction differs from inputs commit: {name}")