@minhduydev/mdpi 0.4.0 → 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 (48) 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 +34 -6
  4. package/dist/template/.pi/prompts/INDEX.md +3 -9
  5. package/dist/template/.pi/skills/INDEX.md +81 -19
  6. package/dist/template/.pi/skills/accessibility-audit/SKILL.md +8 -2
  7. package/dist/template/.pi/skills/baseline-ui/SKILL.md +211 -0
  8. package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
  9. package/dist/template/.pi/skills/design-taste-frontend/SKILL.md +53 -42
  10. package/dist/template/.pi/skills/fixing-accessibility/SKILL.md +509 -0
  11. package/dist/template/.pi/skills/frontend-design/SKILL.md +60 -47
  12. package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
  13. package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
  14. package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
  15. package/dist/template/.pi/skills/frontend-ui-engineering/SKILL.md +21 -27
  16. package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
  17. package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
  18. package/dist/template/.pi/skills/oklch-color-workflow/SKILL.md +426 -0
  19. package/dist/template/.pi/skills/production-hardening/SKILL.md +652 -0
  20. package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
  21. package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
  22. package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
  23. package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
  24. package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
  25. package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
  26. package/dist/template/.pi/skills/ui-craft-principles/SKILL.md +564 -0
  27. package/dist/template/.pi/skills/ui-quality-audit/SKILL.md +329 -0
  28. package/dist/template/.pi/skills/v0/SKILL.md +264 -0
  29. package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
  30. package/dist/template/.pi/templates/DESIGN.md +76 -0
  31. package/dist/template/.pi/workflows/INDEX.md +2 -1
  32. package/dist/template/.pi/workflows/frontend-feature-workflow.md +343 -0
  33. package/dist/template/.pi/workflows/quality-loop.md +1 -1
  34. package/package.json +1 -1
  35. package/dist/template/.pi/prompts/loop-check.md +0 -87
  36. package/dist/template/.pi/prompts/loop-init.md +0 -157
  37. package/dist/template/.pi/prompts/loop-review.md +0 -90
  38. package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
  39. package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
  40. package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
  41. package/dist/template/.pi/templates/loop-github-action.yml +0 -162
  42. package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
  43. package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
  44. package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
  45. package/dist/template/.pi/templates/loop-state.json +0 -24
  46. package/dist/template/.pi/templates/loop-state.md +0 -98
  47. package/dist/template/.pi/templates/loop-vision.md +0 -110
  48. /package/dist/template/.pi/templates/{design.md → feature-design.md} +0 -0
