@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.
- package/dist/index.js +1 -1
- package/dist/template/.pi/VERSION +1 -1
- package/dist/template/.pi/extensions/templates-injector.ts +35 -7
- package/dist/template/.pi/prompts/INDEX.md +3 -9
- package/dist/template/.pi/skills/INDEX.md +39 -8
- package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
- package/dist/template/.pi/skills/frontend-design/SKILL.md +1 -1
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
- package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
- package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
- package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
- package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
- package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
- package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
- package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
- package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
- package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
- package/dist/template/.pi/skills/v0/SKILL.md +264 -0
- package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
- package/package.json +1 -1
- package/dist/template/.pi/prompts/loop-check.md +0 -87
- package/dist/template/.pi/prompts/loop-init.md +0 -157
- package/dist/template/.pi/prompts/loop-review.md +0 -90
- package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
- package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
- package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
- package/dist/template/.pi/templates/loop-github-action.yml +0 -162
- package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
- package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
- package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
- package/dist/template/.pi/templates/loop-state.json +0 -24
- package/dist/template/.pi/templates/loop-state.md +0 -98
- 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 "$@"
|