entropy-machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
package/bin/init ADDED
@@ -0,0 +1,232 @@
1
+ #!/bin/sh
2
+ # Bootstrap: write a starter config.json for a project that has none yet.
3
+ #
4
+ # THE CHICKEN-AND-EGG THIS SOLVES — every other entry point in this harness
5
+ # refuses to run without config.json (docs/CONFIG.md rule 2: a missing key
6
+ # is refused by name, never silently defaulted). That is deliberate, but it
7
+ # leaves a brand-new project with no way in: the first thing a new user is
8
+ # meant to do is run the factory once to discover its own config, and that
9
+ # cannot start if the config file it discovers things INTO does not exist.
10
+ # This script writes the minimal file that unblocks everything else, then
11
+ # gets out of the way — completing it honestly is issue q-1's job, not this
12
+ # script's. See docs/CONFIG.md.
13
+ #
14
+ # Usage: bin/init [--force]
15
+ #
16
+ # (no flags) refuse if config.json already exists — never silently
17
+ # overwrite a config someone has tuned.
18
+ # --force overwrite an existing config.json.
19
+ set -e
20
+
21
+ usage() {
22
+ cat >&2 <<'EOF'
23
+ usage: bin/init [--force]
24
+ EOF
25
+ exit 2
26
+ }
27
+
28
+ force=""
29
+ for arg in "$@"; do
30
+ case "$arg" in
31
+ --force) force=1 ;;
32
+ -h|--help) usage ;;
33
+ *)
34
+ echo "init: unrecognized argument: $arg" >&2
35
+ usage
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ . "$(dirname "$0")/../lib/roots.sh"
41
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
42
+
43
+ # THE NESTED-CLONE REFUSAL STILL APPLIES, and has to be called explicitly.
44
+ # entropy_machines_require_root() runs it first, but this script cannot use that (see
45
+ # below), so skipping it silently exempted the ONE command whose whole job is
46
+ # to write files -- and it wrote config.json INTO the harness, which is the
47
+ # exact misrouting the refusal exists to stop. A gate that every entry point
48
+ # honours except the one that creates state is not a gate.
49
+ entropy_machines_refuse_nested_clone init
50
+
51
+ # Deliberately entropy_machines_root, NOT entropy_machines_require_root: the latter
52
+ # exits unless config.json already exists at the resolved root, and this
53
+ # script is the thing that creates it.
54
+ if ! project=$(entropy_machines_root); then
55
+ echo "init: REFUSED — not inside a git repository, and no config.json" >&2
56
+ echo " found by walking up from $PWD." >&2
57
+ echo " cd into the project you want to run the factory on." >&2
58
+ exit 2
59
+ fi
60
+
61
+ target="$ENTROPY_MACHINES_HOME/config.json"
62
+
63
+ if [ -f "$target" ] && [ -z "$force" ]; then
64
+ echo "init: REFUSED — config.json already exists at $target." >&2
65
+ echo " Not overwriting a config someone has tuned. Re-run with --force" >&2
66
+ echo " if you mean to replace it." >&2
67
+ exit 2
68
+ fi
69
+
70
+ project_name=$(basename -- "$project")
71
+
72
+ # EVERY REFUSAL COMES BEFORE EVERY WRITE. This check used to sit down at step
73
+ # 5, after config.json and .gitignore had already been written, so a harness
74
+ # missing its PRD left the project half-initialised — configured, but with
75
+ # nothing to do next and an config.json that a re-run would then refuse to
76
+ # overwrite. A gate that fires after the damage is not a gate.
77
+ # The PRD is the HTML dialogue doc, not the markdown quickstart. They are two
78
+ # different artifacts for two different readers: docs/QUICKSTART.md tells an
79
+ # AGENT how to operate this repo, and PRD-001 is the doc the OWNER answers in a
80
+ # browser. init used to copy the quickstart here, which put a document written
81
+ # for agents in front of the human and gave them nothing to answer.
82
+ #
83
+ # It lands in the project's docs directory because that is what bin/serve
84
+ # serves; docs.dir is config, defaulting to entropy-machines-docs/.
85
+ prd_src="$ENTROPY_MACHINES_HOME/lib/PRD-001-orientation.html"
86
+ docs_dir=$(python3 "$ENTROPY_MACHINES_HOME/lib/config.py" get docs.dir 2>/dev/null | tr -d '"')
87
+ if [ -z "$docs_dir" ] || [ "$docs_dir" = "null" ]; then docs_dir="entropy-machines-docs"; fi
88
+ prd_dst="$project/$docs_dir/PRD-001-orientation.html"
89
+
90
+ if [ ! -f "$prd_src" ]; then
91
+ echo "init: REFUSED — $prd_src is missing." >&2
92
+ echo " That file is the orientation PRD; without it this command would" >&2
93
+ echo " write a config and leave you with nothing to do next. Nothing has" >&2
94
+ echo " been written." >&2
95
+ exit 2
96
+ fi
97
+
98
+ # STEP 3 — write the starter file. Minimal and honest: every value here is
99
+ # either verified (the directory's own name) or a safe, inert default. In
100
+ # particular, suites is an empty array, not a guess like `npm test` — a
101
+ # fabricated suite command that does not exist is worse than an empty list,
102
+ # because post-fold-audit and the verifier would report a broken command as
103
+ # a failing project instead of an unconfigured one. Filling suites in (and
104
+ # everything else this file leaves out — generate, guards, worktree beyond
105
+ # the placeholder below, unattended) is work the orientation PRD asks the
106
+ # owner to decide and then file as issues.
107
+ cat > "$target" <<EOF
108
+ {
109
+ "project": { "name": "$project_name" },
110
+ "tracker": { "backend": "file", "file": { "path": ".entropy-machines/issues.json" } },
111
+ "suites": [],
112
+ "changelog": { "enabled": false }
113
+ }
114
+ EOF
115
+
116
+ # STEP 4 — .entropy-machines/ is where the tracker's issues.json lives; it is
117
+ # per-checkout state, not something to commit.
118
+ gitignore="$project/.gitignore"
119
+
120
+ # Append one entry, creating .gitignore if the project has none. grep -qx so a
121
+ # re-run does not duplicate a line; -F because the entry is a path, and a path
122
+ # is not a regular expression — an unlucky project directory name (`v1.2`, or
123
+ # anything with a bracket) would otherwise either match the wrong line or make
124
+ # grep error out on a malformed pattern and append a duplicate.
125
+ add_ignore() {
126
+ if [ -f "$gitignore" ]; then
127
+ if ! grep -qxF -- "$1" "$gitignore"; then
128
+ # A .gitignore whose last line has no trailing newline would otherwise
129
+ # weld the entry onto it — `node_modules/.entropy-machines/` — one pattern that
130
+ # matches nothing, in place of two that match. `$(tail -c 1)` strips a
131
+ # trailing newline and is therefore empty exactly when there is one.
132
+ if [ -s "$gitignore" ] && [ -n "$(tail -c 1 "$gitignore")" ]; then
133
+ printf '\n' >> "$gitignore"
134
+ fi
135
+ printf '%s\n' "$1" >> "$gitignore"
136
+ fi
137
+ else
138
+ printf '%s\n' "$1" > "$gitignore"
139
+ fi
140
+ }
141
+
142
+ add_ignore ".entropy-machines/"
143
+ # Python leaves these the first time any harness tool runs, INSIDE the files
144
+ # the user just committed. Ignoring .entropy-machines/ but not these meant a fresh init
145
+ # dirtied the tree it had just set up.
146
+ add_ignore "__pycache__/"
147
+ # Per-dispatch briefs, written by bin/dispatch. Local scratch, not history.
148
+ add_ignore ".dispatch-context/"
149
+ # hooks/post-checkout drops this symlink into every worktree it creates. It was
150
+ # invisible until the scope check started working; now every `handoff --lift`
151
+ # would flag or refuse on it.
152
+ add_ignore ".scratch"
153
+ # docs.dir. The docs a project answers ARE the project's own work — a PRD with
154
+ # the owner's answers in it, a report of a sprint that happened here — and the
155
+ # harness cannot know they are fit to publish. They are also machine-written on
156
+ # every serve (TRACKER.html is regenerated by bin/tracker render), so tracking
157
+ # them means a dirty tree after every read. Committing them once put an owner's
158
+ # answered PRD into a public repository. Ignore by default; a project that wants
159
+ # its docs in history removes the line deliberately.
160
+ add_ignore "$docs_dir/"
161
+
162
+ # STEP 5 — put the orientation PRD in front of the owner.
163
+ #
164
+ # THIS IS THE POINT OF init, AND IT DOES NOT FILE ANY ISSUES. A PRD is the
165
+ # UPSTREAM artifact here: it is what CREATES issues. Seeding a task called
166
+ # "orient the factory" would invert that — it would make the machine hand the
167
+ # owner a job before the owner has decided anything, and it would skip the
168
+ # only step no code path can reach. The PRD's open questions are the owner's
169
+ # to rule on, and the issues fall out of those rulings.
170
+ #
171
+ # lib/PRD-001-orientation.html IS that PRD. It is not a tutorial about a PRD,
172
+ # it is the first one, and it is copied into the project so the owner fills in
173
+ # THEIR answers rather than editing the harness's shipped copy.
174
+ prd_written=""
175
+ if [ -f "$prd_dst" ] && [ -z "$force" ]; then
176
+ prd_note="init: left existing $prd_dst alone (--force overwrites)."
177
+ else
178
+ mkdir -p -- "$(dirname -- "$prd_dst")"
179
+ cp -- "$prd_src" "$prd_dst"
180
+ prd_written=yes
181
+ prd_note="init: wrote $prd_dst — open it. It is the first PRD, and the"
182
+ fi
183
+
184
+ # STEP 6 — prove the tracker actually answers before claiming success.
185
+ # A read, not a write: nothing is filed here. An init that reports success
186
+ # while the tracker is unreachable is the exact failure this harness exists
187
+ # to prevent, and the owner would only discover it at the moment they tried
188
+ # to file their first issue.
189
+ #
190
+ # IT IS THE ONE CHECK THAT CANNOT MOVE UP. Everything above this line is a
191
+ # refusal that fires before any write; this one cannot be, because what it
192
+ # tests is whether the tracker answers for a config that does not exist until
193
+ # step 3 wrote it. So it is NOT phrased as a refusal: the writes above really
194
+ # did happen, and calling it "REFUSED" told the user nothing was written when
195
+ # in fact everything was — and a plain re-run then died on "config.json
196
+ # already exists" with no repair path named.
197
+ if ! (cd "$project" && "$ENTROPY_MACHINES_HOME/bin/tracker" ready >/dev/null 2>&1); then
198
+ echo "init: WARNING — $target was WRITTEN, but bin/tracker could not read the" >&2
199
+ echo " tracker back afterward. This is not a refusal: everything above" >&2
200
+ echo " really was written, and the project is half-initialised." >&2
201
+ echo " Check $project/.entropy-machines/ and $target by hand." >&2
202
+ echo " To re-run once you have fixed it (a plain re-run will refuse," >&2
203
+ echo " because config.json now exists):" >&2
204
+ echo " $ENTROPY_MACHINES_HOME/bin/init --force" >&2
205
+ exit 2
206
+ fi
207
+
208
+ echo "init: wrote $target"
209
+ echo "$prd_note"
210
+ if [ -n "$prd_written" ]; then
211
+ echo " questions in it are the ones only you can answer."
212
+ fi
213
+ # PATHS ARE PRINTED AS THE READER MUST TYPE THEM. These lines used to say a
214
+ # bare `bin/serve`, which is only correct when the harness sits at the repo
215
+ # root. Vendored in a subdirectory -- now the default -- that command does not
216
+ # exist, and the closing instruction of the setup command was one a user could
217
+ # not run.
218
+ case "$ENTROPY_MACHINES_HOME" in
219
+ "$project") cmd_prefix="bin" ;;
220
+ "$project"/?*) cmd_prefix="${ENTROPY_MACHINES_HOME#"$project"/}/bin" ;;
221
+ *) cmd_prefix="$ENTROPY_MACHINES_HOME/bin" ;;
222
+ esac
223
+ # RENDER THE TRACKER, so PRD-001's one nav link resolves on day one. The PRD
224
+ # links to TRACKER.html; nothing else creates it, and a first-run user opening
225
+ # the first document the harness hands them would otherwise hit a 404 — which
226
+ # is exactly the bug that made this necessary.
227
+ "$ENTROPY_MACHINES_HOME/bin/tracker" render >/dev/null 2>&1 || \
228
+ echo "init: warning — could not render TRACKER.html; run \`$cmd_prefix/tracker render\`" >&2
229
+
230
+ echo "init: tracker is live and empty — \`$cmd_prefix/tracker ready\` answers."
231
+ echo "init: next — run \`$cmd_prefix/serve\` and answer the PRD it opens."
232
+ echo " Filing the issues it produces is step one."
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env python3
2
+ """Audit the ORCHESTRATOR's folds, after they land.
3
+
4
+ WHY THIS EXISTS. Every other check in the factory points at the workers. The
5
+ verifier sweeps their finished output before anything is folded; the workers
6
+ prove their own guards. Nothing looks at the code the ORCHESTRATOR writes, and
7
+ it does write some: a conflict resolved by hand at merge time is new code,
8
+ covered by no worker's test and seen by no verifier.
9
+
10
+ Not hypothetical. A patch adding one feature once stopped applying because a
11
+ guard written for a different feature had landed underneath it in the merge.
12
+ The naive conflict resolution left the new feature UNREACHABLE — its own
13
+ tests caught it, eventually — and the actual fix (narrowing the other guard)
14
+ was invented by hand during the merge. It was mutated and proved, but only
15
+ because the orchestrator chose to. Nothing required it, and nothing would
16
+ have noticed if it hadn't.
17
+
18
+ TWO JOBS, both mechanical. No model, no judgement.
19
+
20
+ 1. THE ORCHESTRATOR DELTA. Re-apply each worker's patch to the base it was
21
+ written against, then diff that tree against what actually landed. What
22
+ comes out is precisely what the orchestrator wrote at merge time.
23
+
24
+ 2. HEAD IS GREEN, ON A CLEAN TREE. The orchestrator's checkout carries other
25
+ sessions' uncommitted work, so a green run there is not a green HEAD --
26
+ which is how a real regression once survived being repeatedly dismissed
27
+ as a local artefact before someone finally traced it back to HEAD. This
28
+ runs the suites (config.json's `suites[]`) in a throwaway worktree at
29
+ committed HEAD.
30
+
31
+ Run as a script, not as a fifth agent role: once per sprint, after that
32
+ sprint's folds land and before its report closes it out.
33
+
34
+ bin/post-fold-audit --since <ref>
35
+ bin/post-fold-audit --since <ref> --patch a.patch --base <sha>
36
+ bin/post-fold-audit --clean-tree-only
37
+ bin/post-fold-audit --skip <tag> # skip every suite carrying <tag>
38
+ bin/post-fold-audit --quick # shorthand for --skip slow
39
+
40
+ Writes a stamp naming the commit it verified, under `unattended.stateHome`.
41
+ Whatever closes a sprint on top of this tooling can refuse to do so when that
42
+ stamp is older than the newest commit — a sprint should not close on a tree
43
+ nobody actually checked at HEAD.
44
+
45
+ Exit 0 clean | 1 findings | 2 misuse.
46
+ """
47
+ import argparse
48
+ import json
49
+ import os
50
+ import shutil
51
+ import subprocess
52
+ import sys
53
+ import tempfile
54
+ import time
55
+
56
+ # ONE ROOT -- see lib/roots.sh, which this block mirrors for the shell entry
57
+ # points. ENTROPY_MACHINES_ROOT is the repository's MAIN checkout: config.json,
58
+ # .entropy-machines/ and the git history live there, and the harness is vendored inside
59
+ # it as plain tracked files. ENTROPY_MACHINES_HOME is only the DIRECTORY this harness's
60
+ # own files sit in (bin/, lib/, hooks/, doctrine/) -- the repo root, or a
61
+ # subdirectory of it -- resolved from this file's own path so sibling lib/
62
+ # imports work wherever it is vendored. It is not a root and not a repo.
63
+ ENTROPY_MACHINES_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
64
+ sys.path.insert(0, os.path.join(ENTROPY_MACHINES_HOME, "lib"))
65
+ from config import load_config # noqa: E402 (path must be set up first)
66
+
67
+
68
+ def _refuse_nested_clone():
69
+ """Mirror of lib/roots.sh's entropy_refuse_nested_clone(). See lib/config.py."""
70
+ sys.path.insert(0, os.path.join(
71
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lib"))
72
+ import config
73
+ config.refuse_nested_clone("post-fold-audit")
74
+
75
+
76
+ def _entropy_machines_root():
77
+ """Mirrors lib/roots.sh's entropy_machines_root(): the repository's main checkout,
78
+ or None. `git rev-parse --git-common-dir` -- NOT --show-toplevel, which
79
+ prints a linked WORKTREE's own path and would hand this tool a root whose
80
+ .entropy-machines/ does not exist. Falls back to walking up for config.json when
81
+ there is no git. No environment override, matching lib/roots.sh: with one
82
+ root there is nothing for an override to disambiguate.
83
+ """
84
+ try:
85
+ p = subprocess.run(
86
+ ["git", "rev-parse", "--git-common-dir"],
87
+ capture_output=True, text=True, timeout=10,
88
+ )
89
+ except (OSError, subprocess.SubprocessError):
90
+ p = None
91
+ if p is not None and p.returncode == 0 and p.stdout.strip():
92
+ common = p.stdout.strip()
93
+ if not os.path.isabs(common):
94
+ common = os.path.join(os.getcwd(), common)
95
+ return os.path.realpath(os.path.join(common, os.pardir))
96
+ d = os.getcwd()
97
+ while True:
98
+ if os.path.exists(os.path.join(d, "config.json")):
99
+ return d
100
+ parent = os.path.dirname(d)
101
+ if parent == d:
102
+ return None
103
+ d = parent
104
+
105
+
106
+ def _require_root():
107
+ """Mirrors lib/roots.sh's entropy_require_root() -- returns the repo root
108
+ or exits 2 naming the directory actually looked in, instead of silently
109
+ operating on ENTROPY_MACHINES_HOME."""
110
+ _refuse_nested_clone()
111
+ project = _entropy_machines_root()
112
+ if not project:
113
+ print("post-fold-audit: REFUSED -- not inside a git repository, and no "
114
+ "config.json found by walking up from %s." % os.getcwd(), file=sys.stderr)
115
+ print(" cd into the project you are running the factory on.", file=sys.stderr)
116
+ sys.exit(2)
117
+ if not os.path.exists(os.path.join(ENTROPY_MACHINES_HOME, "config.json")):
118
+ print("post-fold-audit: REFUSED -- no config.json at %s." % project, file=sys.stderr)
119
+ print(" That is this project's contract with the harness. See "
120
+ "%s/docs/CONFIG.md." % ENTROPY_MACHINES_HOME, file=sys.stderr)
121
+ print(" To create a starter one: %s/bin/init" % ENTROPY_MACHINES_HOME, file=sys.stderr)
122
+ sys.exit(2)
123
+ return project
124
+
125
+
126
+ ENTROPY_MACHINES_ROOT = _require_root()
127
+ CONFIG = load_config(cwd=ENTROPY_MACHINES_ROOT)
128
+ STAMP = os.path.join(
129
+ os.path.expanduser(CONFIG["unattended"]["stateHome"]),
130
+ "clean-tree-stamp.json")
131
+
132
+
133
+ def build_suites(cfg):
134
+ """`suites[]` from config.json, as (label, cmd, tag, cwd) tuples.
135
+
136
+ No suite is assumed. A project with nothing configured gets an empty
137
+ list here and a loud "no suites configured" print in clean_tree() rather
138
+ than a silent no-op — an audit that quietly checks zero suites and exits
139
+ 0 is worse than one that refuses to look useful.
140
+
141
+ `cwd` is not in docs/CONFIG.md yet: each suite entry may carry its own
142
+ `"cwd"` (relative to the worktree root, default `"."`), for a project
143
+ whose toolchain lives in a subdirectory rather than at the repo root.
144
+ """
145
+ suites = []
146
+ for s in cfg.get("suites", []):
147
+ suites.append((s["name"], s["cmd"], s.get("tag"), s.get("cwd", ".")))
148
+ return suites
149
+
150
+
151
+ def sh(args, cwd, timeout=2400):
152
+ p = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout)
153
+ return p.returncode, (p.stdout or "") + (p.stderr or "")
154
+
155
+
156
+ def git(args, cwd=ENTROPY_MACHINES_ROOT):
157
+ return sh(["git"] + args, cwd)
158
+
159
+
160
+ def worktree(at, label):
161
+ """A throwaway worktree at `at`, with worktree.linkPaths symlinked in.
162
+
163
+ The symlinks are not a convenience. Without them a runtime walks UP out of
164
+ the worktree and resolves dependencies from a SIBLING worktree -- you run
165
+ someone else's code and report the result as your own. That has happened
166
+ here.
167
+
168
+ READ FROM CONFIG, NOT HARDCODED. This used to symlink `node_modules` and
169
+ nothing else, which silently did nothing for a project that is not
170
+ JavaScript -- so a Python or Go project got an unlinked worktree and the
171
+ exact failure above, in the one code path whose job is to catch it.
172
+ """
173
+ d = tempfile.mkdtemp(prefix="post-fold-audit-%s-" % label)
174
+ path = os.path.join(d, "tree")
175
+ rc, out = git(["worktree", "add", "--detach", path, at])
176
+ if rc != 0:
177
+ shutil.rmtree(d, ignore_errors=True)
178
+ return None, out
179
+ for rel in (CONFIG.get("worktree") or {}).get("linkPaths") or []:
180
+ src = os.path.join(ENTROPY_MACHINES_ROOT, rel)
181
+ if not os.path.isdir(src):
182
+ continue
183
+ dst = os.path.join(path, rel)
184
+ if os.path.exists(dst):
185
+ # Already present in the tree (it is tracked, not ignored). Nothing
186
+ # to link, and nothing wrong.
187
+ continue
188
+ try:
189
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
190
+ os.symlink(src, dst)
191
+ except OSError as exc:
192
+ # SAY SO. A swallowed failure here leaves a worktree that resolves
193
+ # its dependencies from a sibling -- the precise thing this function
194
+ # exists to prevent -- and the audit would then report someone
195
+ # else's results as this sprint's, with nothing in the output to
196
+ # suggest anything went wrong.
197
+ print("post-fold-audit: WARNING could not link %s into %s (%s).\n"
198
+ " Suites run in this worktree may resolve dependencies from\n"
199
+ " another tree; treat their results as unproven."
200
+ % (rel, path, exc), file=sys.stderr)
201
+ return path, ""
202
+
203
+
204
+ def drop(path):
205
+ if not path:
206
+ return
207
+ git(["worktree", "remove", "--force", path])
208
+ shutil.rmtree(os.path.dirname(path), ignore_errors=True)
209
+
210
+
211
+ def orchestrator_delta(patch, base, findings):
212
+ """What the orchestrator wrote at merge time, and nothing else."""
213
+ name = os.path.basename(patch)
214
+ rc, out = sh(["git", "apply", "--numstat", patch], ENTROPY_MACHINES_ROOT)
215
+ if rc != 0:
216
+ findings.append("%s: unreadable as a patch -- %s" % (name, out.strip()[:160]))
217
+ return
218
+ files = [l.split("\t")[-1] for l in out.splitlines() if "\t" in l]
219
+ if not files:
220
+ findings.append("%s: patch touches no files" % name)
221
+ return
222
+
223
+ path, err = worktree(base, "delta")
224
+ if not path:
225
+ findings.append("%s: no worktree at %s -- %s" % (name, base, err.strip()[:160]))
226
+ return
227
+ try:
228
+ rc, out = sh(["git", "apply", "--binary", "-3", patch], path)
229
+ if rc != 0:
230
+ # Not applying to its OWN base means the base is wrong, which makes
231
+ # any delta below meaningless. Say so; do not guess.
232
+ findings.append("%s: does not apply to --base %s; delta NOT computed -- %s"
233
+ % (name, base, out.strip()[:160]))
234
+ return
235
+ print("\n=== ORCHESTRATOR DELTA - %s (base %s)" % (name, base[:9]))
236
+ changed = []
237
+ for f in files:
238
+ a, b = os.path.join(path, f), os.path.join(ENTROPY_MACHINES_ROOT, f)
239
+ if not os.path.exists(b):
240
+ changed.append((f, "deleted after fold"))
241
+ continue
242
+ if not os.path.exists(a):
243
+ continue
244
+ rc3, d = sh(["git", "diff", "--no-index", "--numstat", a, b], ENTROPY_MACHINES_ROOT)
245
+ if d.strip():
246
+ n = d.split("\t")[:2]
247
+ changed.append((f, "+%s/-%s" % (n[0], n[1]) if len(n) == 2 else "differs"))
248
+ if not changed:
249
+ print(" none - what landed is byte-identical to what the worker wrote")
250
+ else:
251
+ for f, how in changed:
252
+ print(" %s %s" % (f, how))
253
+ findings.append(
254
+ "%s: %d file(s) differ from the worker's patch. That is "
255
+ "orchestrator-written code -- confirm each hunk is covered by a "
256
+ "test you mutated yourself." % (name, len(changed)))
257
+ finally:
258
+ drop(path)
259
+
260
+
261
+ def clean_tree(skip_tags, findings):
262
+ suites = build_suites(CONFIG)
263
+ if not suites:
264
+ findings.append("no suites configured -- config.json's suites[] is empty; "
265
+ "nothing was run at HEAD")
266
+ return None, {}
267
+ rc, head = git(["rev-parse", "HEAD"])
268
+ head = head.strip()
269
+ path, err = worktree(head, "cleantree")
270
+ if not path:
271
+ findings.append("cannot create clean worktree -- %s" % err.strip()[:160])
272
+ return None, {}
273
+ results = {}
274
+ try:
275
+ print("\n=== CLEAN TREE AT HEAD %s" % head[:9])
276
+ for label, cmd, tag, cwd_rel in suites:
277
+ if tag and tag in skip_tags:
278
+ results[label] = "skipped (--skip %s)" % tag
279
+ print(" %-12s skipped (--skip %s)" % (label, tag))
280
+ continue
281
+ cwd = os.path.join(path, cwd_rel)
282
+ t0 = time.time()
283
+ try:
284
+ rc, out = sh(cmd, cwd)
285
+ except subprocess.TimeoutExpired:
286
+ results[label] = "TIMEOUT"
287
+ findings.append("clean tree: %s timed out" % label)
288
+ print(" %-12s TIMEOUT" % label)
289
+ continue
290
+ secs = int(time.time() - t0)
291
+ # The SUMMARY line, not the last line. npm's last line is often a
292
+ # node_modules path, which reads as a pass whether 1849 tests ran or
293
+ # zero did -- and a hollow green is the exact thing this script
294
+ # exists to catch.
295
+ summary = ""
296
+ for l in out.splitlines():
297
+ t = l.strip()
298
+ if (t.startswith("Tests ") or t.startswith("ok ")
299
+ or " passed" in t or t.startswith("--- FAIL")):
300
+ summary = t
301
+ if rc == 0:
302
+ results[label] = "ok (%ds) %s" % (secs, summary[:60])
303
+ print(" %-12s ok %ds %s" % (label, secs, summary[:78]))
304
+ else:
305
+ results[label] = "FAILED (%ds)" % secs
306
+ findings.append("clean tree: %s FAILED at HEAD -- your own "
307
+ "checkout may be masking it" % label)
308
+ print(" %-12s FAILED %ds" % (label, secs))
309
+ for l in out.splitlines():
310
+ if l.startswith("FAIL") or "--- FAIL" in l or "error TS" in l:
311
+ print(" %s" % l[:118])
312
+ finally:
313
+ drop(path)
314
+ return head, results
315
+
316
+
317
+ def write_stamp(head, results, partial):
318
+ try:
319
+ os.makedirs(os.path.dirname(STAMP), exist_ok=True)
320
+ with open(STAMP, "w", encoding="utf-8") as f:
321
+ json.dump({"commit": head,
322
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"),
323
+ "partial": bool(partial),
324
+ "suites": results}, f, indent=1)
325
+ print("\nstamp: %s -> %s" % (STAMP, head[:9]))
326
+ except OSError as e:
327
+ print("stamp NOT written (%s) - whatever reads this stamp will see the sprint "
328
+ "as unchecked" % e)
329
+
330
+
331
+ def main():
332
+ ap = argparse.ArgumentParser(add_help=True)
333
+ ap.add_argument("--since", help="ref the sprint's folds start after")
334
+ ap.add_argument("--patch", action="append", default=[], metavar="FILE")
335
+ ap.add_argument("--base", help="commit each --patch was written against")
336
+ ap.add_argument("--clean-tree-only", action="store_true")
337
+ ap.add_argument("--skip", action="append", default=[], metavar="TAG",
338
+ help="skip every suite carrying this config.json suites[] tag "
339
+ "(repeatable); a partial stamp does NOT close a sprint")
340
+ ap.add_argument("--quick", action="store_true",
341
+ help="shorthand for --skip slow")
342
+ a = ap.parse_args()
343
+
344
+ if os.path.realpath(os.getcwd()) != os.path.realpath(ENTROPY_MACHINES_ROOT):
345
+ print("post-fold-audit: run from %s" % ENTROPY_MACHINES_ROOT, file=sys.stderr)
346
+ return 2
347
+ if a.patch and not a.base:
348
+ print("post-fold-audit: --patch needs --base", file=sys.stderr)
349
+ return 2
350
+
351
+ skip_tags = set(a.skip)
352
+ if a.quick:
353
+ skip_tags.add("slow")
354
+
355
+ findings = []
356
+ if not a.clean_tree_only:
357
+ for p in a.patch:
358
+ orchestrator_delta(p, a.base, findings)
359
+ if not a.patch:
360
+ print("no --patch given: the orchestrator-delta half was SKIPPED, not passed")
361
+ head, results = clean_tree(skip_tags, findings)
362
+ if head:
363
+ write_stamp(head, results, bool(skip_tags))
364
+
365
+ print("\n=== FINDINGS")
366
+ if findings:
367
+ for f in findings:
368
+ print(" - %s" % f)
369
+ else:
370
+ print(" none")
371
+ if skip_tags:
372
+ print(" (--skip %s: this stamp does not close a sprint)" % ",".join(sorted(skip_tags)))
373
+ return 1 if findings else 0
374
+
375
+
376
+ if __name__ == "__main__":
377
+ sys.exit(main())