@minhduydev/mdpi 0.4.1 → 0.5.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 (34) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/template/.pi/VERSION +1 -1
  3. package/dist/template/.pi/extensions/templates-injector.ts +35 -7
  4. package/dist/template/.pi/prompts/INDEX.md +3 -9
  5. package/dist/template/.pi/skills/INDEX.md +39 -8
  6. package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
  7. package/dist/template/.pi/skills/frontend-design/SKILL.md +1 -1
  8. package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
  9. package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
  10. package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
  11. package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
  12. package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
  13. package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
  14. package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
  15. package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
  16. package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
  17. package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
  18. package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
  19. package/dist/template/.pi/skills/v0/SKILL.md +264 -0
  20. package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
  21. package/package.json +1 -1
  22. package/dist/template/.pi/prompts/loop-check.md +0 -87
  23. package/dist/template/.pi/prompts/loop-init.md +0 -157
  24. package/dist/template/.pi/prompts/loop-review.md +0 -90
  25. package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
  26. package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
  27. package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
  28. package/dist/template/.pi/templates/loop-github-action.yml +0 -162
  29. package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
  30. package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
  31. package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
  32. package/dist/template/.pi/templates/loop-state.json +0 -24
  33. package/dist/template/.pi/templates/loop-state.md +0 -98
  34. package/dist/template/.pi/templates/loop-vision.md +0 -110
