devlyn-cli 4.1.0 → 4.2.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.
Files changed (201) hide show
  1. package/AGENTS.md +10 -94
  2. package/CLAUDE.md +7 -160
  3. package/LICENSE +21 -0
  4. package/README.md +68 -111
  5. package/bin/devlyn.js +265 -259
  6. package/bin/instruction-templates.json +43 -2
  7. package/bin/instructions.js +29 -8
  8. package/bin/skill-history.json +140 -0
  9. package/bin/skill-ownership.js +67 -0
  10. package/config/skills/_shared/adapters/claude.md +8 -85
  11. package/config/skills/_shared/adapters/codex.md +14 -34
  12. package/config/skills/_shared/adapters/omp.md +1 -11
  13. package/config/skills/_shared/codex-monitored.sh +4 -54
  14. package/config/skills/_shared/engine-doctor.sh +18 -58
  15. package/config/skills/_shared/engine-preflight.md +15 -47
  16. package/config/skills/_shared/expected-contract.py +242 -0
  17. package/config/skills/_shared/expected.schema.json +10 -95
  18. package/config/skills/_shared/invocation-receipt.py +26 -757
  19. package/config/skills/_shared/platform-support.py +37 -28
  20. package/config/skills/_shared/role-config.py +100 -421
  21. package/config/skills/_shared/runtime-principles.md +17 -93
  22. package/config/skills/_shared/task-complete.py +490 -285
  23. package/config/skills/_shared/task-completion.md +235 -0
  24. package/config/skills/devlyn-engines/SKILL.md +9 -11
  25. package/config/skills/devlyn-ideate/SKILL.md +57 -107
  26. package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
  27. package/config/skills/devlyn-ideate/references/loop.md +110 -0
  28. package/config/skills/devlyn-ideate/references/package-format.md +128 -0
  29. package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
  30. package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
  31. package/node_modules/@inquirer/ansi/LICENSE +22 -0
  32. package/node_modules/@inquirer/ansi/README.md +121 -0
  33. package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
  34. package/node_modules/@inquirer/ansi/dist/index.js +21 -0
  35. package/node_modules/@inquirer/ansi/package.json +79 -0
  36. package/node_modules/@inquirer/checkbox/LICENSE +22 -0
  37. package/node_modules/@inquirer/checkbox/README.md +265 -0
  38. package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
  39. package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
  40. package/node_modules/@inquirer/checkbox/package.json +93 -0
  41. package/node_modules/@inquirer/core/LICENSE +22 -0
  42. package/node_modules/@inquirer/core/README.md +424 -0
  43. package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
  44. package/node_modules/@inquirer/core/dist/index.js +12 -0
  45. package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
  46. package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
  47. package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
  48. package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
  49. package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
  50. package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
  51. package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
  52. package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
  53. package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
  54. package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
  55. package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
  56. package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
  57. package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
  58. package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
  59. package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
  60. package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
  61. package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
  62. package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
  63. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
  64. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
  65. package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
  66. package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
  67. package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
  68. package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
  69. package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
  70. package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
  71. package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
  72. package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
  73. package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
  74. package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
  75. package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
  76. package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
  77. package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
  78. package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
  79. package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
  80. package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
  81. package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
  82. package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
  83. package/node_modules/@inquirer/core/package.json +98 -0
  84. package/node_modules/@inquirer/figures/LICENSE +22 -0
  85. package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
  86. package/node_modules/@inquirer/figures/dist/index.js +315 -0
  87. package/node_modules/@inquirer/figures/package.json +81 -0
  88. package/node_modules/@inquirer/select/LICENSE +22 -0
  89. package/node_modules/@inquirer/select/README.md +258 -0
  90. package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
  91. package/node_modules/@inquirer/select/dist/index.js +191 -0
  92. package/node_modules/@inquirer/select/package.json +93 -0
  93. package/node_modules/@inquirer/type/LICENSE +22 -0
  94. package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
  95. package/node_modules/@inquirer/type/dist/index.js +2 -0
  96. package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
  97. package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
  98. package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
  99. package/node_modules/@inquirer/type/dist/utils.js +16 -0
  100. package/node_modules/@inquirer/type/package.json +88 -0
  101. package/node_modules/cli-width/LICENSE +13 -0
  102. package/node_modules/cli-width/README.md +71 -0
  103. package/node_modules/cli-width/index.d.ts +13 -0
  104. package/node_modules/cli-width/index.js +49 -0
  105. package/node_modules/cli-width/package.json +40 -0
  106. package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
  107. package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
  108. package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
  109. package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
  110. package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
  111. package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
  112. package/node_modules/fast-string-truncated-width/license +21 -0
  113. package/node_modules/fast-string-truncated-width/package.json +35 -0
  114. package/node_modules/fast-string-truncated-width/readme.md +59 -0
  115. package/node_modules/fast-string-width/dist/index.d.ts +4 -0
  116. package/node_modules/fast-string-width/dist/index.js +14 -0
  117. package/node_modules/fast-string-width/license +21 -0
  118. package/node_modules/fast-string-width/package.json +34 -0
  119. package/node_modules/fast-string-width/readme.md +42 -0
  120. package/node_modules/fast-wrap-ansi/LICENSE +23 -0
  121. package/node_modules/fast-wrap-ansi/README.md +26 -0
  122. package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
  123. package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
  124. package/node_modules/fast-wrap-ansi/package.json +51 -0
  125. package/node_modules/mute-stream/LICENSE +15 -0
  126. package/node_modules/mute-stream/README.md +68 -0
  127. package/node_modules/mute-stream/lib/index.js +142 -0
  128. package/node_modules/mute-stream/package.json +54 -0
  129. package/node_modules/signal-exit/LICENSE.txt +16 -0
  130. package/node_modules/signal-exit/README.md +74 -0
  131. package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  132. package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  133. package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  134. package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  135. package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  136. package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  137. package/node_modules/signal-exit/dist/cjs/index.js +279 -0
  138. package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  139. package/node_modules/signal-exit/dist/cjs/package.json +3 -0
  140. package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  141. package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  142. package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  143. package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  144. package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  145. package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  146. package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  147. package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  148. package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  149. package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  150. package/node_modules/signal-exit/dist/mjs/index.js +275 -0
  151. package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  152. package/node_modules/signal-exit/dist/mjs/package.json +3 -0
  153. package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  154. package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  155. package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  156. package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  157. package/node_modules/signal-exit/package.json +106 -0
  158. package/package.json +17 -4
  159. package/config/commit-conventions.md +0 -28
  160. package/config/skills/_shared/adapters/README.md +0 -74
  161. package/config/skills/_shared/adapters/grok.md +0 -100
  162. package/config/skills/_shared/archive_run.py +0 -969
  163. package/config/skills/_shared/codex-config.md +0 -81
  164. package/config/skills/_shared/collect-codex-findings.py +0 -505
  165. package/config/skills/_shared/finish-gate.py +0 -521
  166. package/config/skills/_shared/grok-anchor-guard.py +0 -78
  167. package/config/skills/_shared/judge-output-parser.py +0 -200
  168. package/config/skills/_shared/judge-role-evidence.py +0 -320
  169. package/config/skills/_shared/phase-prompt-render.py +0 -183
  170. package/config/skills/_shared/process-evidence.py +0 -926
  171. package/config/skills/_shared/resolve-bootstrap.py +0 -1410
  172. package/config/skills/_shared/resolve-stop-hook.py +0 -359
  173. package/config/skills/_shared/run-bounded.py +0 -58
  174. package/config/skills/_shared/spec-verify-check.py +0 -5083
  175. package/config/skills/_shared/state-phase-write.py +0 -5989
  176. package/config/skills/_shared/terminal-claim-check.py +0 -777
  177. package/config/skills/_shared/verify-merge-findings.py +0 -3528
  178. package/config/skills/devlyn-design-ui/SKILL.md +0 -650
  179. package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
  180. package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
  181. package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
  182. package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
  183. package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
  184. package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
  185. package/config/skills/devlyn-queue/SKILL.md +0 -66
  186. package/config/skills/devlyn-queue/scripts/append.py +0 -94
  187. package/config/skills/devlyn-resolve/SKILL.md +0 -379
  188. package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
  189. package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
  190. package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
  191. package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
  192. package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
  193. package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
  194. package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
  195. package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
  196. package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
  197. package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
  198. package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
  199. package/config/templates/prompt-templates.md +0 -71
  200. package/config/templates/template-feature.spec.md +0 -255
  201. package/config/templates/template-product-spec.md +0 -680
