task-pipeline-skill 1.99.0 → 1.100.0

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 (22) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +2 -1
  3. package/SKILL-CARD.md +1 -1
  4. package/package.json +3 -3
  5. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/task-pipeline/agents/design-tester.md +80 -0
  7. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +73 -1
  8. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +28 -0
  9. package/plugins/task-pipeline/skills/task-pipeline/references/design-review.md +64 -0
  10. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  11. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +9 -3
  12. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
  13. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  14. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +4 -0
  15. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +15 -2
  16. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +2 -3
  17. package/plugins/task-pipeline/skills/task-pipeline/scripts/design_gate.py +362 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  19. package/plugins/task-pipeline/skills/task-pipeline/scripts/resource_guard.py +338 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +269 -30
  21. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +51 -4
  22. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +6 -4
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env python3
2
+ """The machine a browser run borrows: check its load, record what the run starts, close
3
+ exactly that, and prove nothing is left. Stdlib only; macOS and Linux.
4
+
5
+ resource_guard.py check [--max-load-ratio 1.0] [--min-free-gb 5]
6
+ [--max-memory-pressure normal|warn] [--json]
7
+ before a run and between scenarios: load average against cores, memory pressure,
8
+ free disk. Exit 0 go; 4 wait (over a line: wait, then run with fewer pages).
9
+ resource_guard.py record <ledger.json> <pid> <what> [--cap-kind browser|page|server]
10
+ [--max-browsers 2] [--max-pages 4] [--max-servers 1]
11
+ after starting a process: append it with its command line. Exit 5 when the cap for
12
+ its kind is already reached; the caller closes something first.
13
+ resource_guard.py close <ledger.json> [--grace 5]
14
+ at the end: SIGTERM, then SIGKILL after the grace, every RECORDED process that is
15
+ still alive and still the same process (its start time is compared, which survives
16
+ an exec, so a reused pid is never touched), and the descendants it spawned (browser
17
+ helpers, a launcher's child). Nothing else is ever signalled.
18
+ resource_guard.py verify <ledger.json> [--json]
19
+ exit 0 when every recorded process is gone; 1 naming the live ones; 2 when a process
20
+ cannot be inspected (no `ps`): never silently clean. Prints the receipt the
21
+ design-review report carries (`resources`).
22
+
23
+ The operator's own browser windows are never in the ledger, so they are never touched.
24
+ Exit codes: 0 ok · 1 live processes · 2 usage · 4 wait · 5 cap reached.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import fcntl
30
+ import json
31
+ import os
32
+ import tempfile
33
+ import shutil
34
+ import signal
35
+ import subprocess
36
+ import sys
37
+ import time
38
+
39
+
40
+ def cpu_count():
41
+ return os.cpu_count() or 1
42
+
43
+
44
+ def load_avg():
45
+ try:
46
+ return os.getloadavg()[0]
47
+ except OSError:
48
+ return None
49
+
50
+
51
+ def memory_pressure():
52
+ """'normal' | 'warn' | 'critical' | None (unknown)."""
53
+ if sys.platform == "darwin":
54
+ try:
55
+ out = subprocess.run(["sysctl", "-n", "kern.memorystatus_vm_pressure_level"],
56
+ capture_output=True, text=True, timeout=5).stdout.strip()
57
+ return {"1": "normal", "2": "warn", "4": "critical"}.get(out)
58
+ except (OSError, subprocess.SubprocessError):
59
+ return None
60
+ try:
61
+ with open("/proc/meminfo") as fh:
62
+ info = {l.split(":")[0]: int(l.split()[1]) for l in fh if ":" in l}
63
+ avail = info.get("MemAvailable", 0) / max(info.get("MemTotal", 1), 1)
64
+ return "critical" if avail < 0.05 else "warn" if avail < 0.15 else "normal"
65
+ except (OSError, ValueError, IndexError):
66
+ return None
67
+
68
+
69
+ LEVELS = ("normal", "warn", "critical")
70
+
71
+
72
+ def check(max_load_ratio, min_free_gb, max_memory="normal"):
73
+ la, cores = load_avg(), cpu_count()
74
+ mem = memory_pressure()
75
+ free_gb = shutil.disk_usage(os.path.expanduser("~")).free / 1e9
76
+ reasons = []
77
+ if la is not None and la > cores * max_load_ratio:
78
+ reasons.append("load %.1f over %d cores × %.1f" % (la, cores, max_load_ratio))
79
+ if mem in LEVELS and LEVELS.index(mem) > LEVELS.index(max_memory):
80
+ reasons.append("memory pressure %s over %s" % (mem, max_memory))
81
+ if free_gb < min_free_gb:
82
+ reasons.append("free disk %.1f GB under %.0f GB" % (free_gb, min_free_gb))
83
+ return dict(go=not reasons, load=la, cores=cores, memory=mem or "unknown",
84
+ free_gb=round(free_gb, 1), reasons=reasons)
85
+
86
+
87
+ class Uninspectable(Exception):
88
+ pass
89
+
90
+
91
+ def exists(pid):
92
+ try:
93
+ os.kill(int(pid), 0)
94
+ return True
95
+ except ProcessLookupError:
96
+ return False
97
+ except PermissionError:
98
+ return True
99
+
100
+
101
+ # Every ps runs in one locale and one zone: `lstart` is a different string under each, and a
102
+ # start time read under another TZ would make a live process look like a stranger.
103
+ PS_ENV = dict(os.environ, LC_ALL="C", LANG="C", TZ="UTC")
104
+
105
+
106
+ def ps_field(pid, field):
107
+ """A `ps -o <field>=` value for one pid; None when the pid is gone; Uninspectable when
108
+ `ps` itself cannot run (the caller must not read that as gone)."""
109
+ try:
110
+ out = subprocess.run(["ps", "-o", field + "=", "-p", str(pid)], capture_output=True,
111
+ text=True, timeout=5, env=PS_ENV)
112
+ except (OSError, subprocess.SubprocessError) as exc:
113
+ raise Uninspectable("ps unavailable (%s)" % exc)
114
+ if out.returncode not in (0, 1):
115
+ raise Uninspectable("ps exited %s" % out.returncode)
116
+ return out.stdout.strip() or None
117
+
118
+
119
+ def started(pid):
120
+ return ps_field(pid, "lstart")
121
+
122
+
123
+ def alive_same(entry):
124
+ """True: the recorded process is still running (same pid, same start time). False: it is
125
+ gone, or the pid now belongs to another process. Uninspectable: cannot tell."""
126
+ if not exists(entry["pid"]):
127
+ return False
128
+ now = started(entry["pid"])
129
+ if now is None:
130
+ return False
131
+ return now == entry.get("started")
132
+
133
+
134
+ def descendants(pids):
135
+ """Every live descendant of the given pids, from one `ps -A -o pid=,ppid=` table."""
136
+ try:
137
+ out = subprocess.run(["ps", "-A", "-o", "pid=,ppid="], capture_output=True, text=True, timeout=5,
138
+ env=PS_ENV).stdout
139
+ except (OSError, subprocess.SubprocessError) as exc:
140
+ raise Uninspectable("ps unavailable (%s)" % exc)
141
+ kids = {}
142
+ for line in out.splitlines():
143
+ parts = line.split()
144
+ if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
145
+ kids.setdefault(int(parts[1]), []).append(int(parts[0]))
146
+ seen, todo = set(), list(pids)
147
+ while todo:
148
+ p = todo.pop()
149
+ for c in kids.get(p, []):
150
+ if c not in seen:
151
+ seen.add(c)
152
+ todo.append(c)
153
+ return sorted(seen)
154
+
155
+
156
+ def load_ledger(path):
157
+ if not os.path.exists(path):
158
+ return {"schema": "resource-ledger/1", "started": []}
159
+ with open(path, encoding="utf-8") as fh:
160
+ doc = json.load(fh)
161
+ if doc.get("schema") != "resource-ledger/1" or not isinstance(doc.get("started"), list):
162
+ raise ValueError("not a resource-ledger/1 file")
163
+ return doc
164
+
165
+
166
+ def save_ledger(path, doc):
167
+ d = os.path.dirname(os.path.abspath(path))
168
+ os.makedirs(d, exist_ok=True)
169
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".resource-ledger.", suffix=".tmp")
170
+ try:
171
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
172
+ json.dump(doc, fh, indent=2)
173
+ fh.write("\n")
174
+ os.replace(tmp, path)
175
+ finally:
176
+ if os.path.exists(tmp):
177
+ os.unlink(tmp)
178
+
179
+
180
+ class locked:
181
+ """An exclusive lock on the ledger's sidecar for a whole read-modify-write: two agents
182
+ recording at once both land."""
183
+ def __init__(self, path):
184
+ self.lock = os.path.abspath(path) + ".lock"
185
+
186
+ def __enter__(self):
187
+ os.makedirs(os.path.dirname(self.lock), exist_ok=True)
188
+ self.fh = open(self.lock, "a")
189
+ fcntl.flock(self.fh, fcntl.LOCK_EX)
190
+ return self
191
+
192
+ def __exit__(self, *exc):
193
+ fcntl.flock(self.fh, fcntl.LOCK_UN)
194
+ self.fh.close()
195
+
196
+
197
+ def ancestors():
198
+ """This process and every ancestor up to pid 1: never recorded, never signalled."""
199
+ out, p = {os.getpid()}, os.getpid()
200
+ for _ in range(64):
201
+ try:
202
+ pp = int(ps_field(p, "ppid") or 0)
203
+ except ValueError:
204
+ break
205
+ if pp <= 1 or pp in out:
206
+ break
207
+ out.add(pp)
208
+ p = pp
209
+ return out | {1}
210
+
211
+
212
+ def record(path, pid, what, kind, caps):
213
+ if int(pid) in ancestors():
214
+ return 2, "pid %s is this process, one of its ancestors or pid 1: never recorded" % pid
215
+ with locked(path):
216
+ doc = load_ledger(path)
217
+ live = [e for e in doc["started"] if e.get("kind") == kind and alive_same(e)]
218
+ if len(live) >= caps[kind]:
219
+ return 5, "cap reached: %d live %s(s), the cap is %d; close one first" % (len(live), kind, caps[kind])
220
+ if not exists(pid):
221
+ return 2, "pid %s is not running; nothing to record" % pid
222
+ st = started(pid)
223
+ if not st:
224
+ return 2, "pid %s is not running; nothing to record" % pid
225
+ doc["started"].append(dict(pid=int(pid), what=what, kind=kind, started=st,
226
+ command=ps_field(pid, "command") or "", at=int(time.time())))
227
+ save_ledger(path, doc)
228
+ return 0, "recorded %s %s (%s)" % (kind, pid, what)
229
+
230
+
231
+ def close(path, grace):
232
+ with locked(path):
233
+ doc = load_ledger(path)
234
+ keep = ancestors()
235
+ alive = [e for e in doc["started"] if alive_same(e)]
236
+ # A protected pid (this process, an ancestor, pid 1) in the ledger is refused with its whole
237
+ # subtree: its descendants are the operator's session, never this run's.
238
+ protected = [e["pid"] for e in alive if e["pid"] in keep]
239
+ targets = [e for e in alive if e["pid"] not in keep]
240
+ # Children first are found while their parents still live; each keeps its own start time
241
+ # so a pid reused during the grace is not signalled.
242
+ kids = [dict(pid=k, started=started(k)) for k in descendants([e["pid"] for e in targets])]
243
+ victims = [dict(pid=e["pid"], started=e["started"]) for e in targets] + \
244
+ [k for k in kids if k["started"] and k["pid"] not in keep]
245
+ refused = list(protected)
246
+ for v in victims:
247
+ try:
248
+ os.kill(v["pid"], signal.SIGTERM)
249
+ except ProcessLookupError:
250
+ pass
251
+ except PermissionError:
252
+ refused.append(v["pid"])
253
+ deadline = time.time() + grace
254
+ while time.time() < deadline and any(alive_same(v) for v in victims):
255
+ time.sleep(0.2)
256
+ for v in victims:
257
+ if v["pid"] not in refused and alive_same(v):
258
+ try:
259
+ os.kill(v["pid"], signal.SIGKILL)
260
+ except ProcessLookupError:
261
+ pass
262
+ except PermissionError:
263
+ refused.append(v["pid"])
264
+ skipped = [e["pid"] for e in doc["started"] if e not in targets]
265
+ return [e["pid"] for e in targets], [k["pid"] for k in kids], skipped, refused
266
+
267
+
268
+ def verify(path):
269
+ doc = load_ledger(path)
270
+ live = [e["pid"] for e in doc["started"] if alive_same(e)]
271
+ return dict(started=[dict(pid=e["pid"], what=e["what"], kind=e.get("kind")) for e in doc["started"]],
272
+ live=live, closed=not live)
273
+
274
+
275
+ def main(argv=None):
276
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
277
+ sub = ap.add_subparsers(dest="cmd", required=True)
278
+ c = sub.add_parser("check")
279
+ c.add_argument("--max-load-ratio", type=float, default=1.0)
280
+ c.add_argument("--min-free-gb", type=float, default=5)
281
+ c.add_argument("--max-memory-pressure", default="normal", choices=("normal", "warn"))
282
+ c.add_argument("--json", action="store_true")
283
+ r = sub.add_parser("record")
284
+ r.add_argument("ledger")
285
+ r.add_argument("pid", type=int)
286
+ r.add_argument("what")
287
+ r.add_argument("--cap-kind", dest="kind", default="browser", choices=("browser", "page", "server"))
288
+ r.add_argument("--max-browsers", type=int, default=2)
289
+ r.add_argument("--max-pages", type=int, default=4)
290
+ r.add_argument("--max-servers", type=int, default=1)
291
+ k = sub.add_parser("close")
292
+ k.add_argument("ledger")
293
+ k.add_argument("--grace", type=float, default=5)
294
+ v = sub.add_parser("verify")
295
+ v.add_argument("ledger")
296
+ v.add_argument("--json", action="store_true")
297
+ a = ap.parse_args(argv)
298
+ try:
299
+ if a.cmd == "check":
300
+ res = check(a.max_load_ratio, a.min_free_gb, a.max_memory_pressure)
301
+ if a.json:
302
+ print(json.dumps(res, indent=2))
303
+ else:
304
+ print("resource_guard: %s · load %s/%d cores · memory %s · free %.1f GB%s" % (
305
+ "go" if res["go"] else "wait", res["load"], res["cores"], res["memory"], res["free_gb"],
306
+ "" if res["go"] else " · " + "; ".join(res["reasons"])))
307
+ return 0 if res["go"] else 4
308
+ if a.cmd == "record":
309
+ caps = dict(browser=a.max_browsers, page=a.max_pages, server=a.max_servers)
310
+ code, msg = record(a.ledger, a.pid, a.what, a.kind, caps)
311
+ print("resource_guard: %s" % msg, file=sys.stderr if code else sys.stdout)
312
+ return code
313
+ if a.cmd == "close":
314
+ closed, kids, skipped, refused = close(a.ledger, a.grace)
315
+ print("resource_guard: closed %d recorded process(es) and %d descendant(s); %d already gone"
316
+ % (len(closed), len(kids), len(skipped))
317
+ + ("; refused (permission): %s" % ", ".join(map(str, refused)) if refused else ""))
318
+ return 1 if refused else 0
319
+ res = verify(a.ledger)
320
+ # The zero-byte lock sidecar stays: unlinking it while another agent holds it would let
321
+ # two writers in. It lives beside the ledger in .task-pipeline/, outside the tracked tree.
322
+ if a.json:
323
+ print(json.dumps(res, indent=2))
324
+ else:
325
+ print("resource_guard: %s · %d started · live: %s" % (
326
+ "clean" if res["closed"] else "LIVE", len(res["started"]), ", ".join(map(str, res["live"])) or "none"))
327
+ return 0 if res["closed"] else 1
328
+ except Uninspectable as exc:
329
+ print("resource_guard: NOT_RUN: %s; a process that cannot be inspected is never reported gone" % exc,
330
+ file=sys.stderr)
331
+ return 2
332
+ except (ValueError, OSError) as exc:
333
+ print("resource_guard: %s" % exc, file=sys.stderr)
334
+ return 2
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main())