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,349 @@
1
+ #!/usr/bin/env python3
2
+ """Generate each runtime's role files from one source.
3
+
4
+ formwork/build write files for the runtime in .formwork.toml
5
+ formwork/build --all write files for every runtime
6
+ formwork/build --runtime codex just that one
7
+ formwork/build --check regenerate in memory and compare. Change nothing
8
+
9
+ Exit status:
10
+ 0 done, or --check found everything current
11
+ 1 --check found a generated file that is stale or hand-edited
12
+ 2 could not run
13
+
14
+ WHY GENERATE AT ALL
15
+ -------------------
16
+ Four runtimes want role definitions in four shapes. Keeping four hand-written
17
+ copies of twenty-seven roles is the duplication that drifts — one gets updated
18
+ and the others quietly do not, and nothing announces it.
19
+
20
+ One source, generated outward, with a check that regeneration produces
21
+ identical bytes. A hand-edit to a generated file then fails the gate instead of
22
+ surviving.
23
+
24
+ WHAT EACH RUNTIME GETS, AND WHY CURSOR GETS NOTHING
25
+ ---------------------------------------------------
26
+ claude-code .claude/agents/<name>.md markdown, tools by name
27
+ gemini-cli .gemini/agents/<name>.md markdown, tools by name
28
+ codex .codex/agents/<name>.toml TOML, no tool list at all
29
+ cursor nothing is written
30
+
31
+ Cursor's own documentation names `.claude/agents/` as a location it reads. So
32
+ the Claude Code output serves it directly, and generating a second identical
33
+ tree would be duplication for its own sake.
34
+
35
+ THE TOOL GRANT DOES NOT SURVIVE EVERY TRANSLATION
36
+ -------------------------------------------------
37
+ Claude Code and Gemini CLI take a named list and enforce it. Cursor has one
38
+ boolean. Codex has a sandbox mode, which is not a tool list.
39
+
40
+ This generator writes the grant where it can be expressed and **says in the
41
+ generated file where it cannot**, rather than emitting something that looks
42
+ like a restriction and is not. That was decided deliberately; the two rejected
43
+ alternatives are recorded in docs/role-formats.md.
44
+
45
+ Python 3, standard library only, no dependencies.
46
+ """
47
+ import os
48
+ import re
49
+ import sys
50
+
51
+ CLEAN, STALE, CANNOT_RUN = 0, 1, 2
52
+
53
+ HERE = os.path.dirname(os.path.abspath(__file__))
54
+ ROOT = os.path.dirname(HERE)
55
+ ROLES = os.path.join(HERE, "roles")
56
+
57
+ BANNER = ("GENERATED FROM %s — DO NOT EDIT.\n"
58
+ "Change the source and run formwork/build. A hand-edit here fails "
59
+ "the gate.")
60
+
61
+ # The kit's five abstract grants, mapped to what each runtime actually calls
62
+ # them. None means the name is not established, and the generator will not
63
+ # invent one.
64
+ TOOL_NAMES = {
65
+ "claude-code": {
66
+ "read": ["Read", "Glob", "Grep"],
67
+ "write": ["Write", "Edit"],
68
+ "run": ["Bash"],
69
+ "web": ["WebFetch", "WebSearch"],
70
+ "spawn": ["Task"],
71
+ },
72
+ "gemini-cli": {
73
+ "read": ["read_file", "grep_search"],
74
+ "run": ["run_shell_command"],
75
+ # Established from the publisher's own example. The name of the
76
+ # file-writing tool is NOT among the documented examples, and guessing
77
+ # it would produce an allowlist that silently omits a tool the role
78
+ # needs. See docs/role-formats.md.
79
+ "write": None,
80
+ "web": None,
81
+ "spawn": None,
82
+ },
83
+ }
84
+
85
+ # Runtimes that can hold a role to its grant at all.
86
+ ENFORCES = {"claude-code", "gemini-cli"}
87
+
88
+ TARGETS = {
89
+ "claude-code": (os.path.join(".claude", "agents"), ".md"),
90
+ "gemini-cli": (os.path.join(".gemini", "agents"), ".md"),
91
+ "codex": (os.path.join(".codex", "agents"), ".toml"),
92
+ }
93
+ ALL_RUNTIMES = tuple(sorted(TARGETS)) + ("cursor",)
94
+
95
+
96
+ def read_roles():
97
+ out = []
98
+ for sub in ("method", "packs", "project"):
99
+ d = os.path.join(ROLES, sub)
100
+ if not os.path.isdir(d):
101
+ continue
102
+ for fn in sorted(os.listdir(d)):
103
+ if not fn.endswith(".md") or fn.upper().startswith(("TEMPLATE",
104
+ "HOW-TO",
105
+ "README")):
106
+ continue
107
+ out.append(os.path.join(d, fn))
108
+ return out
109
+
110
+
111
+ def parse(path):
112
+ text = open(path, encoding="utf-8").read()
113
+ m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S)
114
+ if not m:
115
+ return None
116
+ meta = {}
117
+ for line in m.group(1).split("\n"):
118
+ if ":" in line:
119
+ k, v = line.split(":", 1)
120
+ meta[k.strip()] = v.strip()
121
+ body = m.group(2).lstrip("\n")
122
+ # Accept both `tools: ["read", "write"]` and `tools: [read, write]`.
123
+ # Reading only the quoted form meant an unquoted list parsed as NO grants,
124
+ # which emitted frontmatter with no tools key at all — and in Claude Code
125
+ # that grants every tool. The safest-looking role got the widest grant.
126
+ raw = meta.get("tools", "")
127
+ tools = re.findall(r'"([a-z]+)"', raw) or re.findall(r"\b([a-z]+)\b", raw)
128
+ owns = re.search(r"^\*\*Owns\.\*\*\s*(.+?)(?:\n\n|\Z)", body, re.S | re.M)
129
+ description = " ".join(owns.group(1).split()) if owns else meta.get("name", "")
130
+ if len(description) > 300:
131
+ description = description[:297].rsplit(" ", 1)[0] + "…"
132
+ return {"name": meta.get("name", ""), "pack": meta.get("pack", ""),
133
+ "owns": meta.get("owns", ""), "tools": tools,
134
+ "description": description, "body": body,
135
+ "source": os.path.relpath(path, ROOT)}
136
+
137
+
138
+ def resolve_tools(runtime, grants):
139
+ """(names, unmapped). names is None when the list cannot be completed."""
140
+ table = TOOL_NAMES.get(runtime)
141
+ if table is None:
142
+ return None, list(grants)
143
+ names, unmapped = [], []
144
+ for g in grants:
145
+ got = table.get(g)
146
+ if got is None:
147
+ unmapped.append(g)
148
+ else:
149
+ names.extend(got)
150
+ if unmapped:
151
+ return None, unmapped
152
+ return names, []
153
+
154
+
155
+ def render_markdown(role, runtime):
156
+ names, unmapped = resolve_tools(runtime, role["tools"])
157
+ if not role["tools"]:
158
+ # No grant could be read at all. Emitting frontmatter without a tools
159
+ # key means "every tool" on some runtimes, so say so instead.
160
+ unmapped = ["(the grant could not be read from the source)"]
161
+ lines = ["---", "name: %s" % role["name"],
162
+ "description: %s" % role["description"]]
163
+ if runtime == "gemini-cli":
164
+ lines.append("kind: local")
165
+ if names:
166
+ if runtime == "claude-code":
167
+ lines.append("tools: %s" % ", ".join(names))
168
+ else:
169
+ lines.append("tools:")
170
+ lines += [" - %s" % n for n in names]
171
+ lines.append("---")
172
+ lines.append("")
173
+ for line in (BANNER % role["source"]).split("\n"):
174
+ lines.append("<!-- %s -->" % line)
175
+ lines.append("")
176
+ if unmapped:
177
+ lines.append("> **This role's tool grant is not expressed here.** It "
178
+ "asks for %s, and %s does not have a documented name for "
179
+ "%s. An incomplete allowlist would quietly remove a tool "
180
+ "the role needs, so none is written. The grant is advice "
181
+ "on this runtime."
182
+ % (", ".join("`%s`" % t for t in role["tools"]),
183
+ runtime, " and ".join("`%s`" % u for u in unmapped)))
184
+ lines.append("")
185
+ lines.append(role["body"].rstrip())
186
+ lines.append("")
187
+ return "\n".join(lines)
188
+
189
+
190
+ def toml_escape(s):
191
+ return s.replace("\\", "\\\\").replace('"', '\\"')
192
+
193
+
194
+ def render_toml(role):
195
+ banner = (BANNER % role["source"]).split("\n")
196
+ out = ["# %s" % b for b in banner]
197
+ out.append("")
198
+ out.append('name = "%s"' % toml_escape(role["name"]))
199
+ out.append('description = "%s"' % toml_escape(role["description"]))
200
+ out.append("")
201
+ out.append("# This runtime has a sandbox mode rather than a tool list, so")
202
+ out.append("# the grant %s is not expressed here. It is advice on Codex."
203
+ % ", ".join(role["tools"]))
204
+ out.append("")
205
+ out.append('developer_instructions = """')
206
+ out.append(role["body"].rstrip().replace('"""', '\\"\\"\\"'))
207
+ out.append('"""')
208
+ out.append("")
209
+ return "\n".join(out)
210
+
211
+
212
+ def existing_targets():
213
+ """Runtimes whose generated tree is already present in this project.
214
+
215
+ A fork that uses one runtime should not be made to generate files for
216
+ three. But a tree that exists must stay current, or a stale agent
217
+ definition survives unnoticed — which is the whole reason this generator
218
+ exists.
219
+ """
220
+ out = []
221
+ for runtime, (folder, _ext) in sorted(TARGETS.items()):
222
+ if os.path.isdir(os.path.join(ROOT, folder)):
223
+ out.append(runtime)
224
+ return out
225
+
226
+
227
+ def orphans(files, runtimes):
228
+ """Generated files with no source, in the trees we generate into.
229
+
230
+ An agent definition added by hand, or one left behind after its source was
231
+ deleted, used to pass unnoticed: --check only compared files the source
232
+ produces. A hand-written agent with every tool granted is exactly what
233
+ this kit exists to prevent.
234
+ """
235
+ expected = set(files)
236
+ found = []
237
+ for runtime in runtimes:
238
+ if runtime == "cursor":
239
+ continue
240
+ folder, ext = TARGETS[runtime]
241
+ d = os.path.join(ROOT, folder)
242
+ if not os.path.isdir(d):
243
+ continue
244
+ for fn in sorted(os.listdir(d)):
245
+ if not fn.endswith(ext):
246
+ continue
247
+ rel = os.path.join(folder, fn)
248
+ if rel not in expected:
249
+ found.append(rel)
250
+ return found
251
+
252
+
253
+ def outputs(runtimes):
254
+ """{path: contents} for every runtime asked for."""
255
+ files = {}
256
+ roles = [parse(p) for p in read_roles()]
257
+ roles = [r for r in roles if r and r["name"]]
258
+ if not roles:
259
+ return None
260
+ for runtime in runtimes:
261
+ if runtime == "cursor":
262
+ continue # reads .claude/agents/ directly
263
+ folder, ext = TARGETS[runtime]
264
+ for role in roles:
265
+ rel = os.path.join(folder, role["name"] + ext)
266
+ if ext == ".toml":
267
+ files[rel] = render_toml(role)
268
+ else:
269
+ files[rel] = render_markdown(role, runtime)
270
+ return files
271
+
272
+
273
+ def configured_runtime():
274
+ config = os.path.join(ROOT, ".formwork.toml")
275
+ if os.path.exists(config):
276
+ text = open(config, encoding="utf-8", errors="ignore").read()
277
+ m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
278
+ if m and m.group(1) in ALL_RUNTIMES:
279
+ return m.group(1)
280
+ return "claude-code"
281
+
282
+
283
+ def main(argv):
284
+ check_only = "--check" in argv
285
+ if "--all" in argv:
286
+ runtimes = [r for r in ALL_RUNTIMES if r != "cursor"]
287
+ elif check_only and "--runtime" not in argv:
288
+ # Check the configured runtime, plus any tree already present. A fork
289
+ # using one runtime is not made to generate files for three, and a
290
+ # tree that does exist is still held to being current.
291
+ runtimes = sorted(set([configured_runtime()] + existing_targets())
292
+ - {"cursor"})
293
+ if not runtimes:
294
+ runtimes = ["claude-code"]
295
+ elif "--runtime" in argv:
296
+ i = argv.index("--runtime")
297
+ if i + 1 >= len(argv) or argv[i + 1] not in ALL_RUNTIMES:
298
+ print("ERROR: --runtime needs one of: %s" % ", ".join(ALL_RUNTIMES),
299
+ file=sys.stderr)
300
+ return CANNOT_RUN
301
+ runtimes = [argv[i + 1]]
302
+ else:
303
+ runtimes = [configured_runtime()]
304
+
305
+ if runtimes == ["cursor"]:
306
+ print("cursor reads .claude/agents/, so nothing is generated for it")
307
+ runtimes = ["claude-code"]
308
+
309
+ files = outputs(runtimes)
310
+ if files is None:
311
+ print("ERROR: no roles found under %s" % ROLES, file=sys.stderr)
312
+ return CANNOT_RUN
313
+
314
+ if check_only:
315
+ stale = ["%s — generated, but nothing in formwork/roles/ produces it"
316
+ % o for o in orphans(files, runtimes)]
317
+ for rel, content in sorted(files.items()):
318
+ full = os.path.join(ROOT, rel)
319
+ if not os.path.exists(full):
320
+ stale.append("%s — missing" % rel)
321
+ elif open(full, encoding="utf-8").read() != content:
322
+ stale.append("%s — differs from what the source produces" % rel)
323
+ if stale:
324
+ print("%d generated file(s) are not current:" % len(stale))
325
+ for s in stale:
326
+ print(" %s" % s)
327
+ print(" Run formwork/build. If you edited one by hand, that edit "
328
+ "is about to be lost — move it to the source first.")
329
+ return STALE
330
+ print("%d generated file(s), all current" % len(files))
331
+ return CLEAN
332
+
333
+ written = 0
334
+ for rel, content in sorted(files.items()):
335
+ full = os.path.join(ROOT, rel)
336
+ os.makedirs(os.path.dirname(full), exist_ok=True)
337
+ existing = None
338
+ if os.path.exists(full):
339
+ existing = open(full, encoding="utf-8").read()
340
+ if existing != content:
341
+ open(full, "w", encoding="utf-8").write(content)
342
+ written += 1
343
+ print("%d role file(s) considered, %d written, for: %s"
344
+ % (len(files), written, ", ".join(runtimes)))
345
+ return CLEAN
346
+
347
+
348
+ if __name__ == "__main__":
349
+ sys.exit(main(sys.argv))
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: sample
3
+ pack: method
4
+ owns: a-sample-job
5
+ tools: ["read"]
6
+ ---
7
+
8
+ # Sample
9
+
10
+ **Owns.** One clearly stated thing.
11
+
12
+ **Does not own.** Something else.
13
+
14
+ **Tools.** Reading.
15
+
16
+ **Stops when.** It reaches the edge.
17
+
18
+ **Would be wrong if.** It carried on.
@@ -0,0 +1,3 @@
1
+ # A project with no generator
2
+
3
+ Nothing to compare.
@@ -0,0 +1 @@
1
+ {"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "formwork/guard/git-boundary --format claude-code"}, {"type": "command", "command": "formwork/guard/protected-files --format claude-code"}]}, {"matcher": "Write|Edit", "hooks": [{"type": "command", "command": "formwork/guard/protected-files --format claude-code"}]}], "Stop": [{"hooks": [{"type": "command", "command": "formwork/guard/quality-gate --format claude-code"}]}]}}
@@ -0,0 +1 @@
1
+ a project that makes no claim about a runtime
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env python3
2
+ # a check that is still here
@@ -0,0 +1,2 @@
1
+ 505fb61a3520e434db4ebd28faf240bc372030c18193c3e591c08ac1b58670e2 formwork/check/checks/still-here
2
+ 505fb61a3520e434db4ebd28faf240bc372030c18193c3e591c08ac1b58670e2 formwork/check/checks/deleted-one
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env python3
2
+ # the guard, quietly altered
3
+ import sys; sys.exit(0)
@@ -0,0 +1 @@
1
+ ae7e7796bf7d35c41e55175f6d85ddeaa3a72b81f3c78c97424f5e8f2102e31a formwork/guard/git-boundary
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env python3
2
+ # the guard, as recorded
@@ -0,0 +1 @@
1
+ ae7e7796bf7d35c41e55175f6d85ddeaa3a72b81f3c78c97424f5e8f2102e31a formwork/guard/git-boundary
@@ -0,0 +1,4 @@
1
+ # Round 0004
2
+
3
+ The challenger argued, and nobody wrote down what it
4
+ expected beforehand. Every objection in here was shaped by what it read.
@@ -0,0 +1,3 @@
1
+ # Docs
2
+
3
+ A project that has run no rounds.
@@ -0,0 +1,4 @@
1
+ # Predictions
2
+
3
+ Written before reading anybody else. What each will propose,
4
+ where each will be weak, and what the group settles on unpushed.
@@ -0,0 +1,6 @@
1
+ # Roles
2
+
3
+ Every role declares which tools it may use.
4
+
5
+ Tool grants are enforced on all four runtimes, so a role that may not spawn
6
+ cannot spawn anywhere.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: complete
3
+ pack: project
4
+ owns: a-job-nobody-else-has
5
+ tools: ["read", "write"]
6
+ ---
7
+
8
+ # Complete
9
+
10
+ **Owns.** One clearly stated thing.
11
+
12
+ **Does not own.** Something else, which belongs to another named role.
13
+
14
+ **Tools.** Reading and writing, and no more than that.
15
+
16
+ **Stops when.** It reaches the edge of what it owns.
17
+
18
+ **Would be wrong if.** It carried on past that edge.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: vague
3
+ pack: project
4
+ owns: something
5
+ tools: ["read", "write"]
6
+ ---
7
+
8
+ # Vague
9
+
10
+ **Owns.** Quality, broadly.
11
+
12
+ **Does not own.** Other things.
13
+
14
+ **Tools.** Reading and writing.
15
+
16
+ It never says when it stops, or what bad advice from it would look like.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: eager
3
+ pack: project
4
+ owns: enthusiasm
5
+ tools: ["read", "write", "spawn"]
6
+ ---
7
+
8
+ # Eager
9
+
10
+ **Owns.** Getting on with it.
11
+
12
+ **Does not own.** Restraint.
13
+
14
+ **Tools.** It has given itself the power to start other agents.
15
+
16
+ **Stops when.** Rarely.
17
+
18
+ **Would be wrong if.** It raised a crowd to agree with it.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: first
3
+ pack: project
4
+ owns: the-database
5
+ tools: ["read"]
6
+ ---
7
+
8
+ # First
9
+
10
+ **Owns.** The database.
11
+
12
+ **Does not own.** Anything else.
13
+
14
+ **Tools.** Reading.
15
+
16
+ **Stops when.** It is unsure.
17
+
18
+ **Would be wrong if.** It broke something.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: second
3
+ pack: project
4
+ owns: the-database
5
+ tools: ["read"]
6
+ ---
7
+
8
+ # Second
9
+
10
+ **Owns.** The database, again. Nothing says which of us decides.
11
+
12
+ **Does not own.** Anything else.
13
+
14
+ **Tools.** Reading.
15
+
16
+ **Stops when.** It is unsure.
17
+
18
+ **Would be wrong if.** It disagreed with the first role, confidently.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: complete
3
+ pack: project
4
+ owns: a-job-nobody-else-has
5
+ tools: ["read", "write"]
6
+ ---
7
+
8
+ # Complete
9
+
10
+ **Owns.** One clearly stated thing.
11
+
12
+ **Does not own.** Something else, which belongs to another named role.
13
+
14
+ **Tools.** Reading and writing, and no more than that.
15
+
16
+ **Stops when.** It reaches the edge of what it owns.
17
+
18
+ **Would be wrong if.** It carried on past that edge.
@@ -0,0 +1,9 @@
1
+ # Rules
2
+
3
+ ### A rule that claims a check nobody wrote
4
+
5
+ **Enforced** by `imaginary-check`.
6
+
7
+ Reads exactly like a real one. Nothing will ever refuse anything.
8
+
9
+ **Catches:** the worst case — you stop watching for it yourself.
@@ -0,0 +1,9 @@
1
+ # Rules
2
+
3
+ ### A rule that will not say what it is for
4
+
5
+ **Advice.**
6
+
7
+ Always do the thing. Do it every time.
8
+
9
+ This is the rule that gets deleted in week two, and it should be.
@@ -0,0 +1,7 @@
1
+ # Rules
2
+
3
+ ### A rule with no label
4
+
5
+ It does not say whether anything enforces it, so a reader cannot tell.
6
+
7
+ **Catches:** nothing, because nobody knows whether to expect a refusal.
@@ -0,0 +1,10 @@
1
+ # Rules
2
+
3
+ ### A rule that is complete
4
+
5
+ **Advice.**
6
+
7
+ Do the thing, in the way described.
8
+
9
+ **Catches:** the situation this rule exists for, stated as a class rather than
10
+ as a story.