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
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """tracker-file — the built-in flat-file tracker backend.
3
+
4
+ Implements the six operations docs/TRACKER-ADAPTER.md requires of any
5
+ backend, over one JSON file. Selected by `tracker.backend: "file"` in
6
+ config.json; never invoked directly — go through `bin/tracker`, which reads
7
+ that config once and passes this script what it needs as environment:
8
+
9
+ ENTROPY_MACHINES_ROOT repo root (relative paths below resolve against it)
10
+ ENTROPY_MACHINES_TRACKER_PATH where the JSON file lives, relative to ENTROPY_MACHINES_ROOT
11
+ (default .entropy-machines/issues.json)
12
+ ENTROPY_ACTOR who is calling, for claim/remember attribution
13
+ (default "unknown")
14
+
15
+ tracker-file show <id>
16
+ tracker-file notes [--issue <id>]
17
+ tracker-file remember --issue <id> <text>
18
+ tracker-file claim <id>
19
+ tracker-file ready
20
+ tracker-file set <id> <key>=<value> [<key>=<value> ...]
21
+
22
+ ON-DISK SHAPE:
23
+
24
+ {
25
+ "issues": {
26
+ "<id>": {
27
+ "status": "notstarted" | "progress" | "done",
28
+ "blockedBy": ["<id>", ...],
29
+ "heldWhy": "<reason>", "heldAt": "<ts>", # present iff held
30
+ "gate": "<handle>", "gatedAt": "<ts>", # present iff gated
31
+ "claimedBy": "<actor>", "claimedAt": "<ts>", # present iff claimed
32
+ "title": "...", "effort": "..."
33
+ }
34
+ },
35
+ "notes": [ {...one note record, see lib/notes.py...}, ... ]
36
+ }
37
+
38
+ HELD AND GATED ARE NOT STATUS VALUES. `status` only ever holds notstarted,
39
+ progress or done. Held-ness is the PRESENCE of `heldWhy`; gated-ness is the
40
+ presence of `gate`. That is deliberate, not a shortcut: the adapter contract
41
+ requires held to carry a reason and requires gating to fail closed on an
42
+ unknown handle. Tying both to a boolean-shaped field, rather than a fourth
43
+ and fifth status string, makes the first requirement structural — there is
44
+ no way to become held without supplying `heldWhy` in the same `set` call,
45
+ because held-ness has no existence apart from that field — and makes the
46
+ second trivially true: this backend has no registry of gate handles and
47
+ resolves none of them, so ANY non-empty `gate` excludes an issue from
48
+ `ready`. There is no such thing as a gate this backend recognizes as
49
+ cleared; clearing one is `set <id> gate=` (empty value), a decision made
50
+ by whoever is allowed to say the open question is answered, not a lookup.
51
+
52
+ ATOMICITY. Every write takes an exclusive lock on `<path>.lock` (fcntl,
53
+ POSIX — the whole harness already assumes a POSIX shell, so this adds no new
54
+ platform requirement) before reading, so a load-modify-save under
55
+ concurrent writers serializes rather than racing; the save itself writes to
56
+ a temp file in the same directory and `os.replace`s it into place, so a
57
+ reader — which takes no lock at all, deliberately, since replace is atomic —
58
+ never observes a torn file, only the version before or after a given write.
59
+ """
60
+ from __future__ import annotations
61
+
62
+ import contextlib
63
+ import fcntl
64
+ import json
65
+ import os
66
+ import sys
67
+ import tempfile
68
+
69
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
70
+ import notes as notes_mod # noqa: E402 (path must be set up first)
71
+
72
+ ALLOWED_STATUS = {"notstarted", "progress", "done"}
73
+ ALLOWED_KEYS = {"status", "heldWhy", "gate", "blockedBy", "title", "effort"}
74
+
75
+ PATH = None # resolved in main()
76
+
77
+
78
+ def _usage(msg=None):
79
+ if msg:
80
+ print(f"tracker-file: {msg}", file=sys.stderr)
81
+ print(__doc__, file=sys.stderr)
82
+ sys.exit(2)
83
+
84
+
85
+ def _new_issue() -> dict:
86
+ return {"status": "notstarted", "blockedBy": []}
87
+
88
+
89
+ def _load(path) -> dict:
90
+ if not os.path.exists(path):
91
+ return {"issues": {}, "notes": []}
92
+ with open(path, encoding="utf-8") as f:
93
+ data = json.load(f)
94
+ data.setdefault("issues", {})
95
+ data.setdefault("notes", [])
96
+ return data
97
+
98
+
99
+ def _save_atomic(path, data) -> None:
100
+ d = os.path.dirname(path) or "."
101
+ os.makedirs(d, exist_ok=True)
102
+ fd, tmp = tempfile.mkstemp(prefix=".tracker-file-", dir=d)
103
+ try:
104
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
105
+ json.dump(data, f, indent=2, sort_keys=True)
106
+ f.write("\n")
107
+ os.replace(tmp, path)
108
+ except Exception:
109
+ try:
110
+ os.unlink(tmp)
111
+ except OSError:
112
+ pass
113
+ raise
114
+
115
+
116
+ @contextlib.contextmanager
117
+ def locked(path):
118
+ d = os.path.dirname(path) or "."
119
+ os.makedirs(d, exist_ok=True)
120
+ with open(path + ".lock", "a+") as lf:
121
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
122
+ try:
123
+ yield
124
+ finally:
125
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
126
+
127
+
128
+ def _actor() -> str:
129
+ return os.environ.get("ENTROPY_ACTOR") or "unknown"
130
+
131
+
132
+ # --------------------------------------------------------------------------
133
+ # commands
134
+ # --------------------------------------------------------------------------
135
+
136
+ def cmd_show(args):
137
+ if len(args) != 1:
138
+ _usage("show <id>")
139
+ iid = args[0]
140
+ issue = _load(PATH)["issues"].get(iid)
141
+ if issue is None:
142
+ print(f"tracker-file: no such issue: {iid}", file=sys.stderr)
143
+ sys.exit(3)
144
+ out = dict(issue)
145
+ out["id"] = iid
146
+ print(json.dumps(out, indent=2, sort_keys=True))
147
+
148
+
149
+ def cmd_notes(args):
150
+ issue = None
151
+ it = iter(args)
152
+ for a in it:
153
+ if a == "--issue":
154
+ issue = next(it, None)
155
+ else:
156
+ _usage("notes [--issue <id>]")
157
+ for rec in _load(PATH)["notes"]:
158
+ if issue and rec.get("issue") != issue:
159
+ continue
160
+ print(notes_mod.format_record(rec))
161
+
162
+
163
+ def cmd_remember(args):
164
+ issue = None
165
+ text_parts = []
166
+ it = iter(args)
167
+ for a in it:
168
+ if a == "--issue":
169
+ issue = next(it, None)
170
+ else:
171
+ text_parts.append(a)
172
+ if not issue or not text_parts:
173
+ _usage("remember --issue <id> <text>")
174
+ text = text_parts[0] if len(text_parts) == 1 else " ".join(text_parts)
175
+
176
+ payload = notes_mod.decode_payload(text)
177
+ actor = os.environ.get("ENTROPY_ACTOR") or payload.get("actor") or "unknown"
178
+ record = notes_mod.build_record(
179
+ ts=notes_mod.now_iso(), verb=payload["verb"], issue=issue,
180
+ fields=payload["fields"], actor=actor,
181
+ )
182
+ with locked(PATH):
183
+ data = _load(PATH)
184
+ data["notes"].append(record)
185
+ _save_atomic(PATH, data)
186
+ print(notes_mod.format_record(record))
187
+
188
+
189
+ def cmd_claim(args):
190
+ if len(args) != 1:
191
+ _usage("claim <id>")
192
+ iid = args[0]
193
+ actor = _actor()
194
+ with locked(PATH):
195
+ data = _load(PATH)
196
+ issue = data["issues"].get(iid)
197
+ if issue is None:
198
+ print(f"tracker-file: no such issue: {iid}", file=sys.stderr)
199
+ sys.exit(3)
200
+
201
+ status = issue.get("status", "notstarted")
202
+ if status == "progress":
203
+ holder = issue.get("claimedBy")
204
+ if holder and holder != actor:
205
+ print(
206
+ f"tracker-file: REFUSED — {iid} is already claimed by "
207
+ f"{holder} (since {issue.get('claimedAt', '?')}).",
208
+ file=sys.stderr,
209
+ )
210
+ sys.exit(4)
211
+ print(json.dumps({"id": iid, "status": status, "claimedBy": holder}, sort_keys=True))
212
+ return
213
+
214
+ if status != "notstarted":
215
+ print(f"tracker-file: note — {iid} is '{status}', not claiming it.", file=sys.stderr)
216
+ print(json.dumps({"id": iid, "status": status, "claimed": False}, sort_keys=True))
217
+ return
218
+
219
+ issue["status"] = "progress"
220
+ issue["claimedBy"] = actor
221
+ issue["claimedAt"] = notes_mod.now_iso()
222
+ _save_atomic(PATH, data)
223
+ print(json.dumps({"id": iid, "status": "progress", "claimedBy": actor}, sort_keys=True))
224
+
225
+
226
+ def cmd_ready(args):
227
+ if args:
228
+ _usage("ready")
229
+ data = _load(PATH)
230
+ issues = data["issues"]
231
+ for iid, issue in sorted(issues.items()):
232
+ if issue.get("status", "notstarted") != "notstarted":
233
+ continue
234
+ if issue.get("heldWhy"):
235
+ continue
236
+ if issue.get("gate"):
237
+ continue
238
+ blocked = False
239
+ for dep in issue.get("blockedBy") or []:
240
+ dep_issue = issues.get(dep)
241
+ if dep_issue is None or dep_issue.get("status") != "done":
242
+ blocked = True
243
+ break
244
+ if blocked:
245
+ continue
246
+ out = dict(issue)
247
+ out["id"] = iid
248
+ print(json.dumps(out, sort_keys=True))
249
+
250
+
251
+ def cmd_set(args):
252
+ if len(args) < 2:
253
+ _usage("set <id> <key>=<value> [<key>=<value> ...]")
254
+ iid, kvs = args[0], args[1:]
255
+ pairs = []
256
+ for kv in kvs:
257
+ if "=" not in kv:
258
+ _usage("set <id> <key>=<value> [<key>=<value> ...]")
259
+ k, v = kv.split("=", 1)
260
+ if k not in ALLOWED_KEYS:
261
+ print(
262
+ f"tracker-file: REFUSED — unknown field '{k}'. Known: "
263
+ f"{', '.join(sorted(ALLOWED_KEYS))}.",
264
+ file=sys.stderr,
265
+ )
266
+ sys.exit(2)
267
+ if k == "status" and v not in ALLOWED_STATUS:
268
+ print(
269
+ f"tracker-file: REFUSED — unknown status '{v}'. Known: "
270
+ f"{', '.join(sorted(ALLOWED_STATUS))}.",
271
+ file=sys.stderr,
272
+ )
273
+ print(
274
+ " held and gated are not status values — set heldWhy=<reason> or "
275
+ "gate=<handle> instead; both stand apart from status.",
276
+ file=sys.stderr,
277
+ )
278
+ sys.exit(2)
279
+ pairs.append((k, v))
280
+
281
+ with locked(PATH):
282
+ data = _load(PATH)
283
+ issue = data["issues"].setdefault(iid, _new_issue())
284
+ for k, v in pairs:
285
+ if k == "status":
286
+ issue["status"] = v
287
+ elif k == "heldWhy":
288
+ if v.strip():
289
+ issue["heldWhy"] = v
290
+ issue["heldAt"] = notes_mod.now_iso()
291
+ else:
292
+ issue.pop("heldWhy", None)
293
+ issue.pop("heldAt", None)
294
+ elif k == "gate":
295
+ if v.strip():
296
+ issue["gate"] = v
297
+ issue["gatedAt"] = notes_mod.now_iso()
298
+ else:
299
+ issue.pop("gate", None)
300
+ issue.pop("gatedAt", None)
301
+ elif k == "blockedBy":
302
+ issue["blockedBy"] = notes_mod.normalize_scope(v)
303
+ else: # title, effort — plain scalars
304
+ issue[k] = v
305
+ _save_atomic(PATH, data)
306
+ out = dict(issue)
307
+ out["id"] = iid
308
+ print(json.dumps(out, sort_keys=True))
309
+
310
+
311
+ _COMMANDS = {
312
+ "show": cmd_show,
313
+ "notes": cmd_notes,
314
+ "remember": cmd_remember,
315
+ "claim": cmd_claim,
316
+ "ready": cmd_ready,
317
+ "set": cmd_set,
318
+ }
319
+
320
+
321
+ def main():
322
+ global PATH
323
+ root = os.environ.get("ENTROPY_MACHINES_ROOT") or os.getcwd()
324
+ rel = os.environ.get("ENTROPY_MACHINES_TRACKER_PATH") or ".entropy-machines/issues.json"
325
+ PATH = rel if os.path.isabs(rel) else os.path.join(root, rel)
326
+
327
+ if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
328
+ _usage()
329
+ _COMMANDS[sys.argv[1]](sys.argv[2:])
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()