@ikon85/agent-workflow-kit 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/kit-update/SKILL.md +13 -0
- package/.agents/skills/setup-workflow/SKILL.md +39 -1
- package/.agents/skills/setup-workflow/census.md +83 -0
- package/.agents/skills/to-prd/SKILL.md +18 -0
- package/.claude/hooks/drift-guard.py +471 -10
- package/.claude/logs/drift-guard.log +3 -0
- package/.claude/skills/kit-update/SKILL.md +13 -0
- package/.claude/skills/setup-workflow/SKILL.md +39 -1
- package/.claude/skills/setup-workflow/census.md +83 -0
- package/.claude/skills/to-prd/SKILL.md +18 -0
- package/README.md +15 -0
- package/agent-workflow-kit.package.json +26 -8
- package/package.json +1 -1
- package/scripts/test_census_backstop.py +626 -0
- package/scripts/test_skill_setup_workflow_seeds.py +316 -0
|
@@ -17,6 +17,9 @@ Run: python3 scripts/test_skill_setup_workflow_seeds.py
|
|
|
17
17
|
"""
|
|
18
18
|
import json
|
|
19
19
|
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
20
23
|
import unittest
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
|
|
@@ -55,6 +58,135 @@ def update_workflow_action(
|
|
|
55
58
|
return "create" if prerequisites and pull_requests_allowed else "skip"
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
def load_census_setup_effects():
|
|
62
|
+
"""Parse the shipped seed's executable census transition contract."""
|
|
63
|
+
seed = (SKILL / "census.md").read_text(encoding="utf-8")
|
|
64
|
+
match = re.search(
|
|
65
|
+
r"```json census-setup-effects\n(.*?)\n```", seed, re.DOTALL,
|
|
66
|
+
)
|
|
67
|
+
if not match:
|
|
68
|
+
raise AssertionError("census seed has no structured setup-effects contract")
|
|
69
|
+
rows = json.loads(match.group(1))
|
|
70
|
+
return {row["state"]: row for row in rows}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_census_state(**inputs):
|
|
74
|
+
"""Ask the shipped #49 API for a state; do not duplicate its state machine."""
|
|
75
|
+
script = (
|
|
76
|
+
"import { resolveCensusState } from './scripts/census/index.mjs';"
|
|
77
|
+
"process.stdout.write(resolveCensusState(JSON.parse(process.argv[1])));"
|
|
78
|
+
)
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["node", "--input-type=module", "-e", script, json.dumps(inputs)],
|
|
81
|
+
cwd=REPO, check=True, capture_output=True, text=True,
|
|
82
|
+
)
|
|
83
|
+
return result.stdout
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_shipped_test(relative_path):
|
|
87
|
+
"""Run a real shipped integration test as proof, without marker files."""
|
|
88
|
+
subprocess.run(
|
|
89
|
+
["node", "--test", relative_path], cwd=REPO, check=True,
|
|
90
|
+
capture_output=True, text=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apply_census_setup_effect(root, effect, writes):
|
|
95
|
+
"""Reconcile setup-owned files and delegate engine work to #49/#50."""
|
|
96
|
+
paths = {
|
|
97
|
+
"choice": root / "docs/agents/census.md",
|
|
98
|
+
"profile": root / ".census/profile.json",
|
|
99
|
+
"active": root / ".census/active.json",
|
|
100
|
+
"scanner": root / ".census/local-scanner.mjs",
|
|
101
|
+
"scanner-test": root / ".census/local-scanner.test.mjs",
|
|
102
|
+
"hook": root / ".git/hooks/census-check",
|
|
103
|
+
"gate": root / ".github/workflows/census-check.yml",
|
|
104
|
+
}
|
|
105
|
+
trace = []
|
|
106
|
+
states = []
|
|
107
|
+
|
|
108
|
+
def write_if_changed(path, content):
|
|
109
|
+
data = content.encode("utf-8")
|
|
110
|
+
if path.exists() and path.read_bytes() == data:
|
|
111
|
+
return
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
path.write_bytes(data)
|
|
114
|
+
writes.append(path.relative_to(root).as_posix())
|
|
115
|
+
|
|
116
|
+
def replace_if_changed(path, content):
|
|
117
|
+
data = content.encode("utf-8")
|
|
118
|
+
if path.exists() and path.read_bytes() == data:
|
|
119
|
+
return
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
staged = path.with_name(path.name + ".setup-workflow.tmp")
|
|
122
|
+
staged.write_bytes(data)
|
|
123
|
+
staged.replace(path)
|
|
124
|
+
writes.append(path.relative_to(root).as_posix())
|
|
125
|
+
|
|
126
|
+
def remove_if_present(path, operation):
|
|
127
|
+
if path.exists():
|
|
128
|
+
path.unlink()
|
|
129
|
+
writes.append(path.relative_to(root).as_posix())
|
|
130
|
+
trace.append(f"{operation}:removed")
|
|
131
|
+
else:
|
|
132
|
+
trace.append(f"{operation}:no-op")
|
|
133
|
+
|
|
134
|
+
for operation in effect["operations"]:
|
|
135
|
+
if operation == "reconcile-choice-doc":
|
|
136
|
+
choice = effect["choice"]
|
|
137
|
+
if not paths["choice"].exists():
|
|
138
|
+
content = (
|
|
139
|
+
"<!-- setup-workflow: state=filled -->\n"
|
|
140
|
+
f"<!-- census: choice={choice} -->\n\n"
|
|
141
|
+
+ (SKILL / "census.md").read_text(encoding="utf-8")
|
|
142
|
+
)
|
|
143
|
+
write_if_changed(paths["choice"], content)
|
|
144
|
+
trace.append("reconcile-choice-doc")
|
|
145
|
+
elif operation == "adopt-choice-doc":
|
|
146
|
+
if not paths["choice"].exists():
|
|
147
|
+
raise AssertionError("existing census needs a documented census path")
|
|
148
|
+
trace.append("adopt-choice-doc")
|
|
149
|
+
elif operation == "reconcile-minimal-profile":
|
|
150
|
+
if not paths["profile"].exists():
|
|
151
|
+
write_if_changed(paths["profile"], json.dumps({
|
|
152
|
+
"schemaVersion": 1, "enabled": True,
|
|
153
|
+
"decisions": [], "localScanners": [], "overrides": [],
|
|
154
|
+
}, sort_keys=True) + "\n")
|
|
155
|
+
trace.append("reconcile-minimal-profile")
|
|
156
|
+
elif operation == "remove-kit-hook":
|
|
157
|
+
remove_if_present(paths["hook"], operation)
|
|
158
|
+
elif operation == "remove-kit-gate":
|
|
159
|
+
remove_if_present(paths["gate"], operation)
|
|
160
|
+
elif operation == "update-profile-disabled":
|
|
161
|
+
profile = json.loads(paths["profile"].read_text(encoding="utf-8"))
|
|
162
|
+
profile["enabled"] = False
|
|
163
|
+
replace_if_changed(paths["profile"], json.dumps(profile, sort_keys=True) + "\n")
|
|
164
|
+
trace.append("update-profile-disabled")
|
|
165
|
+
elif operation == "derive-state":
|
|
166
|
+
enabled = False
|
|
167
|
+
if paths["profile"].exists():
|
|
168
|
+
enabled = json.loads(paths["profile"].read_text(encoding="utf-8"))["enabled"]
|
|
169
|
+
states.append(resolve_census_state(
|
|
170
|
+
enabled=enabled, hasActive=paths["active"].exists(),
|
|
171
|
+
))
|
|
172
|
+
trace.append("derive-state")
|
|
173
|
+
elif operation == "run-foundation-self-test":
|
|
174
|
+
run_shipped_test("scripts/census/state.test.mjs")
|
|
175
|
+
trace.append("run-foundation-self-test")
|
|
176
|
+
elif operation == "delegate-census-update":
|
|
177
|
+
skill = (REPO / ".claude/skills/census-update/SKILL.md").read_text(encoding="utf-8")
|
|
178
|
+
for token in ("activateCensus", "resolveCensusState", "scripts/census/index.mjs"):
|
|
179
|
+
if token not in skill:
|
|
180
|
+
raise AssertionError(f"census-update contract missing {token}")
|
|
181
|
+
trace.append("delegate-census-update")
|
|
182
|
+
elif operation == "run-census-update-contract":
|
|
183
|
+
run_shipped_test("scripts/test_census_update_contract.test.mjs")
|
|
184
|
+
trace.append("run-census-update-contract")
|
|
185
|
+
else:
|
|
186
|
+
raise AssertionError(f"unknown census setup operation: {operation}")
|
|
187
|
+
return paths, trace, states
|
|
188
|
+
|
|
189
|
+
|
|
58
190
|
class IdempotencyRule(unittest.TestCase):
|
|
59
191
|
CASES = [
|
|
60
192
|
# (first_line, is_empty, expected)
|
|
@@ -81,6 +213,190 @@ class IdempotencyRule(unittest.TestCase):
|
|
|
81
213
|
|
|
82
214
|
|
|
83
215
|
class SeedTemplatesValid(unittest.TestCase):
|
|
216
|
+
def test_census_effect_contract_executes_every_transition_and_repeats_without_writes(self):
|
|
217
|
+
effects = load_census_setup_effects()
|
|
218
|
+
self.assertEqual(
|
|
219
|
+
set(effects),
|
|
220
|
+
{"missing", "yes", "later", "no", "existing", "explicit-enable", "disable"},
|
|
221
|
+
)
|
|
222
|
+
self.assertEqual(effects["explicit-enable"]["actor"], "census-update")
|
|
223
|
+
|
|
224
|
+
for state, effect in effects.items():
|
|
225
|
+
with self.subTest(state=state), tempfile.TemporaryDirectory() as tmp:
|
|
226
|
+
root = Path(tmp)
|
|
227
|
+
initial = {}
|
|
228
|
+
if state in ("existing", "explicit-enable", "disable"):
|
|
229
|
+
initial = {
|
|
230
|
+
"choice": (
|
|
231
|
+
b'<!-- setup-workflow: state=filled -->\n'
|
|
232
|
+
b'<!-- census: choice=yes -->\n\nconsumer notes stay exact\n'
|
|
233
|
+
),
|
|
234
|
+
"profile": (
|
|
235
|
+
b'{"enabled":true,"consumerKey":"keep exactly",'
|
|
236
|
+
b'"decisions":["keep"],"overrides":[{"keep":true}]}\n'
|
|
237
|
+
),
|
|
238
|
+
"active": b'{"consumerSnapshot":"keep exactly"}\n',
|
|
239
|
+
"scanner": b"export const consumerScanner = true;\n",
|
|
240
|
+
"scanner-test": b"consumer scanner test\n",
|
|
241
|
+
}
|
|
242
|
+
if state == "explicit-enable":
|
|
243
|
+
initial["profile"] = b'{"enabled":false,"consumerKey":"keep"}\n'
|
|
244
|
+
initial.pop("active")
|
|
245
|
+
for name, data in initial.items():
|
|
246
|
+
path = {
|
|
247
|
+
"choice": root / "docs/agents/census.md",
|
|
248
|
+
"profile": root / ".census/profile.json",
|
|
249
|
+
"active": root / ".census/active.json",
|
|
250
|
+
"scanner": root / ".census/local-scanner.mjs",
|
|
251
|
+
"scanner-test": root / ".census/local-scanner.test.mjs",
|
|
252
|
+
}[name]
|
|
253
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
path.write_bytes(data)
|
|
255
|
+
if state in ("existing", "disable"):
|
|
256
|
+
for path in (
|
|
257
|
+
root / ".git/hooks/census-check",
|
|
258
|
+
root / ".github/workflows/census-check.yml",
|
|
259
|
+
):
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
path.write_text("kit-owned census enforcement\n", encoding="utf-8")
|
|
262
|
+
|
|
263
|
+
writes = []
|
|
264
|
+
paths, trace, states = apply_census_setup_effect(root, effect, writes)
|
|
265
|
+
|
|
266
|
+
if state == "missing":
|
|
267
|
+
self.assertEqual(writes, [])
|
|
268
|
+
if state == "yes":
|
|
269
|
+
choice = paths["choice"].read_text(encoding="utf-8")
|
|
270
|
+
self.assertTrue(choice.startswith(
|
|
271
|
+
"<!-- setup-workflow: state=filled -->\n"
|
|
272
|
+
"<!-- census: choice=yes -->\n"
|
|
273
|
+
))
|
|
274
|
+
profile = json.loads(paths["profile"].read_text(encoding="utf-8"))
|
|
275
|
+
self.assertEqual(profile, {
|
|
276
|
+
"schemaVersion": 1, "enabled": True,
|
|
277
|
+
"decisions": [], "localScanners": [], "overrides": [],
|
|
278
|
+
})
|
|
279
|
+
self.assertEqual(states, ["bootstrap"])
|
|
280
|
+
self.assertIn("run-foundation-self-test", trace)
|
|
281
|
+
if state in ("yes", "later", "no"):
|
|
282
|
+
self.assertFalse(paths["active"].exists())
|
|
283
|
+
self.assertFalse(paths["hook"].exists())
|
|
284
|
+
self.assertFalse(paths["gate"].exists())
|
|
285
|
+
if state in ("later", "no"):
|
|
286
|
+
self.assertFalse(paths["profile"].exists())
|
|
287
|
+
self.assertIn(
|
|
288
|
+
f"<!-- census: choice={state} -->",
|
|
289
|
+
paths["choice"].read_text(encoding="utf-8"),
|
|
290
|
+
)
|
|
291
|
+
if state == "existing":
|
|
292
|
+
self.assertEqual(writes, [])
|
|
293
|
+
for name, expected in initial.items():
|
|
294
|
+
self.assertEqual(paths[name].read_bytes(), expected)
|
|
295
|
+
if state == "explicit-enable":
|
|
296
|
+
self.assertEqual(writes, [])
|
|
297
|
+
for name, expected in initial.items():
|
|
298
|
+
self.assertEqual(paths[name].read_bytes(), expected)
|
|
299
|
+
self.assertEqual(
|
|
300
|
+
trace,
|
|
301
|
+
["delegate-census-update", "run-census-update-contract"],
|
|
302
|
+
)
|
|
303
|
+
if state == "disable":
|
|
304
|
+
profile = json.loads(paths["profile"].read_text(encoding="utf-8"))
|
|
305
|
+
self.assertFalse(profile["enabled"])
|
|
306
|
+
self.assertEqual(profile["consumerKey"], "keep exactly")
|
|
307
|
+
self.assertEqual(profile["decisions"], ["keep"])
|
|
308
|
+
self.assertEqual(profile["overrides"], [{"keep": True}])
|
|
309
|
+
for name in ("choice", "active", "scanner", "scanner-test"):
|
|
310
|
+
self.assertEqual(paths[name].read_bytes(), initial[name])
|
|
311
|
+
self.assertFalse(paths["hook"].exists())
|
|
312
|
+
self.assertFalse(paths["gate"].exists())
|
|
313
|
+
self.assertEqual(states, ["disabled"])
|
|
314
|
+
self.assertLess(
|
|
315
|
+
next(i for i, value in enumerate(trace) if value.startswith("remove-kit-hook:")),
|
|
316
|
+
trace.index("update-profile-disabled"),
|
|
317
|
+
)
|
|
318
|
+
self.assertLess(
|
|
319
|
+
next(i for i, value in enumerate(trace) if value.startswith("remove-kit-gate:")),
|
|
320
|
+
trace.index("update-profile-disabled"),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
before = {
|
|
324
|
+
str(path.relative_to(root)): (path.read_bytes(), path.stat().st_mtime_ns)
|
|
325
|
+
for path in root.rglob("*") if path.is_file()
|
|
326
|
+
}
|
|
327
|
+
time.sleep(0.01)
|
|
328
|
+
repeat_writes = []
|
|
329
|
+
apply_census_setup_effect(root, effect, repeat_writes)
|
|
330
|
+
after = {
|
|
331
|
+
str(path.relative_to(root)): (path.read_bytes(), path.stat().st_mtime_ns)
|
|
332
|
+
for path in root.rglob("*") if path.is_file()
|
|
333
|
+
}
|
|
334
|
+
self.assertEqual(effect["repeat"], "no-write")
|
|
335
|
+
self.assertEqual(repeat_writes, [])
|
|
336
|
+
self.assertEqual(after, before)
|
|
337
|
+
|
|
338
|
+
def test_census_seed_covers_the_complete_setup_state_matrix(self):
|
|
339
|
+
seed = (SKILL / "census.md").read_text(encoding="utf-8")
|
|
340
|
+
rows = {}
|
|
341
|
+
for line in seed.splitlines():
|
|
342
|
+
match = re.match(r"^\| `([^`]+)` \| (.+) \| (.+) \|$", line)
|
|
343
|
+
if match:
|
|
344
|
+
rows[match.group(1)] = f"{match.group(2)} {match.group(3)}"
|
|
345
|
+
for state in (
|
|
346
|
+
"missing", "yes", "later", "no", "existing",
|
|
347
|
+
"explicit-enable", "disable",
|
|
348
|
+
):
|
|
349
|
+
self.assertIn(state, rows)
|
|
350
|
+
|
|
351
|
+
expected_terms = {
|
|
352
|
+
"missing": ("ask `yes / later / no`", "do not infer", "no hook or gate"),
|
|
353
|
+
"yes": ("enabled: true", "active snapshot absent", "self-test", "bootstrap"),
|
|
354
|
+
"later": ("deferral", "setup rerun is a no-op", "`census-update`"),
|
|
355
|
+
"no": ("opt-out", "`disabled`", "do not create census files, hooks, or gates"),
|
|
356
|
+
"existing": ("Adopt", "without replacing", "Preserve every existing byte"),
|
|
357
|
+
"explicit-enable": ("`census-update`", "without rerunning setup", "no write"),
|
|
358
|
+
"disable": ("enabled: false", "remove census hooks/gates", "separately approves"),
|
|
359
|
+
}
|
|
360
|
+
for state, terms in expected_terms.items():
|
|
361
|
+
for term in terms:
|
|
362
|
+
self.assertIn(term, rows[state], f"{state} missing {term!r}")
|
|
363
|
+
|
|
364
|
+
def test_census_yes_is_an_honest_bootstrap_not_activation(self):
|
|
365
|
+
skill = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
|
366
|
+
seed = (SKILL / "census.md").read_text(encoding="utf-8")
|
|
367
|
+
|
|
368
|
+
self.assertIn("optional census choice", skill.split("---", 2)[1])
|
|
369
|
+
self.assertIn("Section A3 — Optional project census", skill)
|
|
370
|
+
self.assertIn("[census.md](./census.md)", skill)
|
|
371
|
+
for token in (
|
|
372
|
+
".census/profile.json", ".census/active.json", "enabled: true",
|
|
373
|
+
"bootstrap", "not yet meaningful", "self-test",
|
|
374
|
+
"Setup itself never calls `activateCensus`",
|
|
375
|
+
):
|
|
376
|
+
self.assertIn(token, seed)
|
|
377
|
+
self.assertIn("must not install pre-commit, pre-push, CI, planning, or", seed)
|
|
378
|
+
|
|
379
|
+
def test_census_deferral_adoption_enable_and_disable_are_safe_and_idempotent(self):
|
|
380
|
+
skill = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
|
381
|
+
seed = (SKILL / "census.md").read_text(encoding="utf-8")
|
|
382
|
+
|
|
383
|
+
for token in (
|
|
384
|
+
"retryable deferral", "explicit opt-out", "explicit `census-update`",
|
|
385
|
+
"consumer-owned", "separate deletion approval", "no write",
|
|
386
|
+
):
|
|
387
|
+
self.assertIn(token, seed)
|
|
388
|
+
self.assertIn("Repeated runs are no-ops", skill)
|
|
389
|
+
self.assertIn("setup never deletes consumer-owned files", seed.lower())
|
|
390
|
+
|
|
391
|
+
def test_census_setup_surface_is_fully_mirrored_for_codex(self):
|
|
392
|
+
codex = REPO / ".agents/skills/setup-workflow"
|
|
393
|
+
for relative in ("SKILL.md", "census.md"):
|
|
394
|
+
self.assertEqual(
|
|
395
|
+
(SKILL / relative).read_text(encoding="utf-8"),
|
|
396
|
+
(codex / relative).read_text(encoding="utf-8"),
|
|
397
|
+
f"setup-workflow mirror drift in {relative}",
|
|
398
|
+
)
|
|
399
|
+
|
|
84
400
|
def test_update_workflow_provider_and_choice_fixtures(self):
|
|
85
401
|
fixtures = [
|
|
86
402
|
("github", "enable", False, True, True, "create"),
|