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/lib/tracker-file
ADDED
|
@@ -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()
|