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/status ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """status — print live factory state. Reads; writes nothing.
3
+
4
+ bin/status
5
+
6
+ Three questions, answered from the tracker and the docs directory:
7
+
8
+ what is ready to work on (bin/tracker ready)
9
+ what is in flight (DISPATCH notes with no HANDOFF yet)
10
+ which docs have open questions (unanswered .response boxes)
11
+
12
+ It is a READ. Nothing here files, claims, dispatches or edits — so it is safe
13
+ to run at the top of any session, on a shared checkout, or in a loop.
14
+
15
+ This is the one surviving half of what used to be `bin/adopt prime`, the body
16
+ of a Claude Code SessionStart hook that bin/adopt installed into
17
+ .claude/settings.json. That whole mechanism — a hashed managed block written
18
+ into someone else's instruction file, a consent prompt, a settings.json edit,
19
+ per-runner profiles — is gone. The PRINTING is what was actually worth having,
20
+ and it is worth having as a command a human or an agent can just run.
21
+ """
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import sys
27
+
28
+ # ENTROPY_MACHINES_HOME is the harness DIRECTORY — where bin/tracker sits — which may be
29
+ # the repo root or a subdirectory of it. Not a root and not a separate repo;
30
+ # see lib/roots.sh.
31
+ ENTROPY_MACHINES_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ sys.path.insert(0, os.path.join(ENTROPY_MACHINES_HOME, "lib"))
33
+ import config # noqa: E402 -- needs ENTROPY_MACHINES_HOME on the path first
34
+
35
+
36
+ def entropy_machines_root():
37
+ """Mirrors lib/roots.sh's entropy_machines_root(): the repository's main checkout,
38
+ or None. `git rev-parse --git-common-dir` -- NOT --show-toplevel, which
39
+ prints a linked WORKTREE's own path; .entropy/ is gitignored and absent
40
+ from every worktree, so that would report an empty tracker as "0 ready"
41
+ rather than as an error. Falls back to walking up for config.json when
42
+ there is no git."""
43
+ try:
44
+ p = subprocess.run(
45
+ ["git", "rev-parse", "--git-common-dir"],
46
+ capture_output=True, text=True, timeout=10,
47
+ )
48
+ except (OSError, subprocess.SubprocessError):
49
+ p = None
50
+ if p is not None and p.returncode == 0 and p.stdout.strip():
51
+ common = p.stdout.strip()
52
+ if not os.path.isabs(common):
53
+ common = os.path.join(os.getcwd(), common)
54
+ return os.path.realpath(os.path.join(common, os.pardir))
55
+ d = os.getcwd()
56
+ while True:
57
+ if os.path.exists(os.path.join(d, "config.json")):
58
+ return d
59
+ parent = os.path.dirname(d)
60
+ if parent == d:
61
+ return None
62
+ d = parent
63
+
64
+
65
+ def docs_dir_for(root):
66
+ """config.json's docs.dir, defaulting to entropy-machines-docs -- the same
67
+ default bin/init writes to and bin/serve reads from. Never raises."""
68
+ try:
69
+ with open(os.path.join(ENTROPY_MACHINES_HOME, "config.json"), encoding="utf-8") as f:
70
+ cfg = json.load(f)
71
+ d = (cfg.get("docs") or {}).get("dir")
72
+ if isinstance(d, str) and d.strip():
73
+ return d.strip().strip("/")
74
+ except Exception:
75
+ pass
76
+ return "entropy-machines-docs"
77
+
78
+
79
+ def scan_unanswered_docs(root):
80
+ """Best-effort: dialogue docs in this harness's convention mark each
81
+ question with <div class="response" data-resp="KEY"> and record answers in
82
+ a <script id="responses-data"> JSON blob (see bin/serve,
83
+ lib/doc-template.html). Returns [(path, unanswered_count), ...] for docs
84
+ with at least one unanswered question. Never raises -- a doc this cannot
85
+ parse is skipped."""
86
+ # THE CONFIGURED DOCS DIRECTORY. bin/init installs the orientation PRD into
87
+ # docs.dir and bin/serve serves it from there; this must agree with both.
88
+ docs = os.path.join(root, docs_dir_for(root))
89
+ if not os.path.isdir(docs):
90
+ return []
91
+ out = []
92
+ resp_re = re.compile(r'class="response"[^>]*data-resp="([^"]+)"')
93
+ data_re = re.compile(r'<script[^>]*id="responses-data"[^>]*>(.*?)</script>', re.DOTALL)
94
+ for fname in sorted(os.listdir(docs)):
95
+ if not fname.endswith(".html"):
96
+ continue
97
+ path = os.path.join(docs, fname)
98
+ try:
99
+ with open(path, encoding="utf-8") as f:
100
+ content = f.read()
101
+ # Strip HTML comments first. lib/doc-template.html's own header
102
+ # comment contains a literal data-resp example -- counting it made
103
+ # every doc built from the template report one phantom question
104
+ # nobody could ever answer, so a fully-answered doc kept nagging.
105
+ content = re.sub(r"<!--[\s\S]*?-->", "", content)
106
+ keys = set(resp_re.findall(content))
107
+ if not keys:
108
+ continue
109
+ m = data_re.search(content)
110
+ answered = set()
111
+ if m:
112
+ try:
113
+ answered = {k for k, v in json.loads(m.group(1)).items() if str(v).strip()}
114
+ except (json.JSONDecodeError, AttributeError):
115
+ pass
116
+ unanswered_n = len(keys - answered)
117
+ if unanswered_n:
118
+ out.append((path, unanswered_n))
119
+ except OSError:
120
+ continue
121
+ return out
122
+
123
+
124
+ def main(argv):
125
+ if argv and argv[0] in ("-h", "--help"):
126
+ print(__doc__.strip("\n"))
127
+ return 0
128
+ if argv:
129
+ print("usage: bin/status", file=sys.stderr)
130
+ return 2
131
+
132
+ config.refuse_nested_clone("status")
133
+ root = entropy_machines_root()
134
+ if not root or not os.path.isfile(os.path.join(ENTROPY_MACHINES_HOME, "config.json")):
135
+ print("entropy-machines: not configured here -- run %s/bin/init" % ENTROPY_MACHINES_HOME)
136
+ return 0
137
+
138
+ tracker = os.path.join(ENTROPY_MACHINES_HOME, "bin", "tracker")
139
+
140
+ def run_tracker(args):
141
+ try:
142
+ p = subprocess.run(
143
+ [tracker] + args, cwd=root, capture_output=True, text=True, timeout=15,
144
+ )
145
+ except (OSError, subprocess.SubprocessError):
146
+ return None
147
+ if p.returncode != 0:
148
+ return None
149
+ return p.stdout
150
+
151
+ ready_out = run_tracker(["ready"])
152
+ if ready_out is None:
153
+ print("entropy-machines: tracker unavailable (bin/tracker ready failed)")
154
+ else:
155
+ ready = []
156
+ for line in ready_out.splitlines():
157
+ line = line.strip()
158
+ if not line:
159
+ continue
160
+ try:
161
+ ready.append(json.loads(line))
162
+ except json.JSONDecodeError:
163
+ continue
164
+ if ready:
165
+ print("entropy-machines: %d issue(s) ready -- `bin/tracker ready`" % len(ready))
166
+ for i in ready[:10]:
167
+ print(" %s %s" % (i.get("id", "?"), i.get("title") or i.get("summary") or ""))
168
+ if len(ready) > 10:
169
+ print(" ... and %d more" % (len(ready) - 10))
170
+ else:
171
+ print("entropy-machines: 0 issues ready")
172
+
173
+ notes_out = run_tracker(["notes"])
174
+ if notes_out is not None:
175
+ state = {}
176
+ for line in notes_out.splitlines():
177
+ line = line.strip()
178
+ if not line:
179
+ continue
180
+ try:
181
+ rec = json.loads(line)
182
+ except json.JSONDecodeError:
183
+ continue
184
+ issue = rec.get("issue")
185
+ if not issue:
186
+ continue
187
+ verb = rec.get("verb")
188
+ if verb == "DISPATCH":
189
+ state[issue] = "dispatched"
190
+ elif verb == "HANDOFF":
191
+ state[issue] = "handed_off"
192
+ in_flight = [i for i, st in state.items() if st == "dispatched"]
193
+ if in_flight:
194
+ print("entropy-machines: %d issue(s) in flight (dispatched, not yet handed off): %s"
195
+ % (len(in_flight), ", ".join(sorted(in_flight))))
196
+
197
+ unanswered = scan_unanswered_docs(root)
198
+ if unanswered:
199
+ total_q = sum(n for _, n in unanswered)
200
+ print("entropy-machines: %d unanswered question(s) across %d doc(s) in %s/:"
201
+ % (total_q, len(unanswered), docs_dir_for(root)))
202
+ for path, n in unanswered:
203
+ print(" %s -- %d unanswered" % (os.path.relpath(path, root), n))
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main(sys.argv[1:]))
package/bin/tracker ADDED
@@ -0,0 +1,153 @@
1
+ #!/bin/sh
2
+ # Adapter dispatcher for the issue tracker. Every caller in this harness goes
3
+ # through this file instead of a backend directly — see
4
+ # docs/TRACKER-ADAPTER.md for the six-command contract every backend
5
+ # implements, and docs/CONFIG.md for config.json.
6
+ #
7
+ # bin/tracker show i-foo
8
+ # bin/tracker notes --issue i-foo
9
+ # bin/tracker remember --issue i-foo "..."
10
+ # bin/tracker claim i-foo
11
+ # bin/tracker ready
12
+ # bin/tracker set i-foo status=done
13
+ # bin/tracker render
14
+ #
15
+ # `render` is the ONE word this file answers itself. It is not a seventh
16
+ # adapter operation and never reaches a backend: it writes TRACKER.html, a
17
+ # read-only browsable view of the store, into docs.dir (lib/tracker-view.py).
18
+ # Rendering is a view over what a backend already holds, so requiring it of
19
+ # every future backend would be asking each one to ship an HTML generator.
20
+ #
21
+ # `tracker.backend` in config.json picks the implementation: "file" runs the
22
+ # built-in lib/tracker-file over a flat JSON file, "command" shells out to an
23
+ # external tracker binary. This file reads that config once and never again —
24
+ # a backend never re-reads config.json itself (docs/CONFIG.md rule 3).
25
+ #
26
+ # WHY THE WHOLE DISPATCH RUNS IN A SUBSHELL — this is not ergonomics, it is a
27
+ # correctness guard inherited unchanged from the tool this one replaced. That
28
+ # tool's entire job was to `cd` into a hardcoded nested tracker directory
29
+ # before running its backend, because the backend resolved its storage
30
+ # relative to the process's cwd. A caller whose shell keeps its working
31
+ # directory between commands — an agent harness with a persistent shell is
32
+ # exactly this shape — had that `cd` leak into every later command in the
33
+ # same session, silently, and a worktree or a dispatch created after the leak
34
+ # was rooted in the wrong repository. It happened three times before the fix,
35
+ # twice in one day, taking out two dispatched agents at once.
36
+ #
37
+ # This file no longer needs to `cd` anywhere itself — the file backend takes
38
+ # its storage path as configuration, not as an ambient cwd — but the guard is
39
+ # kept anyway and generalized: a `command` backend is arbitrary external code,
40
+ # and nothing stops it from changing directory or leaving other state behind.
41
+ # Everything below runs inside `( ... )`, so nothing the resolved backend does
42
+ # to the working directory survives past this script returning. Do not let
43
+ # the final backend invocation become a bare top-level `exec`; that removes
44
+ # the one property this file exists to guarantee.
45
+ set -e
46
+
47
+ usage() {
48
+ cat >&2 <<'EOF'
49
+ usage: bin/tracker show <id>
50
+ bin/tracker notes [--issue <id>]
51
+ bin/tracker remember --issue <id> <text>
52
+ bin/tracker claim <id>
53
+ bin/tracker ready
54
+ bin/tracker set <id> <key>=<value> [<key>=<value> ...]
55
+ bin/tracker render
56
+ EOF
57
+ exit 2
58
+ }
59
+
60
+ [ $# -ge 1 ] || usage
61
+
62
+ . "$(dirname "$0")/../lib/roots.sh"
63
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
64
+ entropy_machines_require_root tracker
65
+
66
+ config="$ENTROPY_MACHINES_HOME/config.json"
67
+
68
+ # One read of config.json, here, once. Prints four tab-separated fields:
69
+ # backend "file" or "command" (defaults to "file")
70
+ # file backend path tracker.file.path, defaulted
71
+ # command bin tracker.command.bin
72
+ # command args tracker.command.args, space-joined (an argument that
73
+ # itself contains a space is not representable this
74
+ # way — the same limitation any space-joined shell
75
+ # config carries; a backend needing one should read its
76
+ # own richer config, not rely on this passthrough)
77
+ info=$(python3 - "$config" <<'PY'
78
+ import json, sys
79
+ with open(sys.argv[1], encoding="utf-8") as f:
80
+ cfg = json.load(f)
81
+ t = cfg.get("tracker") or {}
82
+ backend = t.get("backend") or "file"
83
+ file_path = ((t.get("file") or {}).get("path")) or ".entropy/issues.json"
84
+ command = t.get("command") or {}
85
+ cmd_bin = command.get("bin") or ""
86
+ cmd_args = " ".join(command.get("args") or [])
87
+ # Fields 5 and 6 are for `render` only. They are read HERE, in the one place
88
+ # this harness reads config.json per command (docs/CONFIG.md rule 3), rather
89
+ # than in lib/tracker-view.py — which must stay as ignorant of the config file
90
+ # as a backend is.
91
+ docs_dir = ((cfg.get("docs") or {}).get("dir")) or "entropy-machines-docs"
92
+ project_name = ((cfg.get("project") or {}).get("name")) or ""
93
+ print("\t".join([backend, file_path, cmd_bin, cmd_args, docs_dir, project_name]))
94
+ PY
95
+ ) || {
96
+ echo "tracker: REFUSED — could not read tracker config out of $config." >&2
97
+ exit 2
98
+ }
99
+
100
+ backend=$(printf '%s' "$info" | cut -f1)
101
+ file_path=$(printf '%s' "$info" | cut -f2)
102
+ cmd_bin=$(printf '%s' "$info" | cut -f3)
103
+ cmd_args=$(printf '%s' "$info" | cut -f4)
104
+ docs_dir=$(printf '%s' "$info" | cut -f5)
105
+ project_name=$(printf '%s' "$info" | cut -f6)
106
+
107
+ # RENDER IS INTERCEPTED HERE, before the backend dispatch, so no backend is
108
+ # ever handed a word the six-command contract does not contain. It still runs
109
+ # inside the same subshell guard everything else here does.
110
+ if [ "$1" = "render" ]; then
111
+ shift
112
+ view="$ENTROPY_MACHINES_HOME/lib/tracker-view.py"
113
+ if [ ! -f "$view" ]; then
114
+ echo "tracker: REFUSED — bin/tracker render needs $view, which is missing." >&2
115
+ exit 2
116
+ fi
117
+ (
118
+ cd "$ENTROPY_MACHINES_ROOT" &&
119
+ ENTROPY_MACHINES_TRACKER_PATH="$file_path" \
120
+ ENTROPY_MACHINES_TRACKER_BACKEND="$backend" \
121
+ ENTROPY_MACHINES_DOCS_DIR="$docs_dir" \
122
+ ENTROPY_MACHINES_PROJECT_NAME="$project_name" \
123
+ exec python3 "$view" "$@"
124
+ )
125
+ exit $?
126
+ fi
127
+
128
+ case "$backend" in
129
+ file)
130
+ impl="$ENTROPY_MACHINES_HOME/lib/tracker-file"
131
+ if [ ! -x "$impl" ]; then
132
+ echo "tracker: REFUSED — tracker.backend is \"file\" but $impl is missing" >&2
133
+ echo " or not executable." >&2
134
+ exit 2
135
+ fi
136
+ # ENTROPY_MACHINES_ROOT is already exported by entropy_machines_require_root; lib/tracker-file
137
+ # reads it as the root its storage path is relative to.
138
+ ( cd "$ENTROPY_MACHINES_ROOT" && ENTROPY_MACHINES_TRACKER_PATH="$file_path" exec "$impl" "$@" )
139
+ ;;
140
+ command)
141
+ if [ -z "$cmd_bin" ]; then
142
+ echo "tracker: REFUSED — tracker.backend is \"command\" but tracker.command.bin" >&2
143
+ echo " is empty in $config." >&2
144
+ exit 2
145
+ fi
146
+ ( cd "$ENTROPY_MACHINES_ROOT" && exec "$cmd_bin" $cmd_args "$@" )
147
+ ;;
148
+ *)
149
+ echo "tracker: REFUSED — unknown tracker.backend \"$backend\" in $config" >&2
150
+ echo " (want \"file\" or \"command\")." >&2
151
+ exit 2
152
+ ;;
153
+ esac
@@ -0,0 +1,86 @@
1
+ # Agent quickstart
2
+
3
+ This doc is for the coding agent bootstrapping and running the harness.
4
+ If you are the project owner (a human), start at [../README.md](../README.md).
5
+ The *why*: [../doctrine/](../doctrine/).
6
+
7
+ Four roles move one issue through a fixed cycle — dispatch, work, verify, fold —
8
+ enforced by shell gates, not convention. Decisions happen in served HTML docs with
9
+ response boxes, not in chat. Plain tracked files, one git root; prefix the commands
10
+ below if they sit in a subdirectory.
11
+
12
+ ## Step 0 — no `config.json` yet
13
+
14
+ Your first job and the whole job. Don't stop for permission between steps.
15
+
16
+ 1. **Point `CLAUDE.md` and/or `AGENTS.md` at it** — both if both exist, rewriting
17
+ nothing already there:
18
+
19
+ ```markdown
20
+ ## entropy-machines
21
+ Vendored at `<path>`. **Owner** decides and ticks. **Worker** does one scoped
22
+ issue in an isolated worktree and commits nothing. **Verifier** sweeps a sprint
23
+ once, on a clean tree. **Orchestrator** dispatches, folds, lands — the only
24
+ committer. `<path>/bin/status` = live state, `bin/tracker ready` = find work,
25
+ `bin/dispatch` = the gate before any writing agent, `bin/handoff` = the only way
26
+ work lands. Doctrine: `<path>/doctrine/`.
27
+ ```
28
+
29
+ 2. **`bin/init`** — writes `config.json`, gitignores `.entropy-machines/`, copies the
30
+ orientation PRD into `docs.dir`. A second run exits 2; that refusal is working.
31
+
32
+ 3. **Baseline discovery — record only what you RAN.** `init` writes `suites: []`
33
+ rather than guess. **Run** the candidate test/typecheck/build commands and enter
34
+ only those that passed; one that fails is a finding, not an entry. Then fill the
35
+ PRD's "What we found in your repo" page, saying what you couldn't verify. Leave
36
+ the open questions — the owner's.
37
+
38
+ 4. **Start `bin/serve`** — actually start it, backgrounded; it holds the terminal.
39
+ Binds `127.0.0.1` and prints its URL. Run `bin/doclint` first.
40
+
41
+ Hand over that URL and **stop**. A PRD you answered yourself produces issues nobody
42
+ agreed to.
43
+
44
+ ## The commands
45
+
46
+ ```
47
+ bin/status # read-only: ready, in flight, unanswered
48
+ bin/tracker show|notes|remember|claim|ready|set # six operations, no more
49
+ bin/dispatch <id> --files "…" --brief "…" # the gate; paste its block verbatim
50
+ bin/handoff <id> --from <worktree> --verified "<what YOU ran>"
51
+ bin/serve [port] · bin/doclint [path…]
52
+ ```
53
+
54
+ `tracker ready` is the state of the world, not a doc's account of it: it excludes
55
+ held (`heldWhy`) and gated (`gate`), neither a status value — `status` holds only
56
+ `notstarted`/`progress`/`done`. `set` auto-vivifies; there is no `add`.
57
+ `dispatch --files` is advisory; what's enforced is the denylist of files other
58
+ agents hold, pasted into your brief. `handoff --from` lifts the worker's four
59
+ lines; `--verified` never is. See [CONFIG.md](CONFIG.md), [SERVE.md](SERVE.md),
60
+ [TRACKER-ADAPTER.md](TRACKER-ADAPTER.md).
61
+
62
+ ## Never
63
+
64
+ Commit, unless you are the orchestrator landing via `handoff`. Claim a command you
65
+ did not run. Write a file another live dispatch holds — stop, ship nothing, name it
66
+ in `HANDOFF.md`'s `found:`. Touch another worktree, or run the full build (it
67
+ rewrites committed bookkeeping — use `suites`). Relay another agent's verification.
68
+ Land held work.
69
+
70
+ ## Limits
71
+
72
+ - **`handoff` cannot read `dispatch`'s notes** (JSONL vs. the older free-text
73
+ form), so `--lift` copies a held file instead of refusing, interrogation is never
74
+ required, and a recorded handoff never releases the claim. Read dispatch's
75
+ overlap warning yourself.
76
+ - **`--lift` exits 1 on `.scratch`** — delete that symlink from the worktree, rerun.
77
+ - **Claude-native in practice.** The gates are POSIX shell; automatic parallel
78
+ workers in isolated worktrees is a Claude Code capability. Another runner gets
79
+ the same gates one issue at a time, using `agents/isolated-worker.md` by hand.
80
+ - **Isolation can silently not happen.** `isolation: worktree` branches the repo the
81
+ *calling session's cwd* is in, so driving one project from a shell in another
82
+ hands every worker the wrong repo — or one shared checkout where agents see each
83
+ other's edits. Workers check `git rev-parse --git-common-dir` (never
84
+ `--show-toplevel`); orchestrators verify cwd, keep scopes disjoint and never `git
85
+ add -A` while a lane is live. No gate covers it —
86
+ [../doctrine/WORKFLOW.md](../doctrine/WORKFLOW.md).
package/docs/CONFIG.md ADDED
@@ -0,0 +1,68 @@
1
+ # config.json — the project contract
2
+
3
+ Everything the harness would otherwise hardcode about its host project, at the
4
+ repo root. Nothing in `bin/` or `lib/` may name a language, package manager or
5
+ directory not read from here. It is also the root marker.
6
+
7
+ `bin/init` writes the first one. It files **no issues** (a PRD is what creates
8
+ issues) and guesses at no command it hasn't run — a fabricated suite makes the
9
+ harness report a broken command as a broken project.
10
+
11
+ ```json
12
+ {
13
+ "project": { "name": "my-project", "protectedPaths": [] },
14
+ "tracker": { "backend": "file", "file": { "path": ".entropy-machines/issues.json" } },
15
+ "docs": { "dir": "entropy-machines-docs", "theme": "high-contrast" },
16
+ "suites": [ { "name": "unit", "cmd": ["npm", "test"] },
17
+ { "name": "e2e", "cmd": ["npm", "run", "e2e"], "tag": "slow" } ],
18
+ "generate": { "cmd": ["npm", "run", "gen"], "outputs": [] },
19
+ "changelog": { "enabled": true, "fragmentDir": "changelog.d",
20
+ "collatedFile": "docs/CHANGELOG.md",
21
+ "marker": "<!-- BEGIN COLLATED changelog.d -->" },
22
+ "guards": { "testPathPatterns": ["tests/**"], "nonCodePatterns": ["**/*.md"],
23
+ "buildFailureMarkers": [] },
24
+ "worktree": { "linkPaths": ["node_modules"] },
25
+ "unattended": { "enabled": false, "scheduler": "launchd",
26
+ "agent": { "cmd": ["claude", "-p"] } }
27
+ }
28
+ ```
29
+
30
+ Every key is optional — deep-merged over `lib/config.py`'s `DEFAULTS`.
31
+
32
+ | Key | What it means |
33
+ |---|---|
34
+ | `project.protectedPaths` | Off-limits to a dispatched agent without an override. |
35
+ | `tracker.backend` | `file`, or `command` for your own tracker — [TRACKER-ADAPTER.md](TRACKER-ADAPTER.md). |
36
+ | `docs.dir` / `.theme` | What `bin/serve` serves; `theme` names a file in `lib/themes/`. |
37
+ | `suites` | What "the suites" means. `post-fold-audit` and the verifier run these; `tag` is skippable. |
38
+ | `generate` | `handoff` runs it and fails the fold if anything moves. |
39
+ | `changelog` | Whether a fragment is required per commit. Defaults on. |
40
+ | `guards` | How fail-first classifies a revert, and tells a build failure from a test failure. |
41
+ | `worktree.linkPaths` | Symlinked into every worktree. Without `node_modules` a runtime resolves out of a SIBLING worktree and the agent verifies someone else's code. |
42
+ | `unattended` | The drain loop. `scheduler` is `launchd` (macOS) or `systemd` (a `systemctl --user` timer); omit the key and `bin/drain install` detects one, refusing if neither is present. `on/off/status/now/at` work anywhere. |
43
+
44
+ ## Themes and root
45
+
46
+ `docs.theme` resolves to `lib/themes/<name>.css` — `high-contrast` (default,
47
+ dark-first) or `daylight` (light-first). A theme is a `:root` token block and
48
+ nothing else, so layout stays in the templates; every theme must define the same
49
+ names with a real value in the bare `:root`, since one defined only under
50
+ `[data-theme=…]` renders unstyled by default.
51
+ `tests/cases/themes-ship-and-apply.sh` catches that by diffing the name sets.
52
+ How `bin/serve` inlines it: [SERVE.md](SERVE.md).
53
+
54
+ The harness is vendored as **plain tracked files**, never a git repository of
55
+ its own — a nested `.git` shadows the enclosing repo, so commands operate on the
56
+ wrong one; `git clone` and `git submodule` are refused by name. **Root** is
57
+ always the MAIN CHECKOUT, via `--git-common-dir`, never `--show-toplevel`: they
58
+ differ inside a linked worktree, and `.entropy-machines/` lives in the main checkout.
59
+ `lib/roots.sh` is canonical; `lib/config.py` and `lib/config.mjs` mirror it.
60
+
61
+ ## Rules for contributors
62
+
63
+ 1. A new hardcoded path, command or noun in `bin/` or `lib/` is a bug — add a key.
64
+ 2. Every key has a working default, or the harness refuses naming the missing
65
+ key. Silent fallback is what made the original un-portable.
66
+ 3. Read once per process and passed down; never re-read from library code.
67
+ 4. Anything resolving root independently uses `--git-common-dir` and cites
68
+ `lib/roots.sh`. Three implementations already drifted apart once.
package/docs/NPM.md ADDED
@@ -0,0 +1,91 @@
1
+ # The npm package
2
+
3
+ `npx entropy-machines init` is a **delivery mechanism, not a runtime**. It
4
+ copies the harness into your repository as plain tracked files and runs
5
+ `bin/init`. After that npm is gone: nothing vendored imports from
6
+ `node_modules`, nothing shells out to `node`, and the harness runs on git, a
7
+ POSIX shell and Python 3. Deleting `node_modules/` changes nothing.
8
+
9
+ The other route is identical in outcome and needs no Node at all — clone this
10
+ repo and copy `bin/`, `lib/`, `docs/`, `doctrine/`, `hooks/` and `agents/`
11
+ into your project.
12
+
13
+ ## Usage
14
+
15
+ ```sh
16
+ npx entropy-machines init # -> entropy-machines/ at the repo root
17
+ npx entropy-machines init --dir . # -> vendored at the repo root instead
18
+ npx entropy-machines --help
19
+ npx entropy-machines --version
20
+ ```
21
+
22
+ `init` copies the six directories plus `LICENSE`, then runs the vendored
23
+ `bin/init` with the repository root as its working directory. To undo it,
24
+ delete the directory. There is no uninstall command, no install receipt and
25
+ no version check, on purpose.
26
+
27
+ ## Why a subdirectory by default
28
+
29
+ `entropy-machines/` is the default because vendoring at the repo root merges
30
+ the harness's `bin/`, `lib/` and `docs/` into whatever the project already
31
+ has under those names, and those are three of the most commonly taken
32
+ directory names there are. A subdirectory has none of that: it is one
33
+ directory to add, one to `rm -rf`, and `git log` on it shows only harness
34
+ changes. `lib/roots.sh` derives `ENTROPY_MACHINES_HOME` from each script's own `$0`,
35
+ so both layouts work; `docs/AGENT-QUICKSTART.md` already tells the agent to prefix
36
+ every `bin/…` command with the vendored path.
37
+
38
+ This is **not** the old nested clone. There is no `.git` in the vendored
39
+ directory, so `git` from anywhere inside your project still answers with your
40
+ project — one root, as `lib/roots.sh` requires. `entropy_machines_refuse_nested_clone`
41
+ refuses the layout that does have one.
42
+
43
+ Use `--dir .` if you want the root layout the README describes, and every
44
+ `bin/…` command in the docs verbatim.
45
+
46
+ ## What it refuses, before writing anything
47
+
48
+ | Situation | Why |
49
+ |---|---|
50
+ | Not inside a git repository | Vendored files have to be tracked by something. |
51
+ | The destination already holds any of the six directories | An older harness may have edited doctrine in it; your own `bin/` is not ours to merge into. Pass `--dir <path>`. |
52
+ | The destination is inside the package's own directory, or a checkout of this package is inside the destination | It would copy itself over itself, and in a `npm link`ed checkout that tree is where a real `.git` lives. An *installed* copy at `<repo>/node_modules/entropy-machines` is exempt — that is what `npm i -D entropy-machines` plus `--dir .` looks like, and the two trees never overlap. |
53
+ | The destination is outside the repository | Vendoring means committed alongside your code. |
54
+
55
+ A `.git` directory is never copied, at any depth — filtered by path segment,
56
+ not by trusting the tarball to lack one. A nested `.git` shadows the parent
57
+ repo for every git query and silently misroutes every command in the harness;
58
+ it is the single failure the vendored layout exists to prevent. `__pycache__`,
59
+ `node_modules` and `.DS_Store` are dropped the same way.
60
+
61
+ ## Publishing
62
+
63
+ `package.json` has no dependencies and no build step.
64
+
65
+ ```sh
66
+ npm pack --dry-run # the exact file list that will ship
67
+ npm publish
68
+ ```
69
+
70
+ `files` is a whitelist of the six directories, with `!**/__pycache__` and
71
+ `!**/*.pyc` negations — a `.npmignore` does **not** filter inside a
72
+ whitelisted directory, so the negations are what keeps compiled Python out.
73
+ `planning/`, `.entropy-machines/`, `tests/`, `example/`, `entropy-machines-docs/` and
74
+ `CONTRIBUTING.md` are excluded by not being listed; npm excludes `.git`
75
+ unconditionally. `LICENSE`, `README.md` and `package.json` are always
76
+ included — they ship even though `files` does not name them, so the shipped
77
+ list is the six directories plus those three.
78
+
79
+ Verified against a real `npm pack` on 2026-08-30: the tarball's file list is
80
+ exactly the six directories' tracked contents plus those three, with every
81
+ executable bit intact (20 of the vendored files land as `100755`).
82
+ `bin/entropy-machines-init` is the one packaged file NOT vendored, because it
83
+ is the npm wrapper rather than part of the harness.
84
+
85
+ `npm pack` reads the WORKING TREE, not HEAD. An uncommitted or untracked file
86
+ inside one of the six directories ships. Pack from a clean tree, and read the
87
+ `npm notice` file list before publishing rather than trusting `files`.
88
+
89
+ The package version is the npm artifact's version. It is not a harness
90
+ version and nothing checks it — there is no staleness detection anywhere in
91
+ this project.