@@ -1,514 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # loop-orchestrator.sh — Portable bash orchestrator for the pi loop-engineering harness.
4
- #
5
- # Mirrors loop-orchestrator.ts (T9, Node SDK primary). This is the portable
6
- # alternative for cron/launchd/systemd where the Node SDK runtime is not
7
- # desirable. Composes native pi capabilities: `pi -p --tools ... --no-session
8
- # -a "<prompt>"` (capability-deprivation — the maker structurally cannot ship),
9
- # git worktree isolation (parallel loops never collide), an exit-code gate
10
- # (computational, never an LLM's opinion), dedup state (jq), and ship-on-pass
11
- # (push branch `loop/<name>/<ts>` + `gh pr create`).
12
- #
13
- # GRACEFUL DEGRADATION (FR10): `set -uo pipefail` — NOT `-e`. A loop failure is
14
- # logged + recorded in STATE.json + the scheduler continues. This script never
15
- # aborts the scheduler on a loop failure; only on misuse (bad args / missing
16
- # VISION.md) does it `exit 1` *before* any loop work begins (arg/config errors
17
- # are operator errors, not loop failures). All loop-phase failures are
18
- # captured and recorded, then the script exits 0.
19
- #
20
- # GATE-PARSE CONTRACT (must match T2's loop-vision.md exactly):
21
- # The gate command is extracted from `.pi/loops/<name>/VISION.md`:
22
- # THE FIRST fenced ```bash block located DIRECTLY UNDER the `## Gate`
23
- # heading. Extraction: find the `## Gate` heading line, scan forward to the
24
- # first opening fence line whose info-string is `bash` (i.e. a line equal to
25
- # ```bash), take every line until the next closing fence (a line equal to
26
- # ```), strip leading/trailing whitespace, run via `bash -c "<command>"`,
27
- # read the exit code.
28
- # exit 0 -> PASS -> ship (push `loop/<name>/<ts>` + `gh pr create`)
29
- # non-zero -> FAIL -> no ship; record in STATE.json.failures[]; cleanup
30
- # The gate decision is computational (exit code), never an LLM's opinion
31
- # (avoids the Ralph Wiggum loop). Keep exactly ONE ```bash block directly
32
- # under `## Gate` in VISION.md.
33
- #
34
- # IDEMPOTENCE (FR9): an item already in STATE.json.processed is skipped
35
- # (NOTHING_TO_DO). Re-running is always safe; deleting STATE.json reprocesses.
36
- #
37
- # PHASES (each logs INFO/WARN/ERROR with instance id + duration):
38
- # A. parse args → resolve loop dir + repo root
39
- # B. load VISION.md gate (parse contract above) → GATE_CMD
40
- # C. git worktree add --detach <tmp> HEAD; run maker `pi -p` (cwd in worktree)
41
- # D. (BUDGET-CAP HOOK POINT — T13 fills this; see PHASE D block below)
42
- # E. run gate via `bash -c "$GATE_CMD"`; capture $?
43
- # F. on 0: git push -u origin loop/<name>/<ts> + gh pr create
44
- # G. jq update STATE.json (processed/failures/metrics)
45
- # H. git worktree remove --force (trap-registered for cleanup on exit)
46
- #
47
- # Usage:
48
- # loop-orchestrator.sh run-once <loop-name> [repo-root] [item-id]
49
- # loop-orchestrator.sh run-once ci-triage . # process newest item
50
- # loop-orchestrator.sh run-once ci-triage . 12345 # process item 12345
51
- #
52
- # Requires: pi (CLI), git, gh (authenticated, for PR ship; falls back to
53
- # commit-only/log if absent), jq. API key in env or CI secrets.
54
- #
55
- # -----------------------------------------------------------------------------
56
-
57
- set -uo pipefail
58
-
59
- # =============================================================================
60
- # CONFIG BLOCK (mirror of T9's loop-orchestrator.ts config block)
61
- # =============================================================================
62
- # LOOP_NAME — set from argv[1]; the .pi/loops/<name>/ directory to run.
63
- # GATE — auto-loaded from .pi/loops/<name>/VISION.md (see parse_gate).
64
- # REPO_ROOT — set from argv[2] (default: PWD); where `git worktree` runs.
65
- # TOKEN_CAP — placeholder; T13 (budget cap) will fill this. When non-null and
66
- # the maker's `--mode json` token usage exceeds it, the loop is
67
- # killed and the kill is recorded in STATE.json.metrics.killed.
68
- # MAKER_TOOLS — capability-deprivation allowlist (FR6). Maker CANNOT call
69
- # push/PR/Slack/etc — they are not in this list. Maker only
70
- # stages files in the worktree; the orchestrator ships on pass.
71
- # LOOP_DIR — resolved .pi/loops/<LOOP_NAME>/ (vision + state live here).
72
- # VISION_FILE — .pi/loops/<LOOP_NAME>/VISION.md (the anti goal-drift contract).
73
- # STATE_FILE — .pi/loops/<LOOP_NAME>/STATE.json (the dedup + metrics ledger).
74
- # INSTANCE_ID — per-invocation unique id for log correlation (date + pid).
75
- # MAKER_PROMPT_FN — build_maker_prompt() (single source of truth, see below).
76
- LOOP_NAME="${LOOP_NAME:-}"
77
- GATE="${GATE:-}"
78
- REPO_ROOT="${REPO_ROOT:-}"
79
- # FR13 budget cap: per-run token ceiling. Empty/0 = disabled. When set and the
80
- # maker's --mode json cumulative message_end usage exceeds it, the loop is
81
- # killed (pi PID terminated) and the kill is recorded in STATE.json.metrics.
82
- TOKEN_CAP="${TOKEN_CAP:-}" # BUDGET-CAP HOOK (FR13): set to enforce cap.
83
- MAKER_TOOLS="read,edit,write,bash,grep,find"
84
- LOOP_DIR=""
85
- VISION_FILE=""
86
- STATE_FILE=""
87
- INSTANCE_ID=""
88
- ITEM_ID=""
89
- WORKTREE_DIR=""
90
- ACTION=""
91
- GIT_BRANCH=""
92
- PR_URL=""
93
- START_EPOCH="$(date +%s)" # Fix 4: init at script entry so dur= is sane on early error paths.
94
- BUDGET_KILLED=0 # FR13: set to 1 when the maker is killed for exceeding TOKEN_CAP.
95
- TOKEN_SUM=0 # FR13: cumulative message_end.message.usage.totalTokens across the run.
96
- _CLEANUP_RAN=0 # Fix 3: guard so the EXIT trap doesn't override a signal's exit code.
97
-
98
- # =============================================================================
99
- # LOGGING — INFO/WARN/ERROR with instance id + phase + duration
100
- # =============================================================================
101
- _log() {
102
- # $1=LEVEL $2=phase $3=message
103
- local level="$1" phase="$2" msg="$3"
104
- local now dur
105
- now="$(date +%s)"
106
- dur=$((now - START_EPOCH))
107
- printf '%s [%s] instance=%s phase=%s dur=%ss — %s\n' \
108
- "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$level" "$INSTANCE_ID" "$phase" "$dur" "$msg" >&2
109
- }
110
- log_info() { _log INFO "$1" "$2"; }
111
- log_warn() { _log WARN "$1" "$2"; }
112
- log_error() { _log ERROR "$1" "$2"; }
113
-
114
- # =============================================================================
115
- # MAKER PROMPT — single function (kept identical in spirit to T9)
116
- # =============================================================================
117
- # Constructs the prompt passed to `pi -p -a "<prompt>"`. The maker is told to
118
- # reread VISION.md, stay in scope, stage changes in the worktree (no ship —
119
- # the orchestrator ships after the gate passes), and write a PR_BODY.md.
120
- build_maker_prompt() {
121
- cat <<EOF
122
- You are the MAKER phase of loop "$LOOP_NAME" (instance $INSTANCE_ID, item "$ITEM_ID").
123
-
124
- BEFORE ACTING: reread .pi/loops/$LOOP_NAME/VISION.md and treat its boundaries as
125
- authoritative. Do NOT act outside that file. If a proposed action is not clearly
126
- inside Scope, treat it as Out-of-scope and write a diagnosis to PR_BODY.md
127
- instead of editing.
128
-
129
- GOAL: achieve the Definition-of-done in VISION.md for item "$ITEM_ID".
130
- SCOPE: only touch paths/actions listed under ## Scope in VISION.md.
131
- HARD STOPS: honor every entry under ## Hard stops in VISION.md.
132
- YOU CANNOT SHIP: the orchestrator pushes the branch and opens the PR after the
133
- gate passes. Do not attempt to push, open a PR, or message anyone — you do not
134
- have those tools. Just stage your changes in this worktree (git add) and write a
135
- PR_BODY.md summarizing what you did and citing VISION.md.
136
-
137
- When done, write nothing to stdout that matters; the orchestrator runs the gate.
138
- EOF
139
- }
140
-
141
- # =============================================================================
142
- # GATE PARSE — extract the gate command from VISION.md (contract above)
143
- # =============================================================================
144
- # Extracts the FIRST fenced ```bash block located DIRECTLY under the
145
- # `## Gate` heading. Strip leading/trailing whitespace. Returns the command
146
- # on stdout. Returns non-zero (with an ERROR log) if no such block is found.
147
- # Implementation: awk scans from the `## Gate` heading to the first ```bash
148
- # fence, captures until the closing ```, prints the trimmed content.
149
- parse_gate() {
150
- local vision="$1"
151
- [ -f "$vision" ] || { log_error "parse_gate" "VISION.md not found: $vision"; return 1; }
152
- # Contract (must match T2 loop-vision.md + TS parseGateCommand): capture
153
- # EXACTLY ONE ```bash block directly under the `## Gate` heading, bounded
154
- # by the next level-1/2 heading. Zero or >1 blocks -> exit non-zero (caller
155
- # treats as gate-not-parseable -> record failure, no ship).
156
- local out
157
- out="$(awk '
158
- /^## Gate[[:space:]]*$/ { in_gate=1; next }
159
- in_gate && /^## / && !/^## Gate[[:space:]]*$/ { in_gate=0 }
160
- in_gate && /^# / { in_gate=0 }
161
- in_gate && /^```bash[[:space:]]*$/ { block_count++; if (block_count==1) in_block=1; next }
162
- in_gate && in_block && /^```[[:space:]]*$/ { in_block=0; next }
163
- in_block { buf[++n] = $0 }
164
- END {
165
- if (block_count != 1) exit 1
166
- for (i=1; i<=n; i++) print buf[i]
167
- }
168
- ' "$vision")" || { log_error "parse_gate" "expected exactly 1 bash block under ## Gate in $vision (found 0 or >1)"; return 1; }
169
- # Strip leading/trailing whitespace per line; drop blank lines.
170
- out="$(printf '%s\n' "$out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | grep -v '^$')"
171
- [ -n "$out" ] || { log_error "parse_gate" "gate block under ## Gate was empty in $vision"; return 1; }
172
- printf '%s\n' "$out"
173
- }
174
-
175
- # =============================================================================
176
- # STATE HELPERS — jq-backed reads/writes on STATE.json
177
- # =============================================================================
178
- state_ensure() {
179
- # Create STATE.json from template if missing.
180
- [ -f "$STATE_FILE" ] && return 0
181
- local tmpl="${VISION_FILE%/*}/../templates/loop-state.json"
182
- [ -f "$tmpl" ] || tmpl=".pi/templates/loop-state.json"
183
- if [ -f "$tmpl" ]; then
184
- jq --arg name "$LOOP_NAME" '.loop_name=$name' "$tmpl" > "$STATE_FILE" \
185
- || log_error "state_ensure" "failed to seed $STATE_FILE"
186
- else
187
- printf '{"loop_name":"%s","processed":[],"failures":[],"metrics":{"runs":0,"killed":false,"items_fixed":0,"items_skipped":0}}' \
188
- "$LOOP_NAME" > "$STATE_FILE"
189
- fi
190
- }
191
-
192
- state_contains_processed() {
193
- # $1=item id. Returns 0 if already processed (idempotent skip).
194
- [ -f "$STATE_FILE" ] || return 1
195
- jq -e --arg id "$1" '(.processed // []) | index($id)' "$STATE_FILE" >/dev/null
196
- }
197
-
198
- state_record_skip() {
199
- local item="$1"
200
- [ -f "$STATE_FILE" ] || return 0
201
- local tmp
202
- tmp="$(mktemp)"
203
- jq --arg id "$item" '
204
- .processed = ((.processed // []) + [$id]) | unique
205
- | .metrics.items_skipped = ((.metrics.items_skipped // 0) + 1)
206
- | .last_run = (now | todate)
207
- ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
208
- }
209
-
210
- state_record_failure() {
211
- # $1=item $2=reason
212
- local item="$1" reason="$2"
213
- [ -f "$STATE_FILE" ] || return 0
214
- local tmp
215
- tmp="$(mktemp)"
216
- jq --arg id "$item" --arg reason "$reason" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
217
- .failures = ((.failures // []) + [{item:$id, reason:$reason, at:$ts}])
218
- | .last_run = (now | todate)
219
- ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
220
- }
221
-
222
- state_record_ship() {
223
- # $1=item $2=branch $3=pr_url
224
- local item="$1" branch="$2" pr_url="$3"
225
- [ -f "$STATE_FILE" ] || return 0
226
- local tmp
227
- tmp="$(mktemp)"
228
- jq --arg id "$item" --arg branch "$branch" --arg pr "$pr_url" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
229
- .completed = ((.completed // []) + [{item:$id, branch:$branch, pr:$pr, at:$ts}])
230
- | .processed = ((.processed // []) + [$id]) | unique
231
- | .metrics.items_fixed = ((.metrics.items_fixed // 0) + 1)
232
- | .metrics.pr_opened = ((.metrics.pr_opened // 0) + (if ($pr | length) > 0 then 1 else 0 end))
233
- | .last_run = (now | todate)
234
- ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
235
- }
236
-
237
- state_record_run() {
238
- [ -f "$STATE_FILE" ] || return 0
239
- local tmp
240
- tmp="$(mktemp)"
241
- jq '.metrics.runs = ((.metrics.runs // 0) + 1) | .last_run = (now | todate)' \
242
- "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
243
- }
244
-
245
- # FR13: record a budget-cap kill in STATE.json (metrics.killed=true,
246
- # kill_reason=budget_cap_exceeded, tokens_used=cumulative sum). Graceful —
247
- # never exits non-zero; the caller continues to cleanup (FR10).
248
- state_record_budget_kill() {
249
- [ -f "$STATE_FILE" ] || return 0
250
- local tmp
251
- tmp="$(mktemp)"
252
- jq --arg reason "budget_cap_exceeded" --argjson tokens "${TOKEN_SUM:-0}" '
253
- .metrics.killed = true
254
- | .metrics.kill_reason = $reason
255
- | .metrics.tokens_used = $tokens
256
- | .last_run = (now | todate)
257
- ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
258
- }
259
-
260
- # =============================================================================
261
- # CLEANUP — trap-registered; always remove the worktree (FR8).
262
- # =============================================================================
263
- cleanup() {
264
- local sig="${1:-}"
265
- [ "$_CLEANUP_RAN" -eq 1 ] && return 0
266
- _CLEANUP_RAN=1
267
- if [ -n "$WORKTREE_DIR" ] && [ -d "$WORKTREE_DIR" ]; then
268
- log_warn "cleanup" "removing worktree $WORKTREE_DIR (sig=${sig:-normal-exit})"
269
- git -C "$REPO_ROOT" worktree remove --force "$WORKTREE_DIR" 2>/dev/null \
270
- || rm -rf "$WORKTREE_DIR" 2>/dev/null \
271
- || log_error "cleanup" "failed to remove $WORKTREE_DIR"
272
- fi
273
- # On INT/TERM, exit with the conventional signal code so a scheduler/CI
274
- # timeout (e.g. GH Actions 30-min) is NOT reported as success. Normal EXIT
275
- # stays 0 (FR10). _CLEANUP_RAN guards the EXIT-trap re-entry so a signal's
276
- # exit code is not overwritten by a second `exit 0`.
277
- [ -n "$sig" ] && exit "$sig"
278
- exit 0
279
- }
280
-
281
- # =============================================================================
282
- # PHASE A — parse args
283
- # =============================================================================
284
- phase_parse_args() {
285
- [ $# -ge 2 ] || { log_error "A_parse_args" "usage: $0 run-once <loop-name> [repo-root] [item-id]"; exit 1; }
286
- ACTION="$1"
287
- LOOP_NAME="$2"
288
- REPO_ROOT="${3:-$PWD}"
289
- ITEM_ID="${4:-manual-$(date +%s)}"
290
- [ "$ACTION" = "run-once" ] || { log_error "A_parse_args" "unknown action: $ACTION (only run-once supported)"; exit 1; }
291
- [ -d "$REPO_ROOT" ] || { log_error "A_parse_args" "repo-root not a dir: $REPO_ROOT"; exit 1; }
292
- REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
293
- LOOP_DIR="$REPO_ROOT/.pi/loops/$LOOP_NAME"
294
- VISION_FILE="$LOOP_DIR/VISION.md"
295
- STATE_FILE="$LOOP_DIR/STATE.json"
296
- [ -f "$VISION_FILE" ] || { log_error "A_parse_args" "VISION.md missing: $VISION_FILE"; exit 1; }
297
- [ -d "$LOOP_DIR" ] || mkdir -p "$LOOP_DIR"
298
- state_ensure
299
- INSTANCE_ID="${LOOP_NAME}-$(date +%Y%m%dT%H%M%S)-$$"
300
- START_EPOCH="$(date +%s)"
301
- GIT_BRANCH="loop/$LOOP_NAME/$(date +%Y%m%dT%H%M%S)"
302
- log_info "A_parse_args" "loop=$LOOP_NAME repo=$REPO_ROOT item=$ITEM_ID branch=$GIT_BRANCH"
303
- }
304
-
305
- # =============================================================================
306
- # PHASE B — load VISION.md gate
307
- # =============================================================================
308
- phase_load_gate() {
309
- GATE="$(parse_gate "$VISION_FILE")"
310
- local rc=$?
311
- if [ $rc -ne 0 ] || [ -z "$GATE" ]; then
312
- log_error "B_load_gate" "no fenced bash gate block found under ## Gate in $VISION_FILE"
313
- return 1
314
- fi
315
- log_info "B_load_gate" "gate loaded (${#GATE} chars): $(printf '%s' "$GATE" | head -1)"
316
- }
317
-
318
- # =============================================================================
319
- # PHASE C — worktree + maker (`pi -p --mode json` with restricted tools)
320
- # =============================================================================
321
- # Runs the maker with `pi -p --mode json`, which streams JSONL events on
322
- # stdout (one JSON object per line). We stream stdout into a parser that
323
- # accumulates `message_end.message.usage.totalTokens` for assistant messages
324
- # (the verified SDK usage field — only assistant message_end events carry
325
- # .usage; user message_end events do not) and kills the pi PID if the
326
- # cumulative sum exceeds TOKEN_CAP (FR13). Stderr is captured separately so it
327
- # never pollutes the jq-parsed event stream.
328
- phase_worktree_and_maker() {
329
- WORKTREE_DIR="$(mktemp -d -t "loop-${LOOP_NAME}-XXXXXX")"
330
- # mktemp -d gives a plain dir; convert to a git worktree.
331
- rmdir "$WORKTREE_DIR" 2>/dev/null
332
- if ! git -C "$REPO_ROOT" worktree add --detach "$WORKTREE_DIR" HEAD 2>&1; then
333
- log_error "C_worktree" "git worktree add failed"
334
- WORKTREE_DIR=""
335
- return 1
336
- fi
337
- log_info "C_worktree" "worktree at $WORKTREE_DIR"
338
-
339
- local prompt
340
- prompt="$(build_maker_prompt)"
341
-
342
- # ---- Maker: pi -p --mode json with capability-deprivation allowlist (FR6). ----
343
- # The maker literally cannot call push/PR/Slack (not in --tools). It only
344
- # stages files in the worktree + writes PR_BODY.md. The orchestrator ships.
345
- # --mode json emits JSONL events on stdout (one JSON object per line).
346
- log_info "C_maker" "running pi -p --mode json (cwd=$WORKTREE_DIR, tools=$MAKER_TOOLS)"
347
-
348
- local fifo err_file pi_pid=0
349
- fifo="$(mktemp -u -t loop-fifo-XXXXXX)"
350
- err_file="$(mktemp -t loop-maker-err-XXXXXX)"
351
- mkfifo "$fifo"
352
-
353
- # Background: pi writes JSONL events to the FIFO; stderr to err_file.
354
- # The foreground reads the FIFO line by line and parses it inline below.
355
- ( cd "$WORKTREE_DIR" && pi -p --tools "$MAKER_TOOLS" --no-session --approve --mode json -a "$prompt" >"$fifo" 2>"$err_file" ) &
356
- pi_pid=$!
357
-
358
- # ---------------------------------------------------------------------
359
- # PHASE D — BUDGET-CAP enforcement (FR13). Stream-parse the JSONL event
360
- # stream; for each `message_end` event whose .message.role == "assistant",
361
- # sum .message.usage.totalTokens. If cumulative > TOKEN_CAP, kill the pi
362
- # PID, record the kill in STATE.json (metrics.killed=true,
363
- # kill_reason=budget_cap_exceeded), and break. Never `exit 1` (FR10).
364
- # Usage field name: message_end.message.usage.totalTokens.
365
- # ---------------------------------------------------------------------
366
- TOKEN_SUM=0
367
- BUDGET_KILLED=0
368
- while IFS= read -r line; do
369
- # Extract totalTokens from assistant message_end events (jq per line).
370
- local t
371
- t="$(printf '%s' "$line" | jq -r 'select(.type=="message_end" and .message.role=="assistant") | .message.usage.totalTokens // empty' 2>/dev/null)"
372
- if [ -n "$t" ]; then
373
- TOKEN_SUM=$((TOKEN_SUM + t))
374
- fi
375
- if [ -n "$TOKEN_CAP" ] && [ "$TOKEN_SUM" -gt "$TOKEN_CAP" ]; then
376
- log_warn "D_budget_cap" "cumulative tokens $TOKEN_SUM > cap $TOKEN_CAP; killing pi pid $pi_pid"
377
- kill "$pi_pid" 2>/dev/null || true
378
- BUDGET_KILLED=1
379
- state_record_budget_kill 2>/dev/null || true
380
- break
381
- fi
382
- done < "$fifo"
383
-
384
- # Drain / reap the background pi (non-zero exit is not fatal — FR10).
385
- wait "$pi_pid" 2>/dev/null || log_warn "C_maker" "pi -p exited non-zero (recorded, not fatal — FR10)"
386
- [ -s "$err_file" ] && log_warn "C_maker" "pi stderr: $(head -c 500 "$err_file" 2>/dev/null)"
387
- rm -f "$fifo" "$err_file"
388
- log_info "C_maker" "maker phase complete (tokens=$TOKEN_SUM, killed=$BUDGET_KILLED)"
389
- }
390
-
391
- # =============================================================================
392
- # PHASE E — run the gate; capture exit code (computational, FR7)
393
- # =============================================================================
394
- phase_gate() {
395
- log_info "E_gate" "running gate via bash -c"
396
- local gate_out
397
- gate_out="$(cd "$WORKTREE_DIR" && bash -c "$GATE" 2>&1)"
398
- local rc=$?
399
- if [ "$rc" -ne 0 ]; then
400
- printf '%s\n' "$gate_out" >&2
401
- log_error "E_gate" "gate FAILED (exit $rc)"
402
- return "$rc"
403
- fi
404
- log_info "E_gate" "gate PASSED (exit 0)"
405
- }
406
-
407
- # =============================================================================
408
- # PHASE F — ship: push branch + gh pr create (only on gate pass)
409
- # =============================================================================
410
- phase_ship() {
411
- cd "$WORKTREE_DIR" || { log_error "F_ship" "cannot cd worktree"; return 1; }
412
- # Stage everything the maker produced (commits in worktree).
413
- git add -A 2>/dev/null || log_warn "F_ship" "git add -A had nothing to stage"
414
- if ! git diff --cached --quiet; then
415
- git commit -m "loop($LOOP_NAME): $ITEM_ID (instance $INSTANCE_ID)" 2>&1 >&2 \
416
- || log_warn "F_ship" "git commit failed (maybe empty)"
417
- fi
418
- # Branch from the worktree HEAD (detached) onto loop/<name>/<ts>.
419
- if ! git checkout -b "$GIT_BRANCH" 2>&1 >&2; then
420
- log_error "F_ship" "git checkout -b $GIT_BRANCH failed"
421
- return 1
422
- fi
423
- if ! git push -u origin "$GIT_BRANCH" 2>&1 >&2; then
424
- log_error "F_ship" "git push -u origin $GIT_BRANCH failed"
425
- return 1
426
- fi
427
- log_info "F_push" "pushed $GIT_BRANCH"
428
-
429
- if command -v gh >/dev/null 2>&1; then
430
- local body=""
431
- [ -f PR_BODY.md ] && body="$(cat PR_BODY.md)"
432
- PR_URL="$(gh pr create --base main --head "$GIT_BRANCH" \
433
- --title "loop($LOOP_NAME): $ITEM_ID" \
434
- --body "${body:-Auto-generated by loop-orchestrator.sh (instance $INSTANCE_ID).}" 2>&1 \
435
- | tail -n1)" || {
436
- log_warn "F_pr" "gh pr create failed; branch pushed but no PR (commit-only fallback)"
437
- PR_URL=""
438
- }
439
- log_info "F_pr" "PR: ${PR_URL:-<none>}"
440
- else
441
- log_warn "F_pr" "gh not installed; branch pushed, no PR (commit-only)"
442
- PR_URL=""
443
- fi
444
- cd "$REPO_ROOT" || log_warn "F_ship" "cd back to repo-root failed"
445
- }
446
-
447
- # =============================================================================
448
- # MAIN — orchestrate phases; never exit 1 on a loop failure (FR10)
449
- # =============================================================================
450
- main() {
451
- phase_parse_args "$@"
452
- trap 'cleanup' EXIT
453
- trap 'cleanup 130' INT
454
- trap 'cleanup 143' TERM
455
-
456
- # Idempotence (FR9): skip already-processed items.
457
- if state_contains_processed "$ITEM_ID"; then
458
- log_info "main" "NOTHING_TO_DO — item $ITEM_ID already processed (idempotent skip)"
459
- state_record_skip "$ITEM_ID" 2>/dev/null || true
460
- exit 0
461
- fi
462
-
463
- # FR13 early-exit: empty watchlist (in_progress) and no explicit item-id
464
- # arg → nothing to process; exit cheaply (<5k tokens) before the maker.
465
- if [ $# -lt 4 ]; then
466
- in_progress_count="$(jq '(.in_progress // []) | length' "$STATE_FILE" 2>/dev/null || echo 0)"
467
- if [ "${in_progress_count:-0}" -eq 0 ]; then
468
- log_info "main" "NOTHING_TO_DO — watchlist (in_progress) empty; early-exit (<5k tokens)"
469
- exit 0
470
- fi
471
- fi
472
- state_record_run 2>/dev/null || true
473
-
474
- # B — load gate.
475
- if ! phase_load_gate; then
476
- state_record_failure "$ITEM_ID" "gate-parse-failed" 2>/dev/null || true
477
- log_error "main" "gate parse failed; recording failure and exiting 0 (FR10)"
478
- exit 0
479
- fi
480
-
481
- # C — worktree + maker.
482
- if ! phase_worktree_and_maker; then
483
- state_record_failure "$ITEM_ID" "worktree-or-maker-failed" 2>/dev/null || true
484
- log_error "main" "worktree/maker failed; recording and exiting 0 (FR10)"
485
- exit 0
486
- fi
487
-
488
- # D — budget cap: if the maker was killed for exceeding TOKEN_CAP, the
489
- # kill was already recorded in STATE.json by phase_worktree_and_maker;
490
- # skip the gate + ship phases and exit 0 (FR10 — never exit 1).
491
- if [ "$BUDGET_KILLED" -eq 1 ]; then
492
- log_error "main" "budget_cap_exceeded — kill recorded in STATE.json; skipping gate/ship; exiting 0 (FR10)"
493
- exit 0
494
- fi
495
-
496
- # E — gate (exit code is the decision).
497
- if ! phase_gate; then
498
- state_record_failure "$ITEM_ID" "gate-failed-exit-nonzero" 2>/dev/null || true
499
- log_error "main" "gate failed; no ship; recording and exiting 0 (FR10)"
500
- exit 0
501
- fi
502
-
503
- # F — ship on pass.
504
- if ! phase_ship; then
505
- state_record_failure "$ITEM_ID" "ship-failed" 2>/dev/null || true
506
- log_error "main" "ship failed; recording and exiting 0 (FR10)"
507
- exit 0
508
- fi
509
- state_record_ship "$ITEM_ID" "$GIT_BRANCH" "$PR_URL" 2>/dev/null || true
510
- log_info "main" "DONE — shipped $ITEM_ID on $GIT_BRANCH (${PR_URL:-no-pr})"
511
- exit 0
512
- }
513
-
514
- main "$@"