@@ -1,162 +0,0 @@
1
- # =============================================================================
2
- # loop-github-action.yml — GitHub Actions workflow for unattended pi loop runs.
3
- #
4
- # Implements FR11 (Scheduling — unattended): a scheduled trigger fires the
5
- # loop-engineering orchestrator headless. The orchestrator runs the MAKER phase
6
- # (`pi -p` with capability-deprivation, --approve/-a auto-trusts project files
7
- # for non-interactive runs, --offline prevents phone-home), an exit-code GATE,
8
- # and ships-on-pass (push `loop/<name>/<ts>` + `gh pr create`).
9
- #
10
- # Self-contained: this workflow uses raw `pi -p` via the shipped orchestrators
11
- # (.pi/templates/loop-orchestrator.ts [T9] or loop-orchestrator.sh [T10]). It
12
- # does NOT hard-depend on any third-party GitHub Action. An optional compose
13
- # with the pi-coding-agent action is documented at the bottom of this file —
14
- # use it only if you prefer the maintained action wrapper over the raw CLI.
15
- #
16
- # PARAMETERIZATION:
17
- # - loop_name (workflow_dispatch input): which loop to run
18
- # (the .pi/loops/<loop_name>/ directory). Defaults to `ci-triage`.
19
- # - cron (workflow_dispatch input): overrides the placeholder schedule
20
- # below for manual/ad-hoc runs. NOTE: workflow_dispatch inputs cannot
21
- # change the `on.schedule` cron of an already-registered workflow — the
22
- # schedule cron is fixed at registration time. To run a different cadence,
23
- # copy this file, change the `on.schedule` cron placeholder, and register
24
- # the new workflow. The `cron` input is documented for clarity and is
25
- # consumed by the run step as `${{ inputs.cron }}` when you wire it into a
26
- # wrapper; here it is surfaced in the step env for downstream tooling.
27
- #
28
- # CRON PLACEHOLDER:
29
- # on.schedule.cron is set to "0 3 * * *" (03:00 UTC daily). Edit this value
30
- # to match the loop's cadence (see .pi/loops/<name>/VISION.md cadence field).
31
- # GitHub Actions cron is UTC and has a best-effort (not exact) fire time.
32
- #
33
- # Secrets required (repo → Settings → Secrets and variables → Actions):
34
- # - PI_API_KEY — the pi API key (provider key). Injected as env PI_API_KEY.
35
- # - GH_TOKEN — (optional) a GitHub PAT with repo + pull-requests scopes.
36
- # If unset, the workflow falls back to the auto-provided
37
- # `GITHUB_TOKEN` (permissions block below grants write).
38
- #
39
- # Requires (installed in the job): Node 24, pi CLI (global), git, gh.
40
- # =============================================================================
41
-
42
- name: loop-run
43
-
44
- # -----------------------------------------------------------------------------
45
- # Triggers
46
- # -----------------------------------------------------------------------------
47
- # schedule.cron is a PLACEHOLDER — edit to match the loop's cadence.
48
- # GitHub Actions cron is UTC, best-effort (may lag minutes).
49
- # workflow_dispatch inputs parameterize loop_name + cron for ad-hoc/manual runs.
50
- on:
51
- schedule:
52
- - cron: "0 3 * * *"
53
- workflow_dispatch:
54
- inputs:
55
- loop_name:
56
- description: "Loop name (the .pi/loops/<loop_name>/ directory to run, e.g. ci-triage)"
57
- required: true
58
- default: "ci-triage"
59
- type: string
60
- cron:
61
- description: "Cadence hint (informational; schedule cron is fixed at registration — copy this file to change cadence). Example: '0 3 * * *'"
62
- required: false
63
- default: "0 3 * * *"
64
- type: string
65
-
66
- # -----------------------------------------------------------------------------
67
- # Permissions — grant write so the orchestrator can push branches + open PRs.
68
- # (FR11 ship-on-pass: push loop/<name>/<ts> + gh pr create.)
69
- # -----------------------------------------------------------------------------
70
- permissions:
71
- contents: write
72
- pull-requests: write
73
-
74
- # A single run per loop at a time — avoid overlap/collision (FR8 worktree
75
- # isolation is per-invocation, but we still avoid duplicate scheduled runs).
76
- concurrency:
77
- group: loop-run-${{ github.event.inputs.loop_name || 'ci-triage' }}
78
- cancel-in-progress: false
79
-
80
- jobs:
81
- loop:
82
- runs-on: ubuntu-latest
83
- timeout-minutes: 30
84
- env:
85
- # Resolve loop name: workflow_dispatch input wins; scheduled runs default.
86
- LOOP_NAME: ${{ github.event.inputs.loop_name || 'ci-triage' }}
87
- PI_API_KEY: ${{ secrets.PI_API_KEY }}
88
- # Prefer a dedicated GH_TOKEN secret if provided; else use GITHUB_TOKEN.
89
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
90
- # Surface the cron hint for downstream tooling/logging.
91
- LOOP_CRON: ${{ github.event.inputs.cron || '0 3 * * *' }}
92
- steps:
93
- - name: Checkout
94
- uses: actions/checkout@v4
95
- with:
96
- fetch-depth: 0 # full history for branch + worktree ops
97
-
98
- - name: Setup Node
99
- uses: actions/setup-node@v4
100
- with:
101
- node-version: "24"
102
- # No cache: pi is installed globally, not from package.json.
103
-
104
- - name: Install pi globally
105
- run: npm i -g @earendil-works/pi-coding-agent
106
-
107
- - name: Configure gh CLI auth
108
- env:
109
- GH_TOKEN: ${{ env.GH_TOKEN }}
110
- run: |
111
- # If GH_TOKEN secret was set, use it; otherwise rely on the
112
- # auto-provided GITHUB_TOKEN (permissions block grants write).
113
- if [ -n "$GH_TOKEN" ]; then
114
- echo "$GH_TOKEN" | gh auth login --with-token
115
- else
116
- echo "GH_TOKEN empty — relying on auto-provided GITHUB_TOKEN"
117
- fi
118
- gh auth status || true
119
-
120
- - name: Run loop orchestrator (run-once)
121
- env:
122
- PI_API_KEY: ${{ env.PI_API_KEY }}
123
- GH_TOKEN: ${{ env.GH_TOKEN }}
124
- run: |
125
- set -uo pipefail
126
- echo "::group::loop run-once ${LOOP_NAME} (cadence=${LOOP_CRON})"
127
- # Primary: Node SDK orchestrator (T9) via tsx (plain `node` cannot
128
- # execute TypeScript). Fallback to the portable bash orchestrator
129
- # (T10) if tsx/node module resolution fails.
130
- # --approve/-a auto-trusts project files (required non-interactive).
131
- # --offline prevents phone-home.
132
- if command -v npx >/dev/null 2>&1; then
133
- echo "Running Node orchestrator (loop-orchestrator.ts) via tsx..."
134
- npx --yes tsx .pi/templates/loop-orchestrator.ts run-once "${LOOP_NAME}" . \
135
- || echo "::warning::Node orchestrator exited non-zero (FR10: recorded, not fatal)"
136
- else
137
- echo "npx not found — running bash orchestrator (loop-orchestrator.sh)..."
138
- bash .pi/templates/loop-orchestrator.sh run-once "${LOOP_NAME}" . \
139
- || echo "::warning::Bash orchestrator exited non-zero (FR10: recorded, not fatal)"
140
- fi
141
- echo "::endgroup::"
142
-
143
- # =============================================================================
144
- # OPTIONAL COMPOSE (do NOT hard-depend — kept as a comment only).
145
- # -----------------------------------------------------------------------------
146
- # If you prefer the maintained action wrapper over raw `pi -p`, you can replace
147
- # the "Install pi globally" + "Run loop orchestrator" steps with:
148
- #
149
- # - name: Run pi loop (via action)
150
- # uses: shaftoe/pi-coding-agent-action@v1
151
- # with:
152
- # loop-name: ${{ env.LOOP_NAME }}
153
- # approve: true # -a: auto-trust project files (non-interactive)
154
- # offline: true # --offline: prevent phone-home
155
- # api-key: ${{ secrets.PI_API_KEY }}
156
- # gh-token: ${{ secrets.GH_TOKEN || github.token }}
157
- # run-command: "node .pi/templates/loop-orchestrator.ts run-once ${{ env.LOOP_NAME }} ."
158
- #
159
- # This workflow stays self-contained with raw `pi -p` so it has zero external
160
- # action dependency. Use the compose above only if you accept the external
161
- # dependency and pin a version.
162
- # =============================================================================
@@ -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 "$@"