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.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- 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())
|