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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
You are running unattended. Nobody is watching, nobody can answer a question,
|
|
2
|
+
and the session will be killed after its timeout. Work accordingly: finish
|
|
3
|
+
and verify a smaller thing rather than leaving a larger thing half-done.
|
|
4
|
+
|
|
5
|
+
## Your work
|
|
6
|
+
|
|
7
|
+
Take these issues, in this order, and no others:
|
|
8
|
+
|
|
9
|
+
__ISSUES__
|
|
10
|
+
|
|
11
|
+
Read each with `bin/tracker show <id>` before starting it. If an issue turns
|
|
12
|
+
out to need something you cannot do from a terminal with nobody present — a
|
|
13
|
+
live UI, an external service, a decision nobody has made — STOP on that
|
|
14
|
+
issue and run:
|
|
15
|
+
|
|
16
|
+
bin/tracker set <id> heldWhy="<why, specific enough for a human to act on>"
|
|
17
|
+
|
|
18
|
+
so no future unattended run picks it up again, then move to the next one.
|
|
19
|
+
Recording why is the whole point; silently skipping it means the next run
|
|
20
|
+
rediscovers the same wall.
|
|
21
|
+
|
|
22
|
+
## How to work
|
|
23
|
+
|
|
24
|
+
1. `git checkout -b __BRANCH__` before your first edit. Never commit on the
|
|
25
|
+
default branch.
|
|
26
|
+
2. Implement one issue at a time. Commit each one separately.
|
|
27
|
+
3. Verify before each commit: run every suite listed under `suites` in this
|
|
28
|
+
repo's `config.json` (docs/CONFIG.md) that is not tagged `slow`. Run the
|
|
29
|
+
full set, including anything tagged `slow`, at least once before you
|
|
30
|
+
finish. If `suites` is empty, say so in your report rather than skipping
|
|
31
|
+
verification silently.
|
|
32
|
+
4. If this project's `config.json` has `changelog.enabled` true, write one
|
|
33
|
+
changelog fragment per commit using the command in
|
|
34
|
+
`changelog.newFragmentCmd`, naming the issue it closes. Never hand-edit
|
|
35
|
+
the collated file at `changelog.collatedFile` directly, and never run
|
|
36
|
+
this project's full build/release command — some rewrite committed
|
|
37
|
+
bookkeeping (a collated changelog, a checkpoint file) as a side effect,
|
|
38
|
+
which is not a change you were asked to make.
|
|
39
|
+
5. `bin/tracker set <id> status=done` when an issue is genuinely finished
|
|
40
|
+
and verified — not when the code is written, when the tests passed.
|
|
41
|
+
|
|
42
|
+
## What you may not touch
|
|
43
|
+
|
|
44
|
+
This repo's `config.json` lists paths under `project.protectedPaths` — an
|
|
45
|
+
append-only interface, a generated bundle, a cross-writer contract, anything
|
|
46
|
+
that needs a human holding both ends. Do not edit any of them on your own
|
|
47
|
+
initiative. If an issue cannot be done without touching one, mark it held as
|
|
48
|
+
above and move on. The runner also diffs your finished work against this
|
|
49
|
+
list before pushing and quarantines the whole run if it finds a match, so
|
|
50
|
+
there is no version of this where touching one helps.
|
|
51
|
+
|
|
52
|
+
## Before you finish
|
|
53
|
+
|
|
54
|
+
Review your own diff for correctness and simplification issues before
|
|
55
|
+
declaring anything done, using whatever review tooling this environment
|
|
56
|
+
gives you — or record in your handoff why you did not.
|
|
57
|
+
|
|
58
|
+
Then write a `HANDOFF.md` at the repo root: exactly four one-line keys —
|
|
59
|
+
`changed` / `found` / `assumed` / `next` — plus, per issue you touched, run
|
|
60
|
+
`bin/tracker remember --issue <id> "<what changed, what you verified and its
|
|
61
|
+
numbers, anything you skipped and why>"` so the next session (attended or
|
|
62
|
+
not) finds it without opening this branch. If you marked an issue held, say
|
|
63
|
+
so in both places — that is the most useful line here, because it is the one
|
|
64
|
+
that changes what tomorrow's run does.
|
|
65
|
+
|
|
66
|
+
Do not push and do not open a pull request. The runner does that after it has
|
|
67
|
+
checked your diff.
|
package/bin/drain-run.sh
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# One unattended fire: take eligible work, verify it, report it, push it for
|
|
3
|
+
# review. Invoked by the scheduled job on the hour, or by `bin/drain now`, or
|
|
4
|
+
# by `bin/drain at HH:MM`.
|
|
5
|
+
#
|
|
6
|
+
# Also answers `bin/drain-run.sh --probe-quota` — see QUOTA PROBE below. That
|
|
7
|
+
# is not a separate script; it is the same function bin/drain's `status` calls
|
|
8
|
+
# out to, so there is exactly one implementation instead of two that drift.
|
|
9
|
+
#
|
|
10
|
+
# The design goal is NOT "get work done overnight". It is "produce a diff worth
|
|
11
|
+
# reviewing in the morning, or produce nothing". Every guard below exists to
|
|
12
|
+
# make the second outcome cheap and the first trustworthy.
|
|
13
|
+
#
|
|
14
|
+
# GUARDS, in the order they fire. Each maps to a failure this kind of unattended
|
|
15
|
+
# job can produce if left unchecked:
|
|
16
|
+
# armed the operator's switch (bin/drain on|off)
|
|
17
|
+
# branch never commit onto whatever branch another workstream left
|
|
18
|
+
# checked out — the shared-checkout hazard (see doctrine/WORKFLOW.md,
|
|
19
|
+
# "the clean tree is the truth")
|
|
20
|
+
# clean a fire killed mid-issue by a quota ceiling leaves a dirty tree;
|
|
21
|
+
# the NEXT fire refuses to start rather than building on top of it
|
|
22
|
+
# quota skip if the quota probe recently saw a wall — see QUOTA PROBE
|
|
23
|
+
# paths diff the finished work against config.json's protected paths
|
|
24
|
+
# before pushing; the tracker's own tags say what we should not
|
|
25
|
+
# START, this says what we actually TOUCHED
|
|
26
|
+
#
|
|
27
|
+
# EXIT CODES are the observable contract (status reads last-run.json, but a
|
|
28
|
+
# human running `drain now` reads these):
|
|
29
|
+
# 0 work landed 3 not on the expected branch 6 nothing eligible
|
|
30
|
+
# 1 internal error 4 tree not clean 7 timed out (see blockedOn)
|
|
31
|
+
# 2 disarmed 5 quota wall 8 touched a protected path
|
|
32
|
+
set -e
|
|
33
|
+
|
|
34
|
+
# ONE ROOT, TWO PATHS — see lib/roots.sh. ENTROPY_MACHINES_HOME is the harness
|
|
35
|
+
# DIRECTORY (this script's own bin/, and the lib/ beside it); $root is the
|
|
36
|
+
# repository this works on. They are the same tree — the harness is vendored
|
|
37
|
+
# inside the project — but not necessarily the same directory.
|
|
38
|
+
#
|
|
39
|
+
# THE cd COMES FIRST AND IS LOAD-BEARING. A launchd/systemd unit fires with cwd
|
|
40
|
+
# at `/`, so git has nothing to answer from. Stepping into the harness
|
|
41
|
+
# directory — which is inside the repo — gives it one. This is what replaced
|
|
42
|
+
# the old "an installed job only works when the harness sits at the project
|
|
43
|
+
# root" gap: nothing has to carry the root in.
|
|
44
|
+
. "$(dirname "$0")/../lib/roots.sh"
|
|
45
|
+
ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
|
|
46
|
+
cd "$ENTROPY_MACHINES_HOME"
|
|
47
|
+
# The nested-clone refusal still applies and must be asked for explicitly,
|
|
48
|
+
# because this script deliberately skips entropy_machines_require_root (below).
|
|
49
|
+
entropy_machines_refuse_nested_clone drain-run
|
|
50
|
+
|
|
51
|
+
# entropy_machines_root, not entropy_machines_require_root: the "no config.json" refusal exits
|
|
52
|
+
# 2, and 2 is already this script's documented "disarmed" code (see EXIT CODES
|
|
53
|
+
# above). A misconfigured repo is an internal error (1), not a disarmed one,
|
|
54
|
+
# and cfg() below already defaults every key it reads.
|
|
55
|
+
if ! root=$(entropy_machines_root); then
|
|
56
|
+
echo "drain-run: could not resolve the repository from $ENTROPY_MACHINES_HOME." >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# ---- config -------------------------------------------------------------
|
|
61
|
+
# Same cfg() as bin/drain. Kept as a second copy rather than a shared sourced
|
|
62
|
+
# file: these are the only two callers, both tiny, and a sourced helper would
|
|
63
|
+
# be a third file to keep in sync with the config.json shape for a five-line
|
|
64
|
+
# function. If a third caller shows up, that calculus changes.
|
|
65
|
+
cfg() {
|
|
66
|
+
python3 - "$root/config.json" "$1" "$2" <<'PY'
|
|
67
|
+
import json, sys
|
|
68
|
+
path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
69
|
+
try:
|
|
70
|
+
with open(path) as f:
|
|
71
|
+
data = json.load(f)
|
|
72
|
+
except Exception:
|
|
73
|
+
print(default); sys.exit(0)
|
|
74
|
+
cur = data
|
|
75
|
+
# TOLERATE A LEADING DOT. Every call site in this file and in bin/drain writes
|
|
76
|
+
# the key as '.unattended.x' (a jq habit), which split('.') turns into a first
|
|
77
|
+
# segment of '' that matches no dict key — so this helper silently returned its
|
|
78
|
+
# DEFAULT for every lookup, and no unattended setting in config.json had any
|
|
79
|
+
# effect. Fixed here rather than at a dozen call sites so both spellings work.
|
|
80
|
+
for part in key.lstrip('.').split('.'):
|
|
81
|
+
if isinstance(cur, dict) and part in cur:
|
|
82
|
+
cur = cur[part]
|
|
83
|
+
else:
|
|
84
|
+
print(default); sys.exit(0)
|
|
85
|
+
print(default if cur is None else (cur if isinstance(cur, str) else json.dumps(cur)))
|
|
86
|
+
PY
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# ---- QUOTA PROBE ----------------------------------------------------------
|
|
90
|
+
# Whether the configured coding-agent CLI recently hit a rate/quota wall, so a
|
|
91
|
+
# fire that would only hit the same wall again can be skipped cheaply instead
|
|
92
|
+
# of burning a session discovering it. This is OPTIONAL and OFF by default:
|
|
93
|
+
# there is no universal way to ask an arbitrary agent CLI "are you rate
|
|
94
|
+
# limited right now", so absent a configured probe this must not skip fires —
|
|
95
|
+
# skipping without evidence is a worse failure mode than occasionally hitting
|
|
96
|
+
# a wall the hard way.
|
|
97
|
+
#
|
|
98
|
+
# The one probe type implemented here, "log-glob", is a real but fragile
|
|
99
|
+
# technique: it globs a directory of the agent CLI's own session logs for a
|
|
100
|
+
# literal error-marker substring and reads a timestamp field off the JSON line
|
|
101
|
+
# that contains it. It ASSUMES:
|
|
102
|
+
# - the CLI writes one JSON object per line (JSONL) to files under logGlob
|
|
103
|
+
# - a rate-limit response is logged as a line containing errorMarker verbatim
|
|
104
|
+
# - that line is valid JSON with a "timestamp" field in a sortable string form
|
|
105
|
+
# - file mtimes are a reasonable proxy for "was this session active recently"
|
|
106
|
+
# Every one of those is an implementation detail of whatever agent CLI you
|
|
107
|
+
# point it at, not a stable interface, and can silently stop matching the
|
|
108
|
+
# moment that CLI changes its log format. Configure it under
|
|
109
|
+
# unattended.agent.quotaProbe in config.json:
|
|
110
|
+
#
|
|
111
|
+
# "quotaProbe": {
|
|
112
|
+
# "type": "log-glob",
|
|
113
|
+
# "logGlob": "~/.claude/projects/**/*.jsonl",
|
|
114
|
+
# "errorMarker": "\"error\":\"rate_limit\"",
|
|
115
|
+
# "lookbackSeconds": 3600
|
|
116
|
+
# }
|
|
117
|
+
#
|
|
118
|
+
# Leave quotaProbe unset (or type "none") to run with no quota awareness at
|
|
119
|
+
# all — every fire attempts the work and simply fails/times out if the agent
|
|
120
|
+
# CLI itself refuses.
|
|
121
|
+
probe_quota() {
|
|
122
|
+
ptype=$(cfg '.unattended.agent.quotaProbe.type' 'none')
|
|
123
|
+
case "$ptype" in
|
|
124
|
+
none|"") return 0 ;; # no probe configured — print nothing, never skip
|
|
125
|
+
esac
|
|
126
|
+
glob=$(cfg '.unattended.agent.quotaProbe.logGlob' '')
|
|
127
|
+
marker=$(cfg '.unattended.agent.quotaProbe.errorMarker' '')
|
|
128
|
+
lookback=$(cfg '.unattended.agent.quotaProbe.lookbackSeconds' '3600')
|
|
129
|
+
if [ -z "$glob" ] || [ -z "$marker" ]; then
|
|
130
|
+
return 0 # misconfigured probe — same as no probe, never skip
|
|
131
|
+
fi
|
|
132
|
+
python3 - "$glob" "$marker" "$lookback" <<'PY' 2>/dev/null || true
|
|
133
|
+
import glob as globmod, json, os, sys, time
|
|
134
|
+
pattern, marker, lookback = sys.argv[1], sys.argv[2], float(sys.argv[3])
|
|
135
|
+
cut = time.time() - lookback
|
|
136
|
+
newest = None
|
|
137
|
+
for f in globmod.glob(os.path.expanduser(pattern), recursive=True):
|
|
138
|
+
try:
|
|
139
|
+
if os.path.getmtime(f) < cut:
|
|
140
|
+
continue
|
|
141
|
+
for line in open(f, errors="ignore"):
|
|
142
|
+
if marker in line:
|
|
143
|
+
try:
|
|
144
|
+
ts = json.loads(line).get("timestamp")
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
if ts and (newest is None or ts > newest):
|
|
148
|
+
newest = ts
|
|
149
|
+
except OSError:
|
|
150
|
+
continue
|
|
151
|
+
print(newest or "")
|
|
152
|
+
PY
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if [ "$1" = "--probe-quota" ]; then
|
|
156
|
+
probe_quota
|
|
157
|
+
exit 0
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
state_default=$(cfg '.unattended.stateHome' '~/.entropy-machines')
|
|
161
|
+
case "$state_default" in
|
|
162
|
+
"~"*) state_default="$HOME${state_default#\~}" ;;
|
|
163
|
+
esac
|
|
164
|
+
state="${ENTROPY_DRAIN_HOME:-$state_default}"
|
|
165
|
+
flag="$state/armed"
|
|
166
|
+
runlog="$state/last-run.json"
|
|
167
|
+
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
168
|
+
slug=$(date -u +%Y%m%d-%H%M%S)
|
|
169
|
+
out="$state/runs/$slug"
|
|
170
|
+
foreground=""
|
|
171
|
+
[ "$1" = "--foreground" ] && foreground=1
|
|
172
|
+
|
|
173
|
+
want_branch=$(cfg '.unattended.branch' 'main')
|
|
174
|
+
remote=$(cfg '.unattended.remote' 'origin')
|
|
175
|
+
branch_prefix=$(cfg '.unattended.branchPrefix' 'auto/')
|
|
176
|
+
forge_cli=$(cfg '.unattended.forgeCli' 'gh')
|
|
177
|
+
timeout_s=$(cfg '.unattended.sessionTimeoutSeconds' '5400')
|
|
178
|
+
|
|
179
|
+
mkdir -p "$state/runs"
|
|
180
|
+
|
|
181
|
+
# Single-flight. Two fires in one tree is the exact thing every guard below is
|
|
182
|
+
# protecting against, and the scheduler will happily start a second while the
|
|
183
|
+
# first is still going if a run outlives its hour.
|
|
184
|
+
lock="$state/lock"
|
|
185
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
186
|
+
echo "drain: another fire is in progress ($lock) — exiting"
|
|
187
|
+
exit 0
|
|
188
|
+
fi
|
|
189
|
+
trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
|
|
190
|
+
|
|
191
|
+
finish() { # outcome, exit code, detail
|
|
192
|
+
cat > "$runlog" <<JSON
|
|
193
|
+
{
|
|
194
|
+
"startedAt": "$stamp",
|
|
195
|
+
"finishedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
196
|
+
"outcome": "$1",
|
|
197
|
+
"exit": $2,
|
|
198
|
+
"detail": "$3",
|
|
199
|
+
"log": "$out"
|
|
200
|
+
}
|
|
201
|
+
JSON
|
|
202
|
+
echo "drain: $1 ($3)"
|
|
203
|
+
exit "$2"
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# ---- guard: armed ---------------------------------------------------------
|
|
207
|
+
if [ -z "$foreground" ] && [ ! -f "$flag" ]; then
|
|
208
|
+
# Not an error and not worth a log entry — a disarmed job is meant to fire
|
|
209
|
+
# and do nothing. Writing last-run.json here would bury the real last run.
|
|
210
|
+
echo "drain: disarmed, nothing to do"
|
|
211
|
+
exit 2
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
cd "$root"
|
|
215
|
+
|
|
216
|
+
# ---- guard: branch --------------------------------------------------------
|
|
217
|
+
branch=$(git branch --show-current 2>/dev/null || echo "")
|
|
218
|
+
if [ "$branch" != "$want_branch" ]; then
|
|
219
|
+
finish "skipped" 3 "on branch '$branch', not '$want_branch'"
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
# ---- guard: clean tree ----------------------------------------------------
|
|
223
|
+
if ! git diff --quiet || ! git diff --cached --quiet; then
|
|
224
|
+
finish "skipped" 4 "working tree is dirty — triage the previous run first"
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
# ---- guard: quota ---------------------------------------------------------
|
|
228
|
+
recent=$(probe_quota)
|
|
229
|
+
if [ -n "$recent" ] && [ -z "$foreground" ]; then
|
|
230
|
+
finish "skipped" 5 "quota probe saw a wall at $recent — window has not turned over"
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# ---- pick the work --------------------------------------------------------
|
|
234
|
+
# The cost model and budget live in config.json (unattended.sizeCosts,
|
|
235
|
+
# unattended.budget) and are applied by bin/drain-pick.py, which has tests.
|
|
236
|
+
# THE rc IS CAPTURED, NOT SWALLOWED. Under `set -e` a bare
|
|
237
|
+
# `picked=$(...)` assignment kills this script the moment drain-pick exits
|
|
238
|
+
# non-zero -- so a picker that could not reach the tracker took the whole run
|
|
239
|
+
# down BEFORE the idle handler below, leaving no last-run.json, the real error
|
|
240
|
+
# buried in pick.err, and `bin/drain status` still reporting "last fire:
|
|
241
|
+
# never". Silent because the two outcomes look identical from outside: an
|
|
242
|
+
# empty $picked means "nothing eligible", which is a normal idle tick, while a
|
|
243
|
+
# non-zero rc means the picker itself failed and somebody has to be told.
|
|
244
|
+
pick_rc=0
|
|
245
|
+
picked=$(python3 "$ENTROPY_MACHINES_HOME/bin/drain-pick.py" "$root" 2>>"$state/pick.err") || pick_rc=$?
|
|
246
|
+
if [ "$pick_rc" -ne 0 ]; then
|
|
247
|
+
finish "error" 7 "drain-pick failed (rc $pick_rc) — see $state/pick.err"
|
|
248
|
+
fi
|
|
249
|
+
if [ -z "$picked" ]; then
|
|
250
|
+
finish "idle" 6 "nothing eligible for an unattended run"
|
|
251
|
+
fi
|
|
252
|
+
count=$(echo "$picked" | wc -w | tr -d ' ')
|
|
253
|
+
echo "drain: taking $count issue(s): $picked"
|
|
254
|
+
|
|
255
|
+
# ---- run ------------------------------------------------------------------
|
|
256
|
+
# NO permission flags. An unattended session gets exactly the same permissions
|
|
257
|
+
# an attended one would — an unrecognised command therefore prompts, and with
|
|
258
|
+
# nobody there to answer, the session stalls. That is why the timeout below is
|
|
259
|
+
# not optional: a stall costs one window and leaves a specific line in the log
|
|
260
|
+
# to widen the allowlist by, rather than hanging until the scheduler kills it.
|
|
261
|
+
prompt=$(sed "s|__ISSUES__|$picked|g; s|__BRANCH__|${branch_prefix}${slug}|g" "$ENTROPY_MACHINES_HOME/bin/drain-prompt.md")
|
|
262
|
+
|
|
263
|
+
# Agent invocation is pluggable: unattended.agent.cmd is the argv prefix
|
|
264
|
+
# (default ["claude", "-p"]) and the prompt is appended as the final argument.
|
|
265
|
+
# Building it as newline-separated tokens rather than a JSON array kept in one
|
|
266
|
+
# shell variable is deliberate — POSIX sh has no arrays, and this is the
|
|
267
|
+
# smallest portable way to rebuild an argv from a list of strings.
|
|
268
|
+
mkdir -p "$out"
|
|
269
|
+
set --
|
|
270
|
+
while IFS= read -r tok; do
|
|
271
|
+
[ -n "$tok" ] && set -- "$@" "$tok"
|
|
272
|
+
done <<AGENTCMD
|
|
273
|
+
$(python3 - "$root/config.json" <<'PY'
|
|
274
|
+
import json, sys
|
|
275
|
+
try:
|
|
276
|
+
data = json.load(open(sys.argv[1]))
|
|
277
|
+
cmd = data.get("unattended", {}).get("agent", {}).get("cmd") or ["claude", "-p"]
|
|
278
|
+
except Exception:
|
|
279
|
+
cmd = ["claude", "-p"]
|
|
280
|
+
for tok in cmd:
|
|
281
|
+
print(tok)
|
|
282
|
+
PY
|
|
283
|
+
)
|
|
284
|
+
AGENTCMD
|
|
285
|
+
"$@" "$prompt" > "$out/session.json" 2> "$out/session.err" &
|
|
286
|
+
pid=$!
|
|
287
|
+
deadline=$(( $(date +%s) + timeout_s ))
|
|
288
|
+
while kill -0 "$pid" 2>/dev/null; do
|
|
289
|
+
if [ "$(date +%s)" -gt "$deadline" ]; then
|
|
290
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
291
|
+
sleep 5
|
|
292
|
+
kill -KILL "$pid" 2>/dev/null || true
|
|
293
|
+
blocked=$(tail -c 2000 "$out/session.err" 2>/dev/null | tr '\n' ' ' | tr -d '"' | tail -c 300)
|
|
294
|
+
finish "timeout" 7 "killed after $((timeout_s / 60))m — tail: ${blocked:-nothing on stderr}"
|
|
295
|
+
fi
|
|
296
|
+
sleep 20
|
|
297
|
+
done
|
|
298
|
+
|
|
299
|
+
# ---- guard: paths ---------------------------------------------------------
|
|
300
|
+
# The enforced half of the autonomy rule. The tracker's own tags say what an
|
|
301
|
+
# issue should not START on; this says what the session actually TOUCHED,
|
|
302
|
+
# which is the only one that survives an untagged issue turning out unsafe.
|
|
303
|
+
# protectedPaths is a plain list of path prefixes in config.json
|
|
304
|
+
# (project.protectedPaths) — not a project-specific Python module, so widening
|
|
305
|
+
# or narrowing the guarded set is a one-line config edit.
|
|
306
|
+
guarded=$(python3 - "$root" "$remote" "$want_branch" <<'PY'
|
|
307
|
+
import json, os, subprocess, sys
|
|
308
|
+
root, remote, branch = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
309
|
+
protected = []
|
|
310
|
+
try:
|
|
311
|
+
with open(os.path.join(root, "config.json")) as f:
|
|
312
|
+
protected = json.load(f).get("project", {}).get("protectedPaths", []) or []
|
|
313
|
+
except Exception:
|
|
314
|
+
protected = []
|
|
315
|
+
if not protected:
|
|
316
|
+
print("")
|
|
317
|
+
sys.exit(0)
|
|
318
|
+
changed = subprocess.run(
|
|
319
|
+
["git", "diff", "--name-only", f"{remote}/{branch}...HEAD"],
|
|
320
|
+
cwd=root, capture_output=True, text=True).stdout.split()
|
|
321
|
+
hits = [f for f in changed if any(f.startswith(p) or f == p for p in protected)]
|
|
322
|
+
print(",".join(hits))
|
|
323
|
+
PY
|
|
324
|
+
)
|
|
325
|
+
if [ -n "$guarded" ]; then
|
|
326
|
+
finish "quarantined" 8 "touched protected path(s): $guarded — left uncommitted for review"
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
# ---- hand it over for review ----------------------------------------------
|
|
330
|
+
# Pushed, never merged. An unattended run earns no more trust than a human PR
|
|
331
|
+
# does — review happens on it like any other change.
|
|
332
|
+
branch_name="${branch_prefix}${slug}"
|
|
333
|
+
if git rev-parse --verify "$branch_name" >/dev/null 2>&1; then
|
|
334
|
+
git push -q -u "$remote" "$branch_name" 2>>"$out/session.err" || \
|
|
335
|
+
finish "landed-unpushed" 0 "commits made, push failed — see $out/session.err"
|
|
336
|
+
if [ -n "$forge_cli" ] && command -v "$forge_cli" >/dev/null 2>&1; then
|
|
337
|
+
"$forge_cli" pr create --draft --fill --base "$want_branch" --head "$branch_name" \
|
|
338
|
+
>> "$out/session.err" 2>&1 || true
|
|
339
|
+
fi
|
|
340
|
+
finish "landed" 0 "$count issue(s) on $branch_name, pushed for review"
|
|
341
|
+
fi
|
|
342
|
+
finish "no-op" 0 "session ran but produced no branch — see $out/session.json"
|