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,133 @@
1
+ #!/usr/bin/env python3
2
+ """Check: every link between documents points at something that exists.
3
+
4
+ doc-links <directory>
5
+
6
+ CATCHES A document that refers to a file which was renamed, moved or never
7
+ written. The reference still reads correctly, so nothing announces it,
8
+ and the reader follows it into nothing.
9
+
10
+ Both forms count: a markdown link, and a path in backticks. The
11
+ second is how a broken reference sat on the front page unnoticed.
12
+
13
+ Exit status:
14
+ 0 every link resolves
15
+ 1 at least one does not, and it is named with its line
16
+ 2 the check could not run
17
+
18
+ Only links between files are checked. Web addresses are somebody else's
19
+ problem, and an anchor without a path points inside the same document.
20
+
21
+ Python 3, standard library only, no dependencies.
22
+ """
23
+ import os
24
+ import re
25
+ import sys
26
+
27
+ LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
28
+ # A path inside backticks is a reference too. The front page pointed at a file
29
+ # that did not exist for a day, and the markdown-only version never saw it.
30
+ TICKED = re.compile(r"`([A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)+/?)`")
31
+ # Only paths into the kit's own tree, and they resolve from the project root
32
+ # rather than from the file they appear in. Anything else in backticks is
33
+ # somebody else's repository, a command, or an example, and guessing which
34
+ # would produce noise rather than findings.
35
+ OURS = ("formwork/",)
36
+ # Third-party and generated trees. Without these, a single install of
37
+ # somebody's dependencies produced dozens of findings about other people's
38
+ # READMEs, and the file count in the summary was meaningless.
39
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "env",
40
+ ".tox", ".nox", "build", "dist", "target", "vendor",
41
+ "site-packages", ".next", ".nuxt", "coverage", ".mypy_cache",
42
+ ".pytest_cache", ".ruff_cache", ".gradle", "Pods"}
43
+
44
+ # Paths the runner has told this check to stay out of. Part of the check
45
+ # contract: the runner decides, the check obeys.
46
+ EXCLUDED = [os.path.abspath(p) for p in
47
+ os.environ.get("FORMWORK_EXCLUDE", "").split(os.pathsep) if p]
48
+ # .txt was included and produced findings in data files that merely happened
49
+ # to contain bracket-parenthesis text. Markdown and reStructuredText only.
50
+ TEXT_EXT = {".md", ".rst"}
51
+
52
+
53
+ def links_in(text):
54
+ # A fenced code block holds examples. Showing a link in one is legitimate
55
+ # work, and failing the gate for it is a false positive in a kit that is
56
+ # mostly documentation.
57
+ fenced = False
58
+ for line_no, line in enumerate(text.split("\n"), 1):
59
+ if line.lstrip().startswith("```"):
60
+ fenced = not fenced
61
+ continue
62
+ if fenced:
63
+ continue
64
+ for m in TICKED.finditer(line):
65
+ target = m.group(1).strip().rstrip("/")
66
+ if target.startswith(OURS):
67
+ yield line_no, target, True # resolves from the root
68
+ for m in LINK.finditer(line):
69
+ target = m.group(1).strip()
70
+ if not target:
71
+ continue
72
+ # Somebody else's server, or a spot inside this same document.
73
+ if target.startswith(("http://", "https://", "mailto:", "#")):
74
+ continue
75
+ # A trailing anchor names a heading; the file is what we resolve.
76
+ path = target.split("#", 1)[0]
77
+ if path:
78
+ yield line_no, path, False # resolves from this file
79
+
80
+
81
+ def main(argv):
82
+ if len(argv) < 2:
83
+ print("usage: doc-links <directory>", file=sys.stderr)
84
+ return 2
85
+ root = argv[1]
86
+ if not os.path.isdir(root):
87
+ print("ERROR: not a directory: %s" % root, file=sys.stderr)
88
+ return 2
89
+
90
+ broken = []
91
+ unreadable = []
92
+ scanned = 0
93
+ for dirpath, dirnames, filenames in os.walk(root):
94
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
95
+ here = os.path.abspath(dirpath)
96
+ if any(here == e or here.startswith(e + os.sep) for e in EXCLUDED):
97
+ dirnames[:] = []
98
+ continue
99
+ for fn in filenames:
100
+ if os.path.splitext(fn)[1].lower() not in TEXT_EXT:
101
+ continue
102
+ full = os.path.join(dirpath, fn)
103
+ try:
104
+ text = open(full, encoding="utf-8", errors="strict").read()
105
+ except (UnicodeDecodeError, OSError) as e:
106
+ # Silently skipping meant one stray byte hid a document from
107
+ # this check entirely, and the summary still said every link
108
+ # resolved. Say so instead.
109
+ unreadable.append("%s — cannot be read (%s)"
110
+ % (os.path.relpath(full, root), e))
111
+ continue
112
+ scanned += 1
113
+ for line_no, path, from_root in links_in(text):
114
+ base = root if from_root else dirpath
115
+ resolved = os.path.normpath(os.path.join(base, path))
116
+ if not os.path.exists(resolved):
117
+ broken.append((os.path.relpath(full, root), line_no, path))
118
+
119
+ if broken or unreadable:
120
+ print("%d link(s) point at nothing, in %d file(s) scanned"
121
+ % (len(broken), scanned))
122
+ for rel, line_no, path in broken:
123
+ print(" %s:%d -> %s" % (rel, line_no, path))
124
+ for u in unreadable:
125
+ print(" %s" % u)
126
+ return 1
127
+
128
+ print("%d file(s) scanned, every link resolves" % scanned)
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main(sys.argv))
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Check: every generated file matches what its source produces.
3
+
4
+ generated-current <directory>
5
+
6
+ CATCHES A hand-edit to a generated file. It works, for exactly as long as
7
+ nobody regenerates — then it vanishes, and whoever made it does not
8
+ find out.
9
+
10
+ And a generated file left behind after its source changed, which is
11
+ the drift that copying was supposed to remove and quietly reintroduces
12
+ it.
13
+
14
+ The real work is done by `formwork roles --check`, which regenerates in memory
15
+ and compares bytes. This check exists so that comparison happens on every turn
16
+ rather than when somebody remembers.
17
+
18
+ Exit status:
19
+ 0 everything current, or nothing is generated here
20
+ 1 something is stale or hand-edited, and it is named
21
+ 2 the check could not run
22
+ """
23
+ import os
24
+ import subprocess
25
+ import sys
26
+
27
+ TIMEOUT = 120
28
+
29
+
30
+ def main(argv):
31
+ args = [a for a in argv[1:] if not a.startswith("--")]
32
+ if not args:
33
+ print("usage: generated-current <directory>", file=sys.stderr)
34
+ return 2
35
+ root = args[0]
36
+ if not os.path.isdir(root):
37
+ print("ERROR: not a directory: %s" % root, file=sys.stderr)
38
+ return 2
39
+
40
+ # Absolute, because the generator is run with the project as its working
41
+ # directory and a relative path would then resolve against the wrong root.
42
+ build = os.path.abspath(os.path.join(root, "formwork", "build"))
43
+ if not os.path.isfile(build):
44
+ print("no generator here, nothing generated")
45
+ return 0
46
+ if not os.access(build, os.X_OK):
47
+ print("ERROR: %s is not executable, so nothing can be compared"
48
+ % os.path.relpath(build, root), file=sys.stderr)
49
+ return 2
50
+
51
+ try:
52
+ # No --all: the generator checks the configured runtime plus any
53
+ # generated tree already present. Demanding --all made a fork that
54
+ # uses one runtime generate files for three, or live with a red gate.
55
+ p = subprocess.run([build, "--check"], capture_output=True,
56
+ text=True, cwd=root, timeout=TIMEOUT)
57
+ except subprocess.TimeoutExpired:
58
+ print("ERROR: the generator took longer than %ds" % TIMEOUT,
59
+ file=sys.stderr)
60
+ return 2
61
+ except OSError as e:
62
+ print("ERROR: could not run the generator: %s" % e, file=sys.stderr)
63
+ return 2
64
+
65
+ out = (p.stdout + p.stderr).strip()
66
+ if p.returncode == 2:
67
+ print("ERROR: the generator could not run:\n%s" % out, file=sys.stderr)
68
+ return 2
69
+ print(out)
70
+ return 1 if p.returncode else 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main(sys.argv))
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Check: if a runtime is declared, its guard is actually wired up.
3
+
4
+ guard-wired <directory>
5
+
6
+ CATCHES A project that believes it has a boundary and does not. The rule is
7
+ written down, the adapter exists in the repository, and nothing
8
+ connects the two. Everything looks right and nothing refuses.
9
+
10
+ A project that declares no runtime is not failed. It has made no claim.
11
+
12
+ Exit status:
13
+ 0 no runtime declared, or the declared runtime is wired
14
+ 1 a runtime is declared and its wiring is missing
15
+ 2 the check could not run
16
+ """
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ # Where each runtime keeps its hook configuration, relative to the project.
23
+ WIRING = {
24
+ "claude-code": ".claude/settings.json",
25
+ "codex": ".codex/hooks.json",
26
+ "cursor": ".cursor/hooks.json",
27
+ "gemini-cli": ".gemini/settings.json",
28
+ }
29
+ # All three guards, not one. A settings file with the other two deleted used
30
+ # to report "declared and wired".
31
+ MARKERS = ("formwork/guard/git-boundary",
32
+ "formwork/guard/protected-files",
33
+ "formwork/guard/quality-gate")
34
+ MARKER = MARKERS[0]
35
+
36
+ # Events that actually fire before a tool runs, or at the end of a turn.
37
+ # Anything else is a place to park a hook so it never happens.
38
+ LIVE_EVENTS = {"PreToolUse", "Stop", "SubagentStop", "preToolUse",
39
+ "beforeShellExecution", "BeforeTool"}
40
+
41
+ # A matcher that can match a real tool name. "zzz-never-matches" cannot.
42
+ MATCHES_A_TOOL = re.compile(
43
+ r"(?i)\b(Bash|Write|Edit|NotebookEdit|Task|WebFetch|Read|Glob|Grep|\*|\.\*)\b")
44
+
45
+
46
+ def declared_runtime(root):
47
+ config = os.path.join(root, ".formwork.toml")
48
+ if not os.path.exists(config):
49
+ return None
50
+ try:
51
+ text = open(config, encoding="utf-8", errors="ignore").read()
52
+ except OSError:
53
+ return None
54
+ # Accept the key wherever it sits. The configuration grew a [bindings]
55
+ # section and three documents disagreed about where runtime lived; a
56
+ # reader that only understood one of them broke the other.
57
+ m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
58
+ return m.group(1) if m else None
59
+
60
+
61
+ def main(argv):
62
+ if len(argv) < 2:
63
+ print("usage: guard-wired <directory>", file=sys.stderr)
64
+ return 2
65
+ root = argv[1]
66
+ if not os.path.isdir(root):
67
+ print("ERROR: not a directory: %s" % root, file=sys.stderr)
68
+ return 2
69
+
70
+ runtime = declared_runtime(root)
71
+ if runtime is None:
72
+ print("no runtime declared, nothing claimed")
73
+ return 0
74
+ if runtime not in WIRING:
75
+ print("runtime '%s' is not one this kit knows how to wire" % runtime)
76
+ print(" known: %s" % ", ".join(sorted(WIRING)))
77
+ return 1
78
+
79
+ path = os.path.join(root, WIRING[runtime])
80
+ if not os.path.exists(path):
81
+ print("runtime '%s' is declared, but %s does not exist"
82
+ % (runtime, WIRING[runtime]))
83
+ return 1
84
+ try:
85
+ text = open(path, encoding="utf-8", errors="ignore").read()
86
+ except OSError as e:
87
+ print("ERROR: cannot read %s: %s" % (path, e), file=sys.stderr)
88
+ return 2
89
+ # A bare substring test passed when a guard's path appeared anywhere at
90
+ # all — including inside a key called "_disabled_note", and including
91
+ # inside an echo. The path must START a command value.
92
+ # The value may contain escaped quotes: "\"$CLAUDE_PROJECT_DIR\"/formwork/..."
93
+ # A path inside a "command" string was not enough. Every guard was moved
94
+ # to an event that never fires, with a matcher that matches nothing, and
95
+ # this check still said wired. It now reads the shape, not just the text.
96
+ def wired_for(marker):
97
+ try:
98
+ data = json.loads(text)
99
+ except ValueError:
100
+ # Not JSON. Fall back to the text test, which is all the TOML
101
+ # runtimes can be given today.
102
+ return marker in text
103
+
104
+ hooks = data.get("hooks")
105
+ if not isinstance(hooks, dict):
106
+ return False
107
+ for event, entries in hooks.items():
108
+ if event not in LIVE_EVENTS:
109
+ continue
110
+ if not isinstance(entries, list):
111
+ continue
112
+ for entry in entries:
113
+ if not isinstance(entry, dict):
114
+ continue
115
+ matcher = entry.get("matcher")
116
+ if matcher is not None and not MATCHES_A_TOOL.search(str(matcher)):
117
+ continue
118
+ for h in entry.get("hooks") or []:
119
+ if not isinstance(h, dict):
120
+ continue
121
+ cmd = str(h.get("command", ""))
122
+ if marker in cmd and not cmd.strip().startswith("echo"):
123
+ return True
124
+ return False
125
+
126
+ missing = [m for m in MARKERS if not wired_for(m)]
127
+ if missing:
128
+ print("runtime '%s' is declared and %s exists, but it does not "
129
+ "invoke:" % (runtime, WIRING[runtime]))
130
+ for m in missing:
131
+ print(" %s" % m)
132
+ return 1
133
+
134
+ print("runtime '%s' declared and wired in %s" % (runtime, WIRING[runtime]))
135
+ return 0
136
+
137
+
138
+ if __name__ == "__main__":
139
+ sys.exit(main(sys.argv))
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """Check: nothing that does the enforcing has been changed without saying so.
3
+
4
+ kit-integrity <directory>
5
+ kit-integrity --record <directory> write down what is there now
6
+
7
+ CATCHES A guard, a check, the gate, or a piece of wiring, quietly altered.
8
+ Four characters turn the strongest rule in this kit off, and before
9
+ this check nothing anywhere noticed.
10
+
11
+ It catches the routes the guard cannot: an interpreter assembling a
12
+ path, a human editing in their own editor, a file replaced while the
13
+ hook was disabled.
14
+
15
+ The fingerprints live OUTSIDE the repository, beside the word lists. A record
16
+ kept next to the thing it describes protects nothing.
17
+
18
+ Exit status:
19
+ 0 every protected file matches its fingerprint
20
+ 1 something changed, was added, or went missing
21
+ 2 the check could not run — including no fingerprints recorded yet
22
+ """
23
+ import hashlib
24
+ import os
25
+ import re
26
+ import shutil
27
+ import sys
28
+
29
+ PROTECTED = (
30
+ "formwork/guard",
31
+ "formwork/check/run",
32
+ "formwork/check/checks",
33
+ "formwork/adapters",
34
+ ".formwork.toml",
35
+ ".claude/settings.json",
36
+ )
37
+
38
+ STATE = os.environ.get("FORMWORK_STATE_DIR") or os.environ.get(
39
+ "FORMWORK_DENYLIST_DIR", os.path.expanduser("~/.formwork"))
40
+
41
+
42
+ def record_path(root):
43
+ """Where this project's fingerprints live.
44
+
45
+ One file per project, named after the project's own path.
46
+
47
+ It used to be a single file for the whole machine. Two projects on one
48
+ machine then shared one record, so the second one to be set up inherited
49
+ the first one's fingerprints and reported that its untouched guards had
50
+ been tampered with. Recording to fix it moved the accusation to the other
51
+ project. An audit hit this on its first run.
52
+ """
53
+ # realpath, not abspath. On macOS /tmp is a symlink to /private/tmp, so
54
+ # the same project reached two ways hashed to two different records and
55
+ # the second one reported "nothing recorded yet".
56
+ # An explicit file wins. Fixtures carry their own state directory and
57
+ # cannot know the name below, which depends on where they were copied to.
58
+ explicit = os.environ.get("FORMWORK_FINGERPRINTS")
59
+ if explicit:
60
+ return explicit
61
+ full = os.path.realpath(root)
62
+ digest = hashlib.sha256(full.encode("utf-8")).hexdigest()[:16]
63
+ name = re.sub(r"[^A-Za-z0-9_.-]", "-", os.path.basename(full.rstrip(os.sep)))
64
+ mine = os.path.join(STATE, "fingerprints", "%s-%s.txt" % (name, digest))
65
+
66
+ # One migration, once. An older version kept a single record for the whole
67
+ # machine. Reading it as a fallback brought the shared-record bug straight
68
+ # back, so instead it is moved here the first time this project is
69
+ # checked, and the flat file is left behind renamed so nothing is lost.
70
+ legacy = os.path.join(STATE, "fingerprints.txt")
71
+ if os.path.isfile(legacy) and not os.path.exists(mine):
72
+ try:
73
+ os.makedirs(os.path.dirname(mine), exist_ok=True)
74
+ shutil.copyfile(legacy, mine)
75
+ os.rename(legacy, legacy + ".migrated")
76
+ print("moved the old machine-wide record to %s" % mine,
77
+ file=sys.stderr)
78
+ print(" It used to be shared by every project on this machine.",
79
+ file=sys.stderr)
80
+ except OSError:
81
+ pass
82
+ return mine
83
+
84
+
85
+ def digest(path):
86
+ h = hashlib.sha256()
87
+ with open(path, "rb") as f:
88
+ for block in iter(lambda: f.read(65536), b""):
89
+ h.update(block)
90
+ return h.hexdigest()
91
+
92
+
93
+ def protected_files(root):
94
+ found = {}
95
+ for rel in PROTECTED:
96
+ full = os.path.join(root, rel)
97
+ if os.path.isfile(full):
98
+ found[rel] = digest(full)
99
+ elif os.path.isdir(full):
100
+ for dp, dn, fns in os.walk(full):
101
+ dn[:] = [d for d in dn if d not in ("__pycache__", "state")]
102
+ for fn in sorted(fns):
103
+ if fn.endswith(".pyc"):
104
+ continue
105
+ p = os.path.join(dp, fn)
106
+ try:
107
+ found[os.path.relpath(p, root)] = digest(p)
108
+ except OSError:
109
+ # A dangling symlink used to become a traceback and
110
+ # exit 1, which the contract reserves for findings.
111
+ found[os.path.relpath(p, root)] = "UNREADABLE"
112
+ return found
113
+
114
+
115
+ def load_record(root):
116
+ if not os.path.exists(record_path(root)):
117
+ return None
118
+ out = {}
119
+ for line in open(record_path(root), encoding="utf-8"):
120
+ line = line.split("#", 1)[0].strip()
121
+ if not line or " " not in line:
122
+ continue
123
+ h, rel = line.split(None, 1)
124
+ out[rel.strip()] = h
125
+ return out
126
+
127
+
128
+ def main(argv):
129
+ record_mode = "--record" in argv
130
+ args = [a for a in argv[1:] if not a.startswith("--")]
131
+ if not args:
132
+ print("usage: kit-integrity [--record] <directory>", file=sys.stderr)
133
+ return 2
134
+ root = args[0]
135
+ if not os.path.isdir(root):
136
+ print("ERROR: not a directory: %s" % root, file=sys.stderr)
137
+ return 2
138
+
139
+ now = protected_files(root)
140
+
141
+ if record_mode:
142
+ try:
143
+ os.makedirs(os.path.dirname(record_path(root)), exist_ok=True)
144
+ except OSError as e:
145
+ print("ERROR: cannot write the record: %s" % e, file=sys.stderr)
146
+ print(" Check FORMWORK_STATE_DIR points at a directory you "
147
+ "can write to.", file=sys.stderr)
148
+ return 2
149
+ with open(record_path(root), "w", encoding="utf-8") as f:
150
+ f.write("# Fingerprints of everything that enforces a rule.\n")
151
+ f.write("# Kept outside the repository on purpose: a record beside\n")
152
+ f.write("# the thing it describes protects nothing.\n")
153
+ f.write("# Re-record deliberately, after reviewing what changed.\n")
154
+ for rel in sorted(now):
155
+ f.write("%s %s\n" % (now[rel], rel))
156
+ print("recorded %d file(s) to %s" % (len(now), record_path(root)))
157
+ return 0
158
+
159
+ if not now:
160
+ # Every other check in this kit treats "the watcher is not watching"
161
+ # as status 2. This one used to call it a pass.
162
+ print("ERROR: none of the protected files are here", file=sys.stderr)
163
+ print(" Nothing is being watched, and that is not a pass.",
164
+ file=sys.stderr)
165
+ return 2
166
+
167
+ was = load_record(root)
168
+ if was is None:
169
+ print("ERROR: no fingerprints recorded at %s" % record_path(root), file=sys.stderr)
170
+ print(" Run: formwork/check/checks/kit-integrity --record .",
171
+ file=sys.stderr)
172
+ print(" Until then nothing is watching the guards, and that is "
173
+ "not a pass.", file=sys.stderr)
174
+ return 2
175
+
176
+ changed = sorted(r for r in now if r in was and now[r] != was[r])
177
+ added = sorted(r for r in now if r not in was)
178
+ missing = sorted(r for r in was if r not in now)
179
+
180
+ if changed or added or missing:
181
+ print("%d protected file(s) differ from the record"
182
+ % (len(changed) + len(added) + len(missing)))
183
+ for r in changed:
184
+ print(" changed %s" % r)
185
+ for r in added:
186
+ print(" added %s" % r)
187
+ for r in missing:
188
+ print(" missing %s" % r)
189
+ print()
190
+ print("These files enforce every other rule. If you meant it, review "
191
+ "the change and re-record.")
192
+ return 1
193
+
194
+ print("%d protected file(s), all matching the record" % len(now))
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main(sys.argv))
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Check: the challenger's predictions were written before it read anything.
3
+
4
+ predictions-first <directory>
5
+
6
+ A round lives in docs/rounds/<name>/ and holds one file per participant. The
7
+ challenger's file is predictions.md.
8
+
9
+ CATCHES A round whose predictions were written afterwards, or not at all.
10
+
11
+ An objection composed after reading somebody's work is shaped by that
12
+ work — you find whichever weakness the text put in front of you. A
13
+ prediction is not. One that proves accurate shows the weakness was
14
+ built into the approach rather than a slip on the day, which is a far
15
+ stronger result. Written afterwards, it is a description, and it
16
+ proves nothing while looking exactly the same.
17
+
18
+ WHAT THE EVIDENCE IS WORTH, HONESTLY
19
+ ------------------------------------
20
+ Two things are checked, and they are not equally strong.
21
+
22
+ * **The file exists.** Solid. A round with reports and no predictions did
23
+ not do this at all.
24
+ * **It is older than the reports.** Weak. Modification times are not carried
25
+ by version control and are reset by copying, so this can be wrong in both
26
+ directions.
27
+
28
+ The second is reported as a warning rather than treated as proof. Nothing here
29
+ can prove when somebody thought something; what it can do is make the absence
30
+ impossible to miss.
31
+
32
+ Exit status:
33
+ 0 every round has predictions, written first as far as can be told
34
+ 1 a round has reports and no predictions
35
+ 2 the check could not run
36
+ """
37
+ import os
38
+ import sys
39
+
40
+ ROUNDS = os.path.join("docs", "rounds")
41
+ PREDICTIONS = "predictions.md"
42
+ # brief.md is the human's input, written before anybody works. Counting it as
43
+ # a participant report meant every correctly-run round warned about itself.
44
+ SKIP = {"README.md", "round.md", "index.md", "brief.md"}
45
+
46
+
47
+ def rounds(root):
48
+ base = os.path.join(root, ROUNDS)
49
+ if not os.path.isdir(base):
50
+ return None
51
+ out = []
52
+ for name in sorted(os.listdir(base)):
53
+ d = os.path.join(base, name)
54
+ if os.path.isdir(d):
55
+ out.append((name, d))
56
+ return out
57
+
58
+
59
+ def main(argv):
60
+ args = [a for a in argv[1:] if not a.startswith("--")]
61
+ if not args:
62
+ print("usage: predictions-first <directory>", file=sys.stderr)
63
+ return 2
64
+ root = args[0]
65
+ if not os.path.isdir(root):
66
+ print("ERROR: not a directory: %s" % root, file=sys.stderr)
67
+ return 2
68
+
69
+ found = rounds(root)
70
+ if found is None:
71
+ print("no %s directory, no rounds to check" % ROUNDS)
72
+ return 0
73
+ if not found:
74
+ print("no rounds recorded yet")
75
+ return 0
76
+
77
+ problems = []
78
+ warnings = []
79
+ checked = 0
80
+
81
+ for name, d in found:
82
+ files = [f for f in sorted(os.listdir(d))
83
+ if f.endswith(".md") and f not in SKIP]
84
+ reports = [f for f in files if f != PREDICTIONS]
85
+ if not reports:
86
+ continue # nothing was argued yet
87
+ checked += 1
88
+
89
+ predictions = os.path.join(d, PREDICTIONS)
90
+ if not os.path.exists(predictions):
91
+ problems.append("%s — has %d report(s) and no %s"
92
+ % (name, len(reports), PREDICTIONS))
93
+ continue
94
+
95
+ try:
96
+ when = os.path.getmtime(predictions)
97
+ earliest = min(os.path.getmtime(os.path.join(d, r)) for r in reports)
98
+ except OSError:
99
+ continue
100
+ if when > earliest:
101
+ warnings.append("%s — %s is newer than the earliest report"
102
+ % (name, PREDICTIONS))
103
+
104
+ if problems:
105
+ print("%d round(s) argued without predictions, of %d checked"
106
+ % (len(problems), checked))
107
+ for p in problems:
108
+ print(" %s" % p)
109
+ if warnings:
110
+ for w in warnings:
111
+ print(" warning: %s" % w)
112
+ return 1
113
+
114
+ if warnings:
115
+ print("%d round(s) checked. Nothing missing, but:" % checked)
116
+ for w in warnings:
117
+ print(" warning: %s" % w)
118
+ print(" Modification times are weak evidence — version control does "
119
+ "not carry them and copying resets them.")
120
+ return 0
121
+
122
+ print("%d round(s) checked, each with predictions on record" % checked)
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main(sys.argv))