formwork-kit 0.1.0__py3-none-any.whl

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 (137) hide show
  1. formwork_cli/__init__.py +326 -0
  2. formwork_cli/kit/COSTS.md +111 -0
  3. formwork_cli/kit/adapters/claude-code/README.md +53 -0
  4. formwork_cli/kit/adapters/claude-code/settings.json +46 -0
  5. formwork_cli/kit/adapters/codex/README.md +43 -0
  6. formwork_cli/kit/adapters/cursor/README.md +45 -0
  7. formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
  8. formwork_cli/kit/build +410 -0
  9. formwork_cli/kit/check/checks/config-shape +123 -0
  10. formwork_cli/kit/check/checks/decision-ids +159 -0
  11. formwork_cli/kit/check/checks/doc-links +133 -0
  12. formwork_cli/kit/check/checks/generated-current +74 -0
  13. formwork_cli/kit/check/checks/guard-wired +139 -0
  14. formwork_cli/kit/check/checks/kit-integrity +199 -0
  15. formwork_cli/kit/check/checks/predictions-first +127 -0
  16. formwork_cli/kit/check/checks/role-shape +172 -0
  17. formwork_cli/kit/check/checks/rule-labels +135 -0
  18. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
  19. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
  20. formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
  21. formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
  22. formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
  23. formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
  24. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
  25. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
  26. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
  27. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
  28. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
  29. formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
  30. formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
  31. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
  32. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
  33. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
  34. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
  35. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
  36. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
  37. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
  38. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
  39. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
  40. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
  41. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
  42. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
  43. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
  44. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
  45. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
  46. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
  47. formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
  48. formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
  49. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
  50. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
  51. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
  52. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
  53. formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
  54. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
  55. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
  56. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
  57. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
  58. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
  59. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
  60. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
  61. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
  62. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
  63. formwork_cli/kit/check/fixtures/predictions-first/must-pass/a-round-that-has-not-argued-yet/docs/rounds/0006-not-started/round.md +3 -0
  64. formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
  65. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
  66. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
  67. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
  68. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
  69. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
  70. formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
  71. formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
  72. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
  73. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
  74. formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
  75. formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
  76. formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
  77. formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
  78. formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
  79. formwork_cli/kit/check/run +340 -0
  80. formwork_cli/kit/check/test_gate.py +222 -0
  81. formwork_cli/kit/first-run.md +204 -0
  82. formwork_cli/kit/fw +121 -0
  83. formwork_cli/kit/glossary.md +160 -0
  84. formwork_cli/kit/guard/git-boundary +627 -0
  85. formwork_cli/kit/guard/protected-files +748 -0
  86. formwork_cli/kit/guard/quality-gate +260 -0
  87. formwork_cli/kit/guard/test_boundary.py +273 -0
  88. formwork_cli/kit/guard/test_protection.py +254 -0
  89. formwork_cli/kit/guard/test_quality_gate.py +156 -0
  90. formwork_cli/kit/install +395 -0
  91. formwork_cli/kit/limits.md +141 -0
  92. formwork_cli/kit/loop.md +82 -0
  93. formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
  94. formwork_cli/kit/roles/TEMPLATE.md +26 -0
  95. formwork_cli/kit/roles/method/architect.md +269 -0
  96. formwork_cli/kit/roles/method/challenger.md +243 -0
  97. formwork_cli/kit/roles/method/lead.md +280 -0
  98. formwork_cli/kit/roles/method/record-keeper.md +206 -0
  99. formwork_cli/kit/roles/method/researcher.md +246 -0
  100. formwork_cli/kit/roles/method/reviewer.md +207 -0
  101. formwork_cli/kit/roles/packs/accessibility.md +236 -0
  102. formwork_cli/kit/roles/packs/ai.md +248 -0
  103. formwork_cli/kit/roles/packs/analyst.md +233 -0
  104. formwork_cli/kit/roles/packs/backend.md +425 -0
  105. formwork_cli/kit/roles/packs/brainstormer.md +190 -0
  106. formwork_cli/kit/roles/packs/data.md +212 -0
  107. formwork_cli/kit/roles/packs/devops.md +203 -0
  108. formwork_cli/kit/roles/packs/frontend.md +224 -0
  109. formwork_cli/kit/roles/packs/integrations.md +215 -0
  110. formwork_cli/kit/roles/packs/legal.md +251 -0
  111. formwork_cli/kit/roles/packs/marketing.md +206 -0
  112. formwork_cli/kit/roles/packs/mobile.md +202 -0
  113. formwork_cli/kit/roles/packs/performance.md +192 -0
  114. formwork_cli/kit/roles/packs/product.md +217 -0
  115. formwork_cli/kit/roles/packs/security.md +267 -0
  116. formwork_cli/kit/roles/packs/sre.md +203 -0
  117. formwork_cli/kit/roles/packs/tester.md +246 -0
  118. formwork_cli/kit/roles/packs/user-researcher.md +218 -0
  119. formwork_cli/kit/roles/packs/ux.md +205 -0
  120. formwork_cli/kit/roles/packs/visual.md +199 -0
  121. formwork_cli/kit/roles/packs/writer.md +198 -0
  122. formwork_cli/kit/round.md +131 -0
  123. formwork_cli/kit/rules/core.md +195 -0
  124. formwork_cli/kit/rules/full.md +493 -0
  125. formwork_cli/kit/templates/brief.md +68 -0
  126. formwork_cli/kit/templates/decision.md +93 -0
  127. formwork_cli/kit/templates/predictions.md +54 -0
  128. formwork_cli/kit/templates/report.md +52 -0
  129. formwork_cli/kit/templates/round.md +77 -0
  130. formwork_cli/kit/test_install.py +165 -0
  131. formwork_cli/kit/troubleshooting.md +247 -0
  132. formwork_cli/kit-page/FORMWORK.md +182 -0
  133. formwork_kit-0.1.0.dist-info/METADATA +308 -0
  134. formwork_kit-0.1.0.dist-info/RECORD +137 -0
  135. formwork_kit-0.1.0.dist-info/WHEEL +4 -0
  136. formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
  137. formwork_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """Set this project up to use the kit.
3
+
4
+ formwork install detect the runtime and wire it
5
+ formwork install --runtime cursor say which runtime, rather than guessing
6
+ formwork/install --dry-run say what would change, change nothing
7
+
8
+ Exit status:
9
+ 0 installed, or already installed and nothing to do
10
+ 1 installed as far as it can go, and the rest needs your hand
11
+ 2 could not run
12
+
13
+ WHAT IT ADDS, AND ITS THREE LIMITS
14
+ ----------------------------------
15
+ It adds three things:
16
+
17
+ .formwork.toml which runtime this project uses
18
+ the hook wiring for your runtime so the guards are called at all
19
+ the role files for that runtime generated from formwork/roles/
20
+
21
+ The third one used to be a separate program nobody was told about. A fresh
22
+ install passed, and then the very next command — the one this installer
23
+ prints — reported a red gate. Generating them here is the fix.
24
+
25
+ That is all. It has three limits, and they are the point:
26
+
27
+ * **It never needs a clean working tree.** It does not read git, does not
28
+ look at your changes, and does not care what is uncommitted. Installing a
29
+ tool should not make you stop what you were doing.
30
+ * **It never moves or deletes a file.** Only adds. If it has to change an
31
+ existing file, it writes a copy of the original next to it first.
32
+ * **It never demands a document.** No brief, no decision record, no
33
+ structure imposed on a project that already has one.
34
+
35
+ Running it twice changes nothing the second time.
36
+
37
+ WHY IT SOMETIMES EXITS 1
38
+ ------------------------
39
+ Only one of the four runtimes ships a wiring file in this kit. For the other
40
+ three the wiring is documented by their publishers and **has never been run by
41
+ anybody**, so this installer writes nothing for them. It names the file you
42
+ must write yourself, points at the adapter README that says what goes in it,
43
+ and exits 1 rather than 0.
44
+
45
+ **Their gate stays red until that file exists.** That is the correct answer:
46
+ nothing is guarding yet, and reporting green would be a lie.
47
+
48
+ Exit 1 means: you are not finished. Go and look.
49
+
50
+ Python 3, standard library only.
51
+ """
52
+ import json
53
+ import os
54
+ import re
55
+ import shutil
56
+ import subprocess
57
+ import sys
58
+ import time
59
+
60
+ DONE, PARTIAL, CANNOT_RUN = 0, 1, 2
61
+
62
+ HERE = os.path.dirname(os.path.abspath(__file__))
63
+ ROOT = os.path.dirname(HERE)
64
+ ADAPTERS = os.path.join(HERE, "adapters")
65
+
66
+ # Where each runtime keeps its hooks. Must agree with the guard-wired check,
67
+ # which fails the gate when a runtime is declared and its wiring is missing.
68
+ WIRING = {
69
+ "claude-code": ".claude/settings.json",
70
+ "codex": ".codex/hooks.json",
71
+ "cursor": ".cursor/hooks.json",
72
+ "gemini-cli": ".gemini/settings.json",
73
+ }
74
+
75
+ # A directory that means "this runtime is in use here".
76
+ FOOTPRINT = {
77
+ "claude-code": ".claude",
78
+ "codex": ".codex",
79
+ "cursor": ".cursor",
80
+ "gemini-cli": ".gemini",
81
+ }
82
+
83
+ # Only this one has a wiring file in the kit, watched refusing a real command.
84
+ TESTED = {"claude-code"}
85
+
86
+ CONFIG = """\
87
+ # Formwork configuration. Three layers, kept apart on purpose.
88
+
89
+ [bindings]
90
+ # One person's setup. Change freely.
91
+ runtime = "%s"
92
+
93
+ [strength]
94
+ # How hard each enforced rule bites: block | warn | off.
95
+ # Strict by default. A team may need warn; somebody working alone should not.
96
+ git_boundary = "block"
97
+ protect_files = "block"
98
+
99
+ # [rules] is deliberately absent, and that is the point.
100
+ # Rules are not switched off here. Dropping one is an edit to
101
+ # formwork/rules/core.md, which leaves a line in version control with your
102
+ # name on it.
103
+ """
104
+
105
+
106
+ class Plan(object):
107
+ """Everything that would change, decided before anything is written."""
108
+
109
+ def __init__(self, dry_run):
110
+ self.dry_run = dry_run
111
+ self.writes = [] # (path, text, why)
112
+ self.backups = [] # (path, copy_path)
113
+ self.notes = []
114
+ self.unfinished = []
115
+
116
+ def write(self, path, text, why):
117
+ self.writes.append((path, text, why))
118
+
119
+ def apply(self):
120
+ for path, copy in self.backups:
121
+ if self.dry_run:
122
+ continue
123
+ if not os.path.exists(copy):
124
+ shutil.copy2(path, copy)
125
+ else:
126
+ # A second install used to overwrite the file while leaving
127
+ # the first backup in place, so anything added in between
128
+ # existed in neither. Keep both.
129
+ stamp = "%s.%d" % (copy, int(time.time()))
130
+ shutil.copy2(path, stamp)
131
+ for path, text, _ in self.writes:
132
+ if self.dry_run:
133
+ continue
134
+ parent = os.path.dirname(path)
135
+ if parent:
136
+ os.makedirs(parent, exist_ok=True)
137
+ open(path, "w", encoding="utf-8").write(text)
138
+
139
+
140
+ def detect(root):
141
+ """(runtime, why). runtime is None when it cannot be told from the outside."""
142
+ found = [r for r, d in sorted(FOOTPRINT.items())
143
+ if os.path.isdir(os.path.join(root, d))]
144
+ if len(found) == 1:
145
+ return found[0], "found %s/" % FOOTPRINT[found[0]]
146
+ if len(found) > 1:
147
+ return None, ("more than one runtime is set up here: %s"
148
+ % ", ".join(found))
149
+ return None, "no runtime directory found"
150
+
151
+
152
+ def declared(root):
153
+ path = os.path.join(root, ".formwork.toml")
154
+ if not os.path.exists(path):
155
+ return None
156
+ text = open(path, encoding="utf-8", errors="ignore").read()
157
+ m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
158
+ return m.group(1) if m else None
159
+
160
+
161
+ def plan_config(plan, root, runtime):
162
+ path = os.path.join(root, ".formwork.toml")
163
+ already = declared(root)
164
+ if already == runtime:
165
+ plan.notes.append(".formwork.toml already says runtime = \"%s\"" % runtime)
166
+ return
167
+ if os.path.exists(path):
168
+ # Somebody's configuration, which may carry settings this installer
169
+ # knows nothing about. Overwriting it would throw those away.
170
+ plan.unfinished.append(
171
+ ".formwork.toml exists and says runtime = %s, not \"%s\". Left "
172
+ "alone. Change that line yourself if you meant to switch."
173
+ % ('"%s"' % already if already else "nothing", runtime))
174
+ return
175
+ plan.write(path, CONFIG % runtime, "declares the runtime")
176
+
177
+
178
+ def merge_claude_hooks(existing, adapter):
179
+ """Add the kit's hooks to whatever is already there. Removes nothing."""
180
+ out = json.loads(json.dumps(existing)) # copy, leave the original
181
+ hooks = out.get("hooks")
182
+ if not isinstance(hooks, dict):
183
+ # A "hooks" key holding a list, or anything else, used to crash here.
184
+ hooks = {}
185
+ out["hooks"] = hooks
186
+ added = 0
187
+ for event, entries in adapter.get("hooks", {}).items():
188
+ current = hooks.get(event)
189
+ if not isinstance(current, list):
190
+ current = []
191
+ hooks[event] = current
192
+ for entry in entries:
193
+ matcher = entry.get("matcher")
194
+ mine = [e for e in current
195
+ if isinstance(e, dict) and e.get("matcher") == matcher]
196
+ if not mine:
197
+ current.append(json.loads(json.dumps(entry)))
198
+ added += len(entry.get("hooks", []))
199
+ continue
200
+ target = mine[0]
201
+ if not isinstance(target.get("hooks"), list):
202
+ target["hooks"] = []
203
+ have = {h.get("command") for h in target["hooks"]
204
+ if isinstance(h, dict)}
205
+ for h in entry.get("hooks", []):
206
+ if h.get("command") not in have:
207
+ target.setdefault("hooks", []).append(
208
+ json.loads(json.dumps(h)))
209
+ added += 1
210
+ return out, added
211
+
212
+
213
+ def plan_wiring(plan, root, runtime):
214
+ rel = WIRING[runtime]
215
+ path = os.path.join(root, rel)
216
+ source = os.path.join(ADAPTERS, runtime, "settings.json")
217
+
218
+ if not os.path.isfile(source):
219
+ plan.unfinished.append(
220
+ "%s has no wiring file in this kit, so nothing was written to %s. "
221
+ "Its hooks are documented in formwork/adapters/%s/README.md and "
222
+ "nobody has run them."
223
+ % (runtime, rel, runtime))
224
+ return
225
+
226
+ adapter = json.load(open(source, encoding="utf-8"))
227
+
228
+ if not os.path.exists(path):
229
+ plan.write(path, json.dumps(adapter, indent=2) + "\n",
230
+ "wires the guards")
231
+ return
232
+
233
+ try:
234
+ existing = json.load(open(path, encoding="utf-8"))
235
+ except ValueError as e:
236
+ plan.unfinished.append(
237
+ "%s exists and is not valid JSON (%s), so it was left untouched. "
238
+ "Fix it, or merge formwork/adapters/%s/settings.json in by hand."
239
+ % (rel, e, runtime))
240
+ return
241
+
242
+ if not isinstance(existing, dict):
243
+ plan.unfinished.append(
244
+ "%s exists and is not an object, so it was left untouched. Merge "
245
+ "formwork/adapters/%s/settings.json in by hand." % (rel, runtime))
246
+ return
247
+
248
+ merged, added = merge_claude_hooks(existing, adapter)
249
+ if not added:
250
+ plan.notes.append("%s already calls the guards" % rel)
251
+ return
252
+ plan.backups.append((path, path + ".before-formwork"))
253
+ kept = 0
254
+ old_hooks = existing.get("hooks")
255
+ if isinstance(old_hooks, dict):
256
+ for entries in old_hooks.values():
257
+ if isinstance(entries, list):
258
+ for e in entries:
259
+ if isinstance(e, dict) and isinstance(e.get("hooks"), list):
260
+ kept += len(e["hooks"])
261
+ plan.write(path, json.dumps(merged, indent=2) + "\n",
262
+ "adds %d hook(s), keeping the %d you already had"
263
+ % (added, kept))
264
+
265
+
266
+ def record_fingerprints(root, dry_run):
267
+ """Take the first integrity record, if there is not one already.
268
+
269
+ The integrity check refuses to pass until something is recorded, and the
270
+ self-protection guard refuses to let an agent re-record. Without this step
271
+ a fresh fork was deadlocked: the gate said run --record, and the guard
272
+ said no.
273
+
274
+ Only the FIRST record happens here. Re-recording after a change stays a
275
+ human decision, which is the whole point of the record.
276
+ """
277
+ checker = os.path.join(HERE, "check", "checks", "kit-integrity")
278
+ if not (os.path.isfile(checker) and os.access(checker, os.X_OK)):
279
+ return None
280
+ # Ask the checker where this project's record lives. A single machine-wide
281
+ # file meant the second project on a machine inherited the first one's
282
+ # fingerprints and was told its untouched guards had been tampered with.
283
+ probe = subprocess.run([checker, root], capture_output=True, text=True)
284
+ if "no fingerprints recorded" not in (probe.stdout + probe.stderr):
285
+ return "already"
286
+ if dry_run:
287
+ return "would"
288
+ try:
289
+ p = subprocess.run([checker, "--record", root], capture_output=True,
290
+ text=True, timeout=120)
291
+ except (OSError, subprocess.TimeoutExpired):
292
+ return None
293
+ return "done" if p.returncode == 0 else None
294
+
295
+
296
+ def generate_roles(root, runtime, dry_run):
297
+ """Write the role files for one runtime. Returns how many, or None.
298
+
299
+ Roles are the kit's headline feature and the installer used not to place
300
+ any of them. The generator lives beside this program; it is run rather
301
+ than reimplemented, so there is one source of truth for the format.
302
+ """
303
+ build = os.path.join(HERE, "build")
304
+ if not (os.path.isfile(build) and os.access(build, os.X_OK)):
305
+ return None
306
+ args = [build, "--runtime", runtime]
307
+ if dry_run:
308
+ args.append("--check")
309
+ try:
310
+ p = subprocess.run(args, capture_output=True, text=True, cwd=root,
311
+ timeout=120)
312
+ except (OSError, subprocess.TimeoutExpired):
313
+ return None
314
+ m = re.search(r"(\d+)\s+(?:role|generated) file", p.stdout or "")
315
+ return int(m.group(1)) if m else None
316
+
317
+
318
+ def main(argv):
319
+ args = argv[1:]
320
+ dry_run = "--dry-run" in args
321
+ root = os.getcwd()
322
+
323
+ if "--runtime" in args:
324
+ i = args.index("--runtime")
325
+ if i + 1 >= len(args):
326
+ print("ERROR: --runtime needs a name: %s"
327
+ % ", ".join(sorted(WIRING)), file=sys.stderr)
328
+ return CANNOT_RUN
329
+ runtime, why = args[i + 1], "you said so"
330
+ if runtime not in WIRING:
331
+ print("ERROR: '%s' is not a runtime this kit can wire. Known: %s"
332
+ % (runtime, ", ".join(sorted(WIRING))), file=sys.stderr)
333
+ return CANNOT_RUN
334
+ else:
335
+ runtime, why = detect(root)
336
+ if runtime is None:
337
+ print("Cannot tell which runtime this project uses: %s." % why)
338
+ print("Say which, and run again:")
339
+ for r in sorted(WIRING):
340
+ print(" formwork/install --runtime %s" % r)
341
+ return CANNOT_RUN
342
+
343
+ plan = Plan(dry_run)
344
+ plan_config(plan, root, runtime)
345
+ plan_wiring(plan, root, runtime)
346
+ plan.apply()
347
+ generated = generate_roles(root, runtime, dry_run)
348
+ recorded = record_fingerprints(root, dry_run)
349
+
350
+ verb = "would write" if dry_run else "wrote"
351
+ print("runtime: %s (%s)" % (runtime, why))
352
+ for path, _, reason in plan.writes:
353
+ print(" %s %s — %s" % (verb, os.path.relpath(path, root), reason))
354
+ for path, copy in plan.backups:
355
+ print(" %s %s — a copy of the original, before the merge"
356
+ % ("would keep" if dry_run else "kept",
357
+ os.path.relpath(copy, root)))
358
+ if generated is not None:
359
+ print(" %s %d role file(s) for %s"
360
+ % ("would generate" if dry_run else "generated", generated,
361
+ runtime))
362
+ if recorded == "done":
363
+ print(" recorded what every guard and check looks like right now")
364
+ elif recorded == "would":
365
+ print(" would record what every guard and check looks like right now")
366
+ elif recorded == "already":
367
+ print(" already done an integrity record exists; not touching it")
368
+ for note in plan.notes:
369
+ print(" already done %s" % note)
370
+ if not plan.writes and not plan.notes:
371
+ print(" nothing to write")
372
+
373
+ if runtime not in TESTED:
374
+ plan.unfinished.append(
375
+ "This runtime is untested. Its wiring is documented by its "
376
+ "publisher and nobody has watched it refuse anything. Read "
377
+ "formwork/adapters/%s/README.md before relying on it." % runtime)
378
+
379
+ if plan.unfinished:
380
+ print("")
381
+ print("NOT FINISHED:")
382
+ for item in plan.unfinished:
383
+ print(" - %s" % item)
384
+ print("")
385
+ print("Then check it took: formwork check")
386
+ return PARTIAL
387
+
388
+ print("")
389
+ print("Check it took: formwork check")
390
+ print("Then: formwork/first-run.md")
391
+ return DONE
392
+
393
+
394
+ if __name__ == "__main__":
395
+ sys.exit(main(sys.argv))
@@ -0,0 +1,141 @@
1
+ # What this kit cannot do
2
+
3
+ **Everything below was confirmed by running it, not guessed.** A guard you
4
+ trust further than it deserves is worse than no guard at all.
5
+
6
+ ---
7
+
8
+ ## The short version
9
+
10
+ **The guards stop the ordinary path, not a determined one.** They are pattern
11
+ matching over a command line. An agent that wants past them can get past them.
12
+ The value is that an agent not trying to, which is nearly always, is stopped
13
+ from doing the everyday thing that should have been your decision.
14
+
15
+ > [!WARNING]
16
+ > **If you need containment, this is not it.** Use a sandbox.
17
+
18
+ ---
19
+
20
+ ## Ways past the version-control boundary
21
+
22
+ These were all closed during the audit, and are now tested:
23
+
24
+ `bash -c "git commit"` · `eval` · backticks · `$(...)` · `nice -n 5 git push` ·
25
+ `timeout 60 git push` · `env -u FOO git push` · `git config core.hooksPath` ·
26
+ `git checkout <file>`
27
+
28
+ **These are still open, and are not closable by pattern matching:**
29
+
30
+ - **Building the command out of pieces.** `g="git"; $g push`
31
+ - **Encoding it.** Base64 into a shell, a string reversed, a character at a time.
32
+ - **A script.** Writing `deploy.sh` and running it. The guard sees `./deploy.sh`.
33
+ - **A different tool.** A git library in Python, a language binding, an editor's
34
+ built-in version control.
35
+ - **`xargs`.** `echo <path> | xargs rm` — the path is an argument to `echo`.
36
+
37
+ **Why these stay open.** Each fix is a longer pattern, and a longer pattern
38
+ refuses more ordinary work. A guard that does that gets switched off, and then
39
+ nothing is guarded.
40
+
41
+ ---
42
+
43
+ ## Ways past self-protection
44
+
45
+ Closed during the audit: writing through a pipe, `chmod` in every spelling,
46
+ deleting or moving the folder that holds the guards, re-recording the integrity
47
+ fingerprints, hiding a path inside a here-document.
48
+
49
+ **Still open:** everything in the list above, for the same reason. Plus:
50
+
51
+ - **Symbolic and hard links.** A link to a protected file is a different path,
52
+ and the guard compares paths.
53
+ - **A long-running program.** Approved once, then doing whatever it likes.
54
+
55
+ ---
56
+
57
+ ## What the checks cannot tell you
58
+
59
+ **`predictions-first`** fails a round that has reports and no predictions file.
60
+ Beyond that it only warns: modification times are weak evidence and it says so.
61
+ **It cannot tell you the predictions were any good.**
62
+
63
+ **The fixture shuffle hides the case name and nothing else.** Before a check
64
+ runs, its test folder is copied somewhere with a meaningless name, so a check
65
+ cannot pass by spotting the word "must-fail". **Names inside the folder are
66
+ left alone**, because several checks read them for real reasons: a decision
67
+ record is identified by its numbered filename, a role by its `.md` ending.
68
+
69
+ So a check that looks for one particular file inside a fixture can still pass
70
+ while examining nothing. An audit wrote one and it worked. Scrambling the
71
+ contents was tried and broke the checks that read names legitimately.
72
+
73
+ **What this means in practice:** the pair of fixtures proves a check can tell
74
+ two inputs apart. It does not prove the check looked at what is in them.
75
+
76
+ **`rule-labels`** checks that a rule naming a check names one that exists. It
77
+ matches on the name only.
78
+
79
+ **`doc-links`** resolves links. It does not know whether the page it reached
80
+ says what the link promised.
81
+
82
+ **`kit-integrity`** notices that a file changed. **It has no opinion about
83
+ whether the change was good**, and re-recording is a human decision for exactly
84
+ that reason.
85
+
86
+ **`generated-current`** compares generated files with what the source produces.
87
+ It now also reports generated files with no source at all.
88
+
89
+ ---
90
+
91
+ ## The gate gives up
92
+
93
+ The turn-end gate refuses a red gate **three times in a session**, then stands
94
+ aside with a loud message.
95
+
96
+ **That is deliberate and it is a real hole.** Without it, a genuinely stuck turn
97
+ is trapped for ever. With it, an agent that fails three times can proceed.
98
+
99
+ Three refusals in one session is not a subtle signal. It is the point at which
100
+ you should be reading, not the point at which the kit should keep refusing.
101
+
102
+ `gate_budget` in `.formwork.toml` changes the number.
103
+
104
+ ---
105
+
106
+ ## What the privacy scanners cannot see
107
+
108
+ Both ship outside the repository, and this matters to anyone forking the method
109
+ rather than the code.
110
+
111
+ **The word scan finds words.** The overlap scan finds eight words in a row.
112
+
113
+ **Neither can see a paraphrase.** A fact from a private project, retold in fresh
114
+ words, passes both cleanly. That happened while this kit was being built: both
115
+ scans were green and a fresh reader still reconstructed a great deal. It is
116
+ written up as entry 7 of `docs/dogfood.md`, in the source repository.
117
+
118
+ **A clean scan proves the absence of what it looked for. It proves nothing
119
+ about what it cannot see.**
120
+
121
+ ---
122
+
123
+ ## Three of the four runtimes are untested
124
+
125
+ Only Claude Code has been watched refusing a real command.
126
+
127
+ Codex, Cursor and Gemini CLI all document a way to block, and their adapters say
128
+ `untested` at the top. **Their hook payload shapes are assumed**, not verified.
129
+ The guards now refuse rather than allow when they cannot read a payload, so a
130
+ wrong assumption shows up as a refusal rather than as silent permission.
131
+
132
+ ---
133
+
134
+ ## The honest summary
135
+
136
+ This kit will stop an agent doing the wrong thing by habit. **It will not stop
137
+ one doing the wrong thing on purpose**, and nothing built out of pattern
138
+ matching would.
139
+
140
+ If that is not enough for your situation, the answer is not a better pattern.
141
+ It is a sandbox.
@@ -0,0 +1,82 @@
1
+ # The loop
2
+
3
+ There is one loop. Only its size changes.
4
+
5
+ ```
6
+ BRIEF → WORK → CHECK → REPORT → STOP → you say go → BRIEF …
7
+ ```
8
+
9
+ **Nothing continues past STOP without you.** That is the whole shape.
10
+
11
+ ---
12
+
13
+ ## Five sizes
14
+
15
+ | Size | How long | Brief | Report |
16
+ |---|---|---|---|
17
+ | task | minutes | one line | files, check result |
18
+ | checkpoint | one sitting | six headings | the full list |
19
+ | round | hours to days | a question per role | a round record |
20
+ | phase | weeks | what it settles | one document |
21
+ | milestone | months | a direction | — |
22
+
23
+ **These are names for how big a turn was.** They are not five different
24
+ processes. A task and a phase run the same loop.
25
+
26
+ ---
27
+
28
+ ## Four questions, every size
29
+
30
+ Before starting anything, answer these. A phase and a five-minute task both
31
+ deserve them, in proportion.
32
+
33
+ 1. **What does it produce?** One thing.
34
+ 2. **What has to be true before it starts?**
35
+ 3. **Who says go?** You.
36
+ 4. **What would tell us it failed?**
37
+
38
+ Question four is the one people skip. A piece of work that cannot fail is a
39
+ piece of work nobody can check.
40
+
41
+ ---
42
+
43
+ ## Picking a size
44
+
45
+ Start small. The cost of picking too small is one more turn of the loop. The
46
+ cost of picking too big is work nobody can review, which is worse.
47
+
48
+ **Task** — you can describe it in a sentence and it touches a file or two.
49
+
50
+ **Checkpoint** — you need to state what is out of scope. Past a small number of
51
+ files you are looking at two of them.
52
+
53
+ **Round** — the answer is genuinely unclear and you want it argued. Rounds cost
54
+ real money and nobody has measured how much. Do not run one out of habit.
55
+
56
+ **Phase** — a question big enough that its answer is a document.
57
+
58
+ **Milestone** — a direction, not a piece of work. It contains phases.
59
+
60
+ ---
61
+
62
+ ## Where the size comes from
63
+
64
+ Not from counting files. From this question:
65
+
66
+ > Could a reviewer sensibly accept one part of this and reject the next part?
67
+
68
+ If yes, it is two pieces of work. Split it there.
69
+
70
+ *That test comes from a published engineering-process kit, not from this
71
+ method. A file count is arbitrary and this is not.*
72
+
73
+ ---
74
+
75
+ ## What STOP means
76
+
77
+ STOP is not "finished". It is "your turn".
78
+
79
+ The agent halts, reports, and waits. It does not start the next piece because
80
+ the next piece is obvious. It does not commit. It does not tidy up first.
81
+
82
+ **If you only keep one thing from this kit, keep STOP.**