@@ -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())
@@ -6,79 +6,27 @@
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
- "process_evidence": {
9
+ "verification_commands": {
10
10
  "type": "array",
11
- "description": "Declared process obligations captured during implementation. Each id is unique within its phase; the shared process-evidence runner records raw streams and validates the expectation before IMPLEMENT can pass.",
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
12
  "items": {
13
13
  "type": "object",
14
14
  "additionalProperties": false,
15
- "required": ["id", "phase"],
16
15
  "oneOf": [
17
16
  { "required": ["cmd"], "not": { "required": ["argv"] } },
18
17
  { "required": ["argv"], "not": { "required": ["cmd"] } }
19
18
  ],
20
19
  "properties": {
21
- "id": {
22
- "type": "string",
23
- "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
24
- "description": "Stable evidence id, unique within its phase and safe as one path component."
25
- },
26
- "phase": {
27
- "const": "implement",
28
- "description": "The phase that must produce this expected-contract obligation."
29
- },
30
20
  "cmd": {
31
21
  "type": "string",
32
- "minLength": 1,
33
- "description": "Exact shell command executed by the shared runner."
22
+ "description": "Shell command, executed via `subprocess.run(..., shell=True)` from the build's working directory.",
23
+ "minLength": 1
34
24
  },
35
25
  "argv": {
36
26
  "type": "array",
37
27
  "minItems": 1,
38
28
  "items": { "type": "string" },
39
- "description": "Exact argv executed without a shell; argv[0] must be non-empty."
40
- },
41
- "exit_code": {
42
- "type": "integer",
43
- "minimum": 0,
44
- "description": "Required exit code. Default 0 if omitted.",
45
- "default": 0
46
- },
47
- "timeout_sec": {
48
- "type": "integer",
49
- "minimum": 1,
50
- "maximum": 600,
51
- "description": "Per-command timeout in seconds. Default 60 if omitted.",
52
- "default": 60
53
- },
54
- "stdout_contains": {
55
- "type": "array",
56
- "items": { "type": "string", "minLength": 1 },
57
- "description": "Each UTF-8 substring must occur in the concatenated raw stdout and stderr bytes.",
58
- "default": []
59
- },
60
- "stdout_not_contains": {
61
- "type": "array",
62
- "items": { "type": "string", "minLength": 1 },
63
- "description": "No UTF-8 substring may occur in the concatenated raw stdout and stderr bytes.",
64
- "default": []
65
- }
66
- }
67
- },
68
- "default": []
69
- },
70
- "verification_commands": {
71
- "type": "array",
72
- "description": "Each command is executed against the post-BUILD code. Each pass/fail contributes to verify_score. At least one entry is required when the spec has any observable runtime check (CLI, test command, HTTP request).",
73
- "items": {
74
- "type": "object",
75
- "additionalProperties": false,
76
- "required": ["cmd"],
77
- "properties": {
78
- "cmd": {
79
- "type": "string",
80
- "description": "Shell command, executed via `subprocess.run(..., shell=True)` from the build's working directory.",
81
- "minLength": 1
29
+ "description": "Exact argv executed without a shell; argv[0] must be non-empty. Preferred for generated and Windows contracts."
82
30
  },
83
31
  "exit_code": {
84
32
  "type": "integer",
@@ -106,7 +54,7 @@
106
54
  },
107
55
  "contract_refs": {
108
56
  "type": "array",
109
- "description": "For hidden BENCH_FIXTURE_DIR commands, exact substrings from spec.md that this oracle verifies. Hidden oracles may test only visible spec clauses.",
57
+ "description": "Requirements this command verifies. Ideate loop tasks name requirement IDs (R1, R2, ...) from the task Requirements and require at least one.",
110
58
  "items": { "type": "string", "minLength": 1 },
111
59
  "default": []
112
60
  }
@@ -115,7 +63,7 @@
115
63
  },
116
64
  "forbidden_patterns": {
117
65
  "type": "array",
118
- "description": "Regex patterns scanned across diff.patch. Match at severity=disqualifier is a hard-floor fail; match at severity=warning is judge-only critical-finding.",
66
+ "description": "Regex patterns scanned across the task's diff.",
119
67
  "items": {
120
68
  "type": "object",
121
69
  "additionalProperties": false,
@@ -140,14 +88,14 @@
140
88
  "severity": {
141
89
  "type": "string",
142
90
  "enum": ["disqualifier", "warning"],
143
- "description": "disqualifier = hard-floor fail (DQ). warning = judge-visible critical-finding only."
91
+ "description": "disqualifier blocks acceptance; warning is recorded."
144
92
  }
145
93
  }
146
94
  }
147
95
  },
148
96
  "required_files": {
149
97
  "type": "array",
150
- "description": "Files specified by literal paths that must exist after the arm runs; globs are not expanded.",
98
+ "description": "Files specified by literal paths that must exist in the committed source; globs are not expanded.",
151
99
  "items": { "type": "string", "minLength": 1 },
152
100
  "default": []
153
101
  },
@@ -157,21 +105,9 @@
157
105
  "items": { "type": "string", "minLength": 1 },
158
106
  "default": []
159
107
  },
160
- "tier_a_waivers": {
161
- "type": "array",
162
- "description": "Optional fnmatch globs exempted from Tier A scope-oracle path checks when the spec explicitly authorizes those files.",
163
- "items": { "type": "string", "minLength": 1 },
164
- "default": []
165
- },
166
- "spec_output_files": {
167
- "type": "array",
168
- "description": "Files or globs that define the spec-authorized output surface for scope oracles. Touched files outside this set must be reachable from it via static imports or separately waived.",
169
- "items": { "type": "string", "minLength": 1 },
170
- "default": []
171
- },
172
108
  "max_deps_added": {
173
109
  "type": "integer",
174
- "description": "Hard cap on new entries under dependencies/devDependencies in package.json. Exceeds → DQ.",
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.",
175
111
  "minimum": 0,
176
112
  "default": 0
177
113
  },
@@ -179,27 +115,6 @@
179
115
  "type": "boolean",
180
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.",
181
117
  "default": false
182
- },
183
- "required_risk_probe_requirements": {
184
- "type": "array",
185
- "description": "Language-neutral replacement for prose-keyword risk-probe classification: explicit {tag, derived_from} obligations the spec author declares directly. When `--risk-probes` is enabled, risk-probes.jsonl must include a probe whose tags include `tag` and whose derived_from exactly equals `derived_from` for each entry.",
186
- "items": {
187
- "type": "object",
188
- "additionalProperties": false,
189
- "required": ["tag", "derived_from"],
190
- "properties": {
191
- "tag": {
192
- "type": "string",
193
- "description": "One of the risk-probe tags defined in _shared/spec-verify-check.py:RISK_PROBE_TAGS (e.g. ordering_inversion, rollback_state, shape_contract)."
194
- },
195
- "derived_from": {
196
- "type": "string",
197
- "description": "Exact substring of the sibling spec.md's visible verification section that demands this tag.",
198
- "minLength": 1
199
- }
200
- }
201
- },
202
- "default": []
203
118
  }
204
119
  }
205
120
  }