@ikon85/agent-workflow-kit 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/ask-matt/SKILL.md +2 -2
- package/.agents/skills/board-to-waves/SKILL.md +13 -3
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +104 -29
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -3
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +104 -29
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -3
- package/README.md +70 -7
- package/agent-workflow-kit.package.json +99 -43
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +187 -12
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +117 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +14 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- package/src/lib/updateReconcile.mjs +45 -6
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Behavior tests for the pure Tier-2 anchor renderer."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
16
|
+
REPO = SCRIPTS.parent
|
|
17
|
+
SKILLS = (
|
|
18
|
+
REPO / ".claude/skills/to-issues/SKILL.md",
|
|
19
|
+
REPO / ".agents/skills/to-issues/SKILL.md",
|
|
20
|
+
)
|
|
21
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
22
|
+
SPEC = importlib.util.spec_from_file_location(
|
|
23
|
+
"render_anchor", SCRIPTS / "render-anchor.py"
|
|
24
|
+
)
|
|
25
|
+
render_anchor = importlib.util.module_from_spec(SPEC)
|
|
26
|
+
assert SPEC.loader is not None
|
|
27
|
+
SPEC.loader.exec_module(render_anchor)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RenderDocumentsGoldenTest(unittest.TestCase):
|
|
31
|
+
def test_filled_template_and_prd_render_both_documents(self):
|
|
32
|
+
template = "**Welle 12 — Safer publish.**\n\n## Slices\n| K1 |\n"
|
|
33
|
+
prd = (
|
|
34
|
+
"<!-- prd-source-id: safer-publish -->\n"
|
|
35
|
+
"<!-- prd-content-fp: abc123 -->\n"
|
|
36
|
+
"**plan_revision:** r4\n"
|
|
37
|
+
"<!-- prd: awaiting-decomposition -->\n\n"
|
|
38
|
+
"# Safer publish\n\nFull rationale.\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
rendered = render_anchor.render_documents(template, prd)
|
|
42
|
+
|
|
43
|
+
self.assertEqual(rendered.anchor_body, template)
|
|
44
|
+
self.assertEqual(
|
|
45
|
+
rendered.archive_body,
|
|
46
|
+
"📄 Full PRD (archive, r4) — the body carries navigation/decisions only\n\n"
|
|
47
|
+
"# Safer publish\n\nFull rationale.\n",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_only_canonical_markers_in_the_head_block_are_stripped(self):
|
|
51
|
+
template = "Lean anchor\n"
|
|
52
|
+
prd = (
|
|
53
|
+
"<!-- wave-stub-source: safer-publish -->\n"
|
|
54
|
+
"<!-- prd-source-id: safer-publish -->\n"
|
|
55
|
+
"**plan_revision:** r5\n"
|
|
56
|
+
"<!-- prd: awaiting-decomposition -->\n\n"
|
|
57
|
+
"# PRD\n\n"
|
|
58
|
+
"```md\n**plan_revision:** fake\n"
|
|
59
|
+
"<!-- prd-source-id: quoted-example -->\n```\n\n"
|
|
60
|
+
"> **plan_revision:** quoted-fake\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
rendered = render_anchor.render_documents(template, prd)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(
|
|
66
|
+
rendered.archive_body,
|
|
67
|
+
"📄 Full PRD (archive, r5) — the body carries navigation/decisions only\n\n"
|
|
68
|
+
"<!-- wave-stub-source: safer-publish -->\n\n# PRD\n\n"
|
|
69
|
+
"```md\n**plan_revision:** fake\n"
|
|
70
|
+
"<!-- prd-source-id: quoted-example -->\n```\n\n"
|
|
71
|
+
"> **plan_revision:** quoted-fake\n",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def test_duplicate_plan_revisions_fail_closed(self):
|
|
75
|
+
for revisions in (("r5", "r5"), ("r5", "r6")):
|
|
76
|
+
with self.subTest(revisions=revisions):
|
|
77
|
+
prd = (
|
|
78
|
+
f"**plan_revision:** {revisions[0]}\n"
|
|
79
|
+
f"**plan_revision:** {revisions[1]}\n\n# PRD\n"
|
|
80
|
+
)
|
|
81
|
+
with self.assertRaisesRegex(
|
|
82
|
+
ValueError, "exactly one canonical plan_revision"
|
|
83
|
+
):
|
|
84
|
+
render_anchor.render_documents("Lean anchor\n", prd)
|
|
85
|
+
|
|
86
|
+
def test_plan_revision_requires_numeric_canonical_value(self):
|
|
87
|
+
for lookalike in ("rfoo", "r5!"):
|
|
88
|
+
with self.subTest(lookalike=lookalike, valid_revision=True):
|
|
89
|
+
malformed = f"**plan_revision:** {lookalike}"
|
|
90
|
+
prd = f"{malformed}\n**plan_revision:** r5\n\n# PRD\n"
|
|
91
|
+
rendered = render_anchor.render_documents("Lean anchor\n", prd)
|
|
92
|
+
self.assertIn(malformed, rendered.archive_body)
|
|
93
|
+
|
|
94
|
+
with self.subTest(lookalike=lookalike, valid_revision=False):
|
|
95
|
+
with self.assertRaisesRegex(
|
|
96
|
+
ValueError, "exactly one canonical plan_revision"
|
|
97
|
+
):
|
|
98
|
+
render_anchor.render_documents(
|
|
99
|
+
"Lean anchor\n", f"**plan_revision:** {lookalike}\n# PRD\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_malformed_marker_lookalikes_are_preserved(self):
|
|
103
|
+
lookalikes = (
|
|
104
|
+
"<!-- prd-source-id: alpha --> trailing -->",
|
|
105
|
+
"<!-- prd-content-fp: abc > trailing -->",
|
|
106
|
+
"<!-- prd: program > trailing -->",
|
|
107
|
+
)
|
|
108
|
+
for lookalike in lookalikes:
|
|
109
|
+
with self.subTest(lookalike=lookalike):
|
|
110
|
+
prd = (
|
|
111
|
+
f"{lookalike}\n"
|
|
112
|
+
"<!-- prd-source-id: canonical -->\n"
|
|
113
|
+
"<!-- prd-content-fp: abc123 -->\n"
|
|
114
|
+
"**plan_revision:** r5\n"
|
|
115
|
+
"<!-- prd: awaiting-decomposition -->\n\n# PRD\n"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
rendered = render_anchor.render_documents("Lean anchor\n", prd)
|
|
119
|
+
|
|
120
|
+
self.assertIn(lookalike, rendered.archive_body)
|
|
121
|
+
self.assertNotIn(
|
|
122
|
+
"<!-- prd-source-id: canonical -->", rendered.archive_body
|
|
123
|
+
)
|
|
124
|
+
self.assertNotIn(
|
|
125
|
+
"<!-- prd-content-fp: abc123 -->", rendered.archive_body
|
|
126
|
+
)
|
|
127
|
+
self.assertNotIn(
|
|
128
|
+
"<!-- prd: awaiting-decomposition -->", rendered.archive_body
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def test_cli_emits_each_document_without_mutating_inputs(self):
|
|
132
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
133
|
+
root = Path(temporary)
|
|
134
|
+
template = root / "anchor.md"
|
|
135
|
+
prd = root / "prd.md"
|
|
136
|
+
template.write_text("Lean anchor\n", encoding="utf-8")
|
|
137
|
+
prd.write_text(
|
|
138
|
+
"**plan_revision:** r2\n\n# Full PRD\n", encoding="utf-8"
|
|
139
|
+
)
|
|
140
|
+
original = (template.read_bytes(), prd.read_bytes())
|
|
141
|
+
|
|
142
|
+
first = self._run_cli(template, prd, "archive")
|
|
143
|
+
second = self._run_cli(template, prd, "archive")
|
|
144
|
+
anchor = self._run_cli(template, prd, "anchor")
|
|
145
|
+
|
|
146
|
+
self.assertEqual(first.stdout, second.stdout)
|
|
147
|
+
self.assertEqual(anchor.stdout, b"Lean anchor\n")
|
|
148
|
+
self.assertEqual((template.read_bytes(), prd.read_bytes()), original)
|
|
149
|
+
|
|
150
|
+
def test_cli_archive_is_utf8_when_python_io_encoding_is_ascii(self):
|
|
151
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
152
|
+
root = Path(temporary)
|
|
153
|
+
template = root / "anchor.md"
|
|
154
|
+
prd = root / "prd.md"
|
|
155
|
+
template.write_text("Lean anchor\n", encoding="utf-8")
|
|
156
|
+
prd.write_text("**plan_revision:** r2\n\n# PRD\n", encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
result = self._run_cli(
|
|
159
|
+
template,
|
|
160
|
+
prd,
|
|
161
|
+
"archive",
|
|
162
|
+
env={**os.environ, "PYTHONIOENCODING": "ascii"},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.assertTrue(result.stdout.startswith("📄".encode("utf-8")))
|
|
166
|
+
|
|
167
|
+
def _run_cli(
|
|
168
|
+
self, template: Path, prd: Path, document: str, env: dict | None = None
|
|
169
|
+
):
|
|
170
|
+
return subprocess.run(
|
|
171
|
+
[
|
|
172
|
+
sys.executable,
|
|
173
|
+
str(SCRIPTS / "render-anchor.py"),
|
|
174
|
+
"--template",
|
|
175
|
+
str(template),
|
|
176
|
+
"--prd",
|
|
177
|
+
str(prd),
|
|
178
|
+
"--document",
|
|
179
|
+
document,
|
|
180
|
+
],
|
|
181
|
+
check=True,
|
|
182
|
+
capture_output=True,
|
|
183
|
+
env=env,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PromotionStateTableTest(unittest.TestCase):
|
|
188
|
+
EXPECTED_ROWS = {
|
|
189
|
+
"initial": (
|
|
190
|
+
"`S=yes`, `B=no`, `C=0`, `P=absent`",
|
|
191
|
+
"render + write body",
|
|
192
|
+
),
|
|
193
|
+
"body-written": (
|
|
194
|
+
"`S=n/a`, `B=yes`, `C=0`, `P=absent`",
|
|
195
|
+
"reconcile + write archive comment",
|
|
196
|
+
),
|
|
197
|
+
"comment-written": (
|
|
198
|
+
"`S=n/a`, `B=yes`, `C=exact-1`, `P=absent`",
|
|
199
|
+
"promote board state",
|
|
200
|
+
),
|
|
201
|
+
"promoted": (
|
|
202
|
+
"`S=n/a`, `B=yes`, `C=exact-1`, `P=complete`",
|
|
203
|
+
"no-op; continue publish audit",
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
EXPECTED_BOARD_OBSERVATIONS = {
|
|
207
|
+
"ordinary-prestate": "absent",
|
|
208
|
+
"stufe-1p-prestate": "absent",
|
|
209
|
+
"promoted": "complete",
|
|
210
|
+
"cluster-only": "partial",
|
|
211
|
+
"wrong-wave": "partial",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def test_all_four_observable_states_have_one_resume_action(self):
|
|
215
|
+
for path in SKILLS:
|
|
216
|
+
text = path.read_text(encoding="utf-8")
|
|
217
|
+
start = text.index("<!-- promotion-state-table:start -->")
|
|
218
|
+
end = text.index("<!-- promotion-state-table:end -->", start)
|
|
219
|
+
rows = {}
|
|
220
|
+
for line in text[start:end].splitlines():
|
|
221
|
+
if line.startswith("| `"):
|
|
222
|
+
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
|
223
|
+
rows[cells[0].strip("`")] = (cells[1], cells[2])
|
|
224
|
+
self.assertEqual(rows, self.EXPECTED_ROWS, str(path))
|
|
225
|
+
|
|
226
|
+
def test_corrupt_comments_and_partial_board_writes_are_drift(self):
|
|
227
|
+
for path in SKILLS:
|
|
228
|
+
text = path.read_text(encoding="utf-8")
|
|
229
|
+
self.assertIn("`C=wrong-1`", text, str(path))
|
|
230
|
+
self.assertIn("`C=duplicates(<ids>)`", text, str(path))
|
|
231
|
+
self.assertIn("`P=partial`", text, str(path))
|
|
232
|
+
self.assertIn("drift and enters repair", text, str(path))
|
|
233
|
+
|
|
234
|
+
def test_stufe_1p_prestamp_is_not_partial_promotion(self):
|
|
235
|
+
for path in SKILLS:
|
|
236
|
+
text = path.read_text(encoding="utf-8")
|
|
237
|
+
start = text.index("<!-- promotion-board-observation-table:start -->")
|
|
238
|
+
end = text.index("<!-- promotion-board-observation-table:end -->", start)
|
|
239
|
+
rows = {}
|
|
240
|
+
for line in text[start:end].splitlines():
|
|
241
|
+
if line.startswith("| `"):
|
|
242
|
+
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
|
243
|
+
rows[cells[0].strip("`")] = cells[2].strip("`")
|
|
244
|
+
self.assertEqual(rows, self.EXPECTED_BOARD_OBSERVATIONS, str(path))
|
|
245
|
+
|
|
246
|
+
def test_post_initial_body_drift_requires_an_operator_decision(self):
|
|
247
|
+
for path in SKILLS:
|
|
248
|
+
text = path.read_text(encoding="utf-8")
|
|
249
|
+
self.assertIn("outside the valid `initial` tuple", text, str(path))
|
|
250
|
+
self.assertIn("report the body diff", text, str(path))
|
|
251
|
+
self.assertRegex(text, r"explicit\s+operator decision", str(path))
|
|
252
|
+
self.assertNotIn(
|
|
253
|
+
"wrong/missing `B` → rerender and rewrite the body", text, str(path)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def test_stale_source_snapshot_stops_before_body_write(self):
|
|
257
|
+
for path in SKILLS:
|
|
258
|
+
text = path.read_text(encoding="utf-8")
|
|
259
|
+
self.assertRegex(text, r"Pre:\s+`S=yes`, `B=no`", str(path))
|
|
260
|
+
self.assertRegex(text, r"Post:\s+`S=n/a`, `B=yes`", str(path))
|
|
261
|
+
self.assertIn("`S=no`", text, str(path))
|
|
262
|
+
self.assertIn("re-fetch the remote body", text, str(path))
|
|
263
|
+
self.assertRegex(text, r"never\s+write the\s+stale render", str(path))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Contract tests for retro enforcement and safe wrapup workflow chaining."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
SURFACES = (".claude", ".agents")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def skill(surface: str, name: str) -> str:
|
|
13
|
+
return (ROOT / surface / "skills" / name / "SKILL.md").read_text(
|
|
14
|
+
encoding="utf-8"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def contract_text(surface: str, name: str) -> str:
|
|
19
|
+
"""Return prose with Markdown emphasis removed for wording assertions."""
|
|
20
|
+
return re.sub(r"\s+", " ", skill(surface, name).replace("**", ""))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetroEnforcementContract(unittest.TestCase):
|
|
24
|
+
def test_mechanical_check_precedes_target_and_weight_ladder(self):
|
|
25
|
+
for surface in SURFACES:
|
|
26
|
+
with self.subTest(surface=surface):
|
|
27
|
+
text = contract_text(surface, "retro")
|
|
28
|
+
mechanical = text.index("### 3a. Mechanical enforcement check")
|
|
29
|
+
ladder = text.index("### 3b. Determine target + weight")
|
|
30
|
+
self.assertLess(mechanical, ladder)
|
|
31
|
+
|
|
32
|
+
def test_machine_checkable_findings_cannot_stop_at_prose(self):
|
|
33
|
+
required = (
|
|
34
|
+
"Could a machine decide this",
|
|
35
|
+
"implement the enforcement",
|
|
36
|
+
"tracked enforcement issue",
|
|
37
|
+
"explicit trade-off",
|
|
38
|
+
)
|
|
39
|
+
for surface in SURFACES:
|
|
40
|
+
with self.subTest(surface=surface):
|
|
41
|
+
text = contract_text(surface, "retro")
|
|
42
|
+
for phrase in required:
|
|
43
|
+
self.assertIn(phrase, text)
|
|
44
|
+
|
|
45
|
+
def test_upstream_or_own_and_exact_sanitized_approval_survive(self):
|
|
46
|
+
required = (
|
|
47
|
+
"generic or project-specific",
|
|
48
|
+
"recommend `own`",
|
|
49
|
+
"sanitized preview",
|
|
50
|
+
"explicitly approves that exact text",
|
|
51
|
+
"does not need to be the kit maintainer",
|
|
52
|
+
"docs/agents/skills/<skill>.md",
|
|
53
|
+
)
|
|
54
|
+
for surface in SURFACES:
|
|
55
|
+
with self.subTest(surface=surface):
|
|
56
|
+
text = contract_text(surface, "retro")
|
|
57
|
+
for phrase in required:
|
|
58
|
+
self.assertIn(phrase, text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WrapupChainingContract(unittest.TestCase):
|
|
62
|
+
def test_retro_is_model_invocable_on_both_surfaces(self):
|
|
63
|
+
for surface in SURFACES:
|
|
64
|
+
with self.subTest(surface=surface):
|
|
65
|
+
frontmatter = skill(surface, "retro").split("---", 2)[1]
|
|
66
|
+
self.assertIn("disable-model-invocation: false", frontmatter)
|
|
67
|
+
|
|
68
|
+
def test_affirmative_retro_gate_chains_without_landing(self):
|
|
69
|
+
required = (
|
|
70
|
+
"invoke the `retro` skill immediately in this run",
|
|
71
|
+
"land nothing in this run",
|
|
72
|
+
"fresh explicit `/wrapup` invocation",
|
|
73
|
+
)
|
|
74
|
+
for surface in SURFACES:
|
|
75
|
+
with self.subTest(surface=surface):
|
|
76
|
+
text = contract_text(surface, "wrapup")
|
|
77
|
+
for phrase in required:
|
|
78
|
+
self.assertIn(phrase, text)
|
|
79
|
+
|
|
80
|
+
def test_wrapup_does_not_forbid_its_affirmative_retro_chain(self):
|
|
81
|
+
for surface in SURFACES:
|
|
82
|
+
with self.subTest(surface=surface):
|
|
83
|
+
text = contract_text(surface, "wrapup")
|
|
84
|
+
self.assertNotIn("never run by this skill", text.lower())
|
|
85
|
+
|
|
86
|
+
def test_only_model_invocable_non_deploying_workflows_chain(self):
|
|
87
|
+
required = (
|
|
88
|
+
"model-invocable",
|
|
89
|
+
"non-deploying",
|
|
90
|
+
"return control to the user",
|
|
91
|
+
"state the reason",
|
|
92
|
+
)
|
|
93
|
+
for surface in SURFACES:
|
|
94
|
+
with self.subTest(surface=surface):
|
|
95
|
+
text = contract_text(surface, "wrapup")
|
|
96
|
+
for phrase in required:
|
|
97
|
+
self.assertIn(phrase, text)
|
|
98
|
+
|
|
99
|
+
def test_wrapup_remains_manual_and_program_propagation_remains_present(self):
|
|
100
|
+
for surface in SURFACES:
|
|
101
|
+
with self.subTest(surface=surface):
|
|
102
|
+
text = skill(surface, "wrapup")
|
|
103
|
+
self.assertIn("disable-model-invocation: true", text)
|
|
104
|
+
self.assertIn("user's `/wrapup` input IS the explicit", text)
|
|
105
|
+
self.assertIn("program-sync", text)
|
|
106
|
+
self.assertIn("Phasen-Gates", text)
|
|
107
|
+
|
|
108
|
+
def test_new_contract_is_reference_free(self):
|
|
109
|
+
for surface in SURFACES:
|
|
110
|
+
with self.subTest(surface=surface):
|
|
111
|
+
combined = skill(surface, "retro") + skill(surface, "wrapup")
|
|
112
|
+
self.assertNotIn("testreporter", combined.lower())
|
|
113
|
+
self.assertIsNone(re.search(r"#[0-9]{3,}", combined))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Guard the Claude-only cross-model skills' thin codex-exec lifecycle."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
11
|
+
MANIFEST = REPO / ".claude/skills/skill-manifest.json"
|
|
12
|
+
TARGETS = {
|
|
13
|
+
"codex-review": ("review", "read-only"),
|
|
14
|
+
"codex-build": ("build", "workspace-write"),
|
|
15
|
+
"grill-me-codex": ("review", "read-only"),
|
|
16
|
+
"grill-with-docs-codex": ("review", "read-only"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CodexExecSkillLifecycleTests(unittest.TestCase):
|
|
21
|
+
@classmethod
|
|
22
|
+
def setUpClass(cls):
|
|
23
|
+
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))["skills"]
|
|
24
|
+
cls.skills = {}
|
|
25
|
+
for name, expected in TARGETS.items():
|
|
26
|
+
entry = manifest[name]
|
|
27
|
+
if entry["surfaces"] != ["claude"]:
|
|
28
|
+
raise AssertionError(f"{name} must remain Claude-only")
|
|
29
|
+
path = REPO / ".claude/skills" / name / "SKILL.md"
|
|
30
|
+
cls.skills[name] = (expected, path.read_text(encoding="utf-8"))
|
|
31
|
+
|
|
32
|
+
def test_all_four_skills_use_the_thin_wrapper_lifecycle(self):
|
|
33
|
+
self.assertEqual(len(self.skills), 4)
|
|
34
|
+
for name, ((profile, mode), body) in self.skills.items():
|
|
35
|
+
with self.subTest(skill=name):
|
|
36
|
+
self.assertIn(
|
|
37
|
+
f"scripts/codex-exec.sh new --profile {profile} --mode {mode}",
|
|
38
|
+
body,
|
|
39
|
+
)
|
|
40
|
+
self.assertRegex(body, r"scripts/codex-exec\.sh resume [\"']?\$RUN_ID")
|
|
41
|
+
self.assertRegex(body, r"scripts/codex-exec\.sh finalize [\"']?\$RUN_ID")
|
|
42
|
+
self.assertEqual(body.count("scripts/codex-exec.sh handle-failure"), 2)
|
|
43
|
+
self.assertNotRegex(
|
|
44
|
+
body,
|
|
45
|
+
r"scripts/codex-exec\.sh resume[^\n]*--mode",
|
|
46
|
+
"resume must inherit the persisted mode",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def test_new_and_resume_are_thin_ordered_and_valid_bash(self):
|
|
50
|
+
for name, (_, body) in self.skills.items():
|
|
51
|
+
blocks = re.findall(
|
|
52
|
+
r"```bash\n(if ROUND_RESULT=\$\(scripts/codex-exec\.sh (?:new|resume).*?\nfi)\n```",
|
|
53
|
+
body,
|
|
54
|
+
re.DOTALL,
|
|
55
|
+
)
|
|
56
|
+
self.assertEqual(len(blocks), 2, name)
|
|
57
|
+
for block in blocks:
|
|
58
|
+
first_line = block.splitlines()[0]
|
|
59
|
+
is_resume = "scripts/codex-exec.sh resume " in first_line
|
|
60
|
+
success, failure = block.split("\nelse\n", 1)
|
|
61
|
+
self.assertIn("CODEX_REPORT=", success)
|
|
62
|
+
self.assertNotIn("CODEX_REPORT=", failure)
|
|
63
|
+
self.assertIn("scripts/codex-exec.sh handle-failure", failure)
|
|
64
|
+
self.assertIn("|| :", failure)
|
|
65
|
+
self.assertNotIn("json.load", failure)
|
|
66
|
+
handler = failure.index("handle-failure")
|
|
67
|
+
surfaced = failure.index('printf \'%s\\n\' "$FAILURE_RESULT" >&2')
|
|
68
|
+
stopped = failure.index("exit 1")
|
|
69
|
+
self.assertLess(handler, surfaced)
|
|
70
|
+
self.assertLess(surfaced, stopped)
|
|
71
|
+
if is_resume:
|
|
72
|
+
self.assertIn('--run-id "$RUN_ID"', failure)
|
|
73
|
+
self.assertNotIn("\n RUN_ID=", success)
|
|
74
|
+
else:
|
|
75
|
+
self.assertNotIn("--run-id", failure)
|
|
76
|
+
self.assertLess(success.index("RUN_ID="), success.index("CODEX_REPORT="))
|
|
77
|
+
parsed = subprocess.run(
|
|
78
|
+
["bash", "-n"],
|
|
79
|
+
input=block,
|
|
80
|
+
text=True,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
self.assertEqual(parsed.returncode, 0, parsed.stderr)
|
|
85
|
+
|
|
86
|
+
def test_all_skills_abort_known_run_on_orchestration_cancellation(self):
|
|
87
|
+
for name, (_, body) in self.skills.items():
|
|
88
|
+
with self.subTest(skill=name):
|
|
89
|
+
cancellation = re.search(
|
|
90
|
+
r"[Cc]ancellation or a decision to stop (?:orchestration|delegation)"
|
|
91
|
+
r".*?scripts/codex-exec\.sh abort \"\$RUN_ID\"",
|
|
92
|
+
body,
|
|
93
|
+
re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
self.assertIsNotNone(cancellation)
|
|
96
|
+
self.assertRegex(body, r"scripts/codex-exec\.sh finalize [\"']?\$RUN_ID")
|
|
97
|
+
|
|
98
|
+
build = self.skills["codex-build"][1]
|
|
99
|
+
gate = build.split("## Step 5 — Human gate", 1)[1].split("## Hard rules", 1)[0]
|
|
100
|
+
self.assertLess(
|
|
101
|
+
gate.index("Rejected with another requested fix"),
|
|
102
|
+
gate.index("Cancellation or a decision to stop delegation"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def test_no_skill_reimplements_codex_process_or_failure_mechanics(self):
|
|
106
|
+
forbidden = {
|
|
107
|
+
"copied round function": re.compile(r"\brun_codex_round\b"),
|
|
108
|
+
"copied status parser": re.compile(r"\bROUND_STATUS\b|\.get\(\"status\""),
|
|
109
|
+
"copied run-id cleanup": re.compile(r"\bFAILED_RUN_ID\b"),
|
|
110
|
+
"raw codex exec": re.compile(r"(?m)^\s*codex\s+exec\b"),
|
|
111
|
+
"manual liveness sleep": re.compile(r"\bsleep\s+90\b"),
|
|
112
|
+
"manual sandbox override": re.compile(r"\bsandbox_mode\s*="),
|
|
113
|
+
"manual process probe": re.compile(r"\bkill\s+-0\b"),
|
|
114
|
+
"manual codex pid": re.compile(r"\bCODEX_PID\b"),
|
|
115
|
+
}
|
|
116
|
+
for name, (_, body) in self.skills.items():
|
|
117
|
+
for label, pattern in forbidden.items():
|
|
118
|
+
with self.subTest(skill=name, forbidden=label):
|
|
119
|
+
self.assertIsNone(pattern.search(body))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract tests for existing-test-first TDD without weakening RED-first."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
SURFACES = (
|
|
11
|
+
ROOT / ".claude" / "skills" / "tdd" / "SKILL.md",
|
|
12
|
+
ROOT / ".agents" / "skills" / "tdd" / "SKILL.md",
|
|
13
|
+
)
|
|
14
|
+
DECISIONS = ("REUSE", "EXTEND", "REPLACE", "NEW", "RETIRE", "NO-NEW-TEST")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TddContractTest(unittest.TestCase):
|
|
18
|
+
def texts(self):
|
|
19
|
+
return {path: path.read_text(encoding="utf-8") for path in SURFACES}
|
|
20
|
+
|
|
21
|
+
def test_each_behavior_gets_exactly_one_existing_test_first_decision(self):
|
|
22
|
+
for path, text in self.texts().items():
|
|
23
|
+
with self.subTest(surface=path.relative_to(ROOT)):
|
|
24
|
+
self.assertIn("### 2. Existing-Test-First Decision", text)
|
|
25
|
+
self.assertIn("For each planned behavior, record exactly one", text)
|
|
26
|
+
for decision in DECISIONS:
|
|
27
|
+
self.assertEqual(
|
|
28
|
+
len(re.findall(rf"^- \*\*{re.escape(decision)}\*\*", text, re.MULTILINE)),
|
|
29
|
+
1,
|
|
30
|
+
f"{decision} must have exactly one canonical definition",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test_new_files_require_rejecting_the_nearest_owner_with_a_reason(self):
|
|
34
|
+
for path, text in self.texts().items():
|
|
35
|
+
with self.subTest(surface=path.relative_to(ROOT)):
|
|
36
|
+
self.assertIn("nearest existing owner", text)
|
|
37
|
+
self.assertIn("rejected with a reason", text)
|
|
38
|
+
|
|
39
|
+
def test_red_first_is_unconditional_for_executable_behavior_and_bug_fixes(self):
|
|
40
|
+
for path, text in self.texts().items():
|
|
41
|
+
with self.subTest(surface=path.relative_to(ROOT)):
|
|
42
|
+
self.assertIn(
|
|
43
|
+
"Every executable new behavior and bug fix must begin with a failing assertion",
|
|
44
|
+
text,
|
|
45
|
+
)
|
|
46
|
+
self.assertIn("REUSE and NO-NEW-TEST never bypass this RED-first invariant", text)
|
|
47
|
+
self.assertRegex(
|
|
48
|
+
text,
|
|
49
|
+
r"an already-green\s+assertion does not prove the requested change",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_retirement_keeps_negative_tests_only_for_durable_absence(self):
|
|
53
|
+
for path, text in self.texts().items():
|
|
54
|
+
with self.subTest(surface=path.relative_to(ROOT)):
|
|
55
|
+
self.assertIn("delete the tests that specified the retired behavior", text)
|
|
56
|
+
self.assertIn("only when absence itself is a durable", text)
|
|
57
|
+
|
|
58
|
+
def test_worked_matrix_and_counted_handoff_cover_all_decisions(self):
|
|
59
|
+
for path, text in self.texts().items():
|
|
60
|
+
with self.subTest(surface=path.relative_to(ROOT)):
|
|
61
|
+
matrix = re.search(
|
|
62
|
+
r"### Worked decision matrix\n(?P<body>.*?)(?=\n### |\n## )",
|
|
63
|
+
text,
|
|
64
|
+
re.DOTALL,
|
|
65
|
+
)
|
|
66
|
+
self.assertIsNotNone(matrix)
|
|
67
|
+
body = matrix.group("body")
|
|
68
|
+
for decision in DECISIONS:
|
|
69
|
+
self.assertRegex(body, rf"\|\s*{re.escape(decision)}\s*\|")
|
|
70
|
+
self.assertIn("list every behavior and its one decision", text)
|
|
71
|
+
self.assertIn(
|
|
72
|
+
"Reused X · Extended Y · New Z · Replaced/retired W",
|
|
73
|
+
text,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|
package/src/cli.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { init } from './commands/init.mjs';
|
|
|
6
6
|
import { update } from './commands/update.mjs';
|
|
7
7
|
import { diff } from './commands/diff.mjs';
|
|
8
8
|
import { uninstall } from './commands/uninstall.mjs';
|
|
9
|
+
import { setOwnership } from './commands/own.mjs';
|
|
10
|
+
import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
|
|
9
11
|
import { createCommandAdapter } from '../scripts/release-state.mjs';
|
|
10
12
|
import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
|
|
11
13
|
|
|
@@ -21,6 +23,7 @@ const args = process.argv.slice(2);
|
|
|
21
23
|
const cmd = args[0];
|
|
22
24
|
const force = args.includes('--force');
|
|
23
25
|
const yes = args.includes('--yes') || args.includes('-y');
|
|
26
|
+
const owned = args.includes('--owned');
|
|
24
27
|
|
|
25
28
|
p.intro('agent-workflow-kit');
|
|
26
29
|
|
|
@@ -35,15 +38,11 @@ try {
|
|
|
35
38
|
p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
|
|
36
39
|
'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
|
|
37
40
|
} else if (cmd === 'diff') {
|
|
38
|
-
const r = await diff({ kitRoot: KIT_ROOT, consumerRoot });
|
|
41
|
+
const r = await diff({ kitRoot: KIT_ROOT, consumerRoot, owned });
|
|
39
42
|
printPlan(r);
|
|
40
43
|
p.outro('Dry run — nothing written. Run `update` to apply.');
|
|
41
44
|
} else if (cmd === 'update') {
|
|
42
|
-
const decide =
|
|
43
|
-
if (yes) return true;
|
|
44
|
-
const ok = await p.confirm({ message: `Upstream removed ${path} — delete it locally?` });
|
|
45
|
-
return ok === true;
|
|
46
|
-
};
|
|
45
|
+
const decide = (action, path) => decideUpdate(action, path, yes);
|
|
47
46
|
const releaseIdentities = await readUpdateRelease();
|
|
48
47
|
const r = await update({
|
|
49
48
|
kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
|
|
@@ -65,8 +64,13 @@ try {
|
|
|
65
64
|
if (!ok) { p.cancel('Aborted.'); process.exit(0); }
|
|
66
65
|
const r = await uninstall({ consumerRoot });
|
|
67
66
|
p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
|
|
67
|
+
} else if (cmd === 'own' || cmd === 'disown') {
|
|
68
|
+
if (!args[1]) throw new Error(`Usage: agent-workflow-kit ${cmd} <path>`);
|
|
69
|
+
const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
|
|
70
|
+
await setOwnership({ consumerRoot, path: args[1], origin });
|
|
71
|
+
p.outro(`${args[1]} is now ${origin}-owned`);
|
|
68
72
|
} else {
|
|
69
|
-
p.note('Usage: agent-workflow-kit <init|update|diff|uninstall> [--force] [--yes]');
|
|
73
|
+
p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown> [<path>] [--force] [--yes] [--owned]');
|
|
70
74
|
p.outro('');
|
|
71
75
|
}
|
|
72
76
|
} catch (err) {
|
|
@@ -76,12 +80,44 @@ try {
|
|
|
76
80
|
|
|
77
81
|
function printPlan(r) {
|
|
78
82
|
const lines = [];
|
|
79
|
-
for (const k of [
|
|
83
|
+
for (const k of [
|
|
84
|
+
'added', 'updated', 'userModified', 'consumerOwned', 'unchanged',
|
|
85
|
+
'deleted', 'keptDeleted', 'collisions',
|
|
86
|
+
])
|
|
80
87
|
if (r[k]?.length) lines.push(`${k}: ${r[k].length}`);
|
|
81
88
|
if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
|
|
89
|
+
for (const owned of r.ownedDiffs ?? []) {
|
|
90
|
+
lines.push(`${owned.state} ${owned.path}`);
|
|
91
|
+
if (owned.binary) {
|
|
92
|
+
lines.push(` local: ${owned.local.size} bytes sha256:${owned.local.sha256}`);
|
|
93
|
+
lines.push(` upstream: ${owned.upstream.size} bytes sha256:${owned.upstream.sha256}`);
|
|
94
|
+
} else if (owned.diff) {
|
|
95
|
+
lines.push(owned.diff);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
82
98
|
p.note(lines.join('\n') || 'no changes', 'plan');
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
async function decideUpdate(action, path, yes) {
|
|
102
|
+
if (action === 'delete') {
|
|
103
|
+
if (yes) return true;
|
|
104
|
+
return (await p.confirm({ message: `Upstream removed ${path} — delete it locally?` })) === true;
|
|
105
|
+
}
|
|
106
|
+
if (action === 'collision') {
|
|
107
|
+
if (yes) return 'replace';
|
|
108
|
+
const choice = await p.select({
|
|
109
|
+
message: `${path} already exists but is not tracked. Choose its ownership:`,
|
|
110
|
+
options: [
|
|
111
|
+
{ value: 'keep-as-owned', label: 'Keep existing file as consumer-owned' },
|
|
112
|
+
{ value: 'replace', label: 'Replace with kit file' },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
if (p.isCancel(choice)) throw new Error(`collision decision cancelled for ${path}`);
|
|
116
|
+
return choice;
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`unknown update decision action: ${action}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
async function readUpdateRelease() {
|
|
86
122
|
const adapter = await createCommandAdapter({
|
|
87
123
|
repoRoot: KIT_ROOT,
|