diff --git a/plugin/agents/builder.md b/plugin/agents/builder.md index b9c6672..20005a9 100644 --- a/plugin/agents/builder.md +++ b/plugin/agents/builder.md @@ -1,8 +1,40 @@ --- name: builder -description: FrinkLoop builder — implements one task in a worktree. The default workhorse. Reads task spec, edits files, commits. Implemented in Plan 2. +description: FrinkLoop builder — implements ONE task. Reads task spec, edits files, runs local checks, commits. Default workhorse for kind=feature/fix/test/doc. Sequential in Plan 2; worktree-isolated parallelism arrives in Plan 4. --- # builder -Placeholder. Will be implemented in Plan 2. +## Inputs +- One task JSON from `/.frinkloop/tasks.json` (passed by mvp-loop) +- `/.frinkloop/spec.md` for context +- The full project working tree at `$PROJECT_DIR` + +## Output +- One or more file edits / additions inside `$PROJECT_DIR/` +- One git commit per task with conventional-commit message: `(): ` +- No artifact file required (qa writes that separately) + +## Job + +1. Read the task title + kind. Plan the smallest set of edits that satisfies the task without scope creep. +2. If kind=test: write the test FIRST. Run it. Confirm it fails. (TDD discipline.) +3. If kind=feature: implement the smallest code that satisfies the task. If a paired test task exists ahead in the queue, the planner already enforced TDD ordering — your job is just to make it pass. +4. If kind=fix: read the parent task's failure mode (in `qa.json` and `decisions.md`). Make the minimal fix. +5. If kind=doc: edit README, JSDoc, or in-code comments only. +6. Run any obvious local check (typecheck, lint) before committing — but the qa subagent runs the formal verification, so don't overdo it. +7. Stage and commit: + + ```bash + git add <specific files> + git commit -m "<kind>(<scope>): <task title>" + ``` + +## Constraints +- Write only inside `$PROJECT_DIR/`. Never edit the plugin. +- Don't refactor unrelated code. Stick to the task. +- Don't add new dependencies without an explicit task instruction. +- Don't push to a remote. Plan 8 handles deploy. + +## Failure handling +If you can't make progress, return with status `BLOCKED` and a one-line reason. The mvp-loop will queue a fix task or escalate. diff --git a/plugin/agents/planner.md b/plugin/agents/planner.md index 3f89ac6..cf7dc2e 100644 --- a/plugin/agents/planner.md +++ b/plugin/agents/planner.md @@ -1,8 +1,43 @@ --- name: planner -description: FrinkLoop planner — turns spec changes into task deltas in tasks.json. Reads spec.md and current tasks.json; outputs a JSON-patch-style delta that the loop applies. Implemented in Plan 2. +description: FrinkLoop planner — turns a frozen spec into a tasks.json (initial plan) or applies deltas when the spec changes mid-loop. Inputs: spec.md + current tasks.json. Output: a new tasks.json (or a JSON patch) committed to disk. One-shot. --- # planner -Placeholder. Will be implemented in Plan 2. +## Inputs +- `<project>/.frinkloop/spec.md` — frozen YC-shaped spec (Does / For / MVP proves / Done / In-MVP / Phase-2) +- `<project>/.frinkloop/tasks.json` — current task tree (may be empty on first run) +- `<project>/.frinkloop/config.yaml` — for tdd flag + +## Output +- A new `<project>/.frinkloop/tasks.json` validated against `plugin/lib/schemas/tasks.schema.json` +- Append a one-paragraph rationale to `<project>/.frinkloop/decisions.md` describing the milestones chosen + +## Job + +Given the spec, decide: +1. Milestones (typically 3–6): coarse phases like "Scaffold", "Core flow", "Polish & deliver". +2. Tasks per milestone: each is a single 5–30 min unit of work. Use the `kind` enum: scaffold, feature, test, fix, doc, deploy, screenshot. +3. `depends_on` between tasks where order matters. +4. If `tdd: true` in config.yaml, every `kind=feature` task gets a paired `kind=test` task that comes BEFORE it in dependency order. + +Use `T01..TNN` as ids, two digits, sequential across the whole project (not per milestone). + +## What you must NOT do +- Do not add tasks for things in the Phase-2 list (the spec already defers them). +- Do not introduce new fields outside the schema. +- Do not write to anywhere other than `tasks.json` and `decisions.md`. + +## Validation +After writing, run: + +```bash +npx --no-install ajv validate -s plugin/lib/schemas/tasks.schema.json \ + -d "$FRINKLOOP_DIR/tasks.json" --strict=false +``` + +If it fails, fix tasks.json before exiting. + +## Status report +Return: number of milestones, number of tasks, whether tdd was applied. The orchestrator (mvp-loop) will read tasks.json directly — your prose response is for the human-readable log only. diff --git a/plugin/agents/qa.md b/plugin/agents/qa.md index 76e0e27..8a327ff 100644 --- a/plugin/agents/qa.md +++ b/plugin/agents/qa.md @@ -1,8 +1,28 @@ --- name: qa -description: FrinkLoop QA — runs tests, typecheck, lint after each task and milestone. Writes qa.json artifact. Implemented in Plan 2. +description: FrinkLoop QA — verifies a builder's task by running tests, typecheck, and lint where applicable. Writes qa.json artifact validated against schemas/qa-result.schema.json. --- # qa -Placeholder. Will be implemented in Plan 2. +## Inputs +- One task JSON (passed by mvp-loop) +- The post-builder working tree at `$PROJECT_DIR` + +## Output +- `<project>/.frinkloop/qa.json` validated against `plugin/lib/schemas/qa-result.schema.json` +- Optionally one or more diagnostic snippets quoted in qa.json under `output_excerpt` + +## Job + +Run `bash plugin/lib/verify.sh; verify_task '<task json>'`. The helper handles the kind-driven branching (lightweight kinds vs. test-running kinds). + +If `verify_task` returns 0, qa.json shows `outcome=pass`. If non-zero, qa.json shows `outcome=fail` with `error_summary` populated. + +## What you must NOT do +- Do not modify project files. You are read-only against the working tree. +- Do not skip checks. If the helper reports `unknown-kind`, that's a real failure. +- Do not write anything outside `$FRINKLOOP_DIR/`. + +## Reporting +Return: outcome (pass/fail) and the path to qa.json. The orchestrator reads qa.json directly. diff --git a/plugin/commands/frinkloop-pause.md b/plugin/commands/frinkloop-pause.md index 6f380cd..f47f10c 100644 --- a/plugin/commands/frinkloop-pause.md +++ b/plugin/commands/frinkloop-pause.md @@ -4,6 +4,14 @@ description: Pause a running FrinkLoop loop, flush state, write a handoff. # /frinkloop pause <project> -(Plan 2.) Will set state.status = "paused", append a final iteration-log line, and trigger `/handoff`. +Pause the build loop for `<project>`. -For now: print "Pause arrives in Plan 2." +## Steps + +1. Resolve `<project>` and export `FRINKLOOP_DIR`, `PROJECT_DIR` as in resume. +2. Source `plugin/lib/state.sh`. +3. `state_set status paused`. +4. `log_iteration '{"event":"pause","reason":"user-requested"}'`. +5. Trigger the user's `/handoff` skill so the handoff lands in the project Handoffs dir, `~/.claude/handoffs/`, the Obsidian vault, and Notion (for opted-in projects). +6. Print a one-line confirmation: `paused at iteration <N>, milestone <id>, task <id>`. +7. Exit. The Stop hook will not re-prompt because status=paused. diff --git a/plugin/commands/frinkloop-resume.md b/plugin/commands/frinkloop-resume.md index eef148f..944268c 100644 --- a/plugin/commands/frinkloop-resume.md +++ b/plugin/commands/frinkloop-resume.md @@ -4,6 +4,19 @@ description: Resume a paused or quota-stopped FrinkLoop loop for the named proje # /frinkloop resume <project> -(Plan 2.) Will load `<project>/.frinkloop/state.json`, validate, and resume the `mvp-loop` skill. +Resume the build loop for `<project>`. -For now: print "Resume arrives in Plan 2." +## Steps + +1. Resolve `<project>` to a directory (treat as path; if relative, resolve against `~/Developer/`). +2. Export `FRINKLOOP_DIR=<project>/.frinkloop` and `PROJECT_DIR=<project>`. +3. Validate `state.json` against `plugin/lib/schemas/state.schema.json`. If invalid, abort with a clear error. +4. Source `plugin/lib/state.sh`, `plugin/lib/loop.sh`, `plugin/lib/verify.sh`, `plugin/lib/recovery.sh`. +5. Run `resume_or_block` (from `recovery.sh`). It checks the working tree. + - If output is `block` → tell the user the working tree was modified and a blocker was logged. Stop. + - If output is `resume` → continue. +6. Set `status=running` via `state_set status running`. +7. Print a one-line status summary (status, current_milestone, current_task, iteration_count). +8. Hand off to the `mvp-loop` skill with PROMPT.md re-fed. + +The Stop hook will then keep the loop ticking until DONE. diff --git a/plugin/hooks/post-iteration.sh b/plugin/hooks/post-iteration.sh index 160a646..24f093d 100755 --- a/plugin/hooks/post-iteration.sh +++ b/plugin/hooks/post-iteration.sh @@ -1,4 +1,23 @@ #!/usr/bin/env bash -# FrinkLoop post-iteration hook — placeholder. -# Plan 2 will implement: append a structured iteration-log.jsonl line. +# FrinkLoop post-iteration hook. +# Increments iteration_count and appends an iteration-log entry. +# FRINKLOOP_DIR must be exported. + +set -euo pipefail + +: "${FRINKLOOP_DIR:=}" + +if [ -z "$FRINKLOOP_DIR" ] || [ ! -f "$FRINKLOOP_DIR/state.json" ]; then + exit 0 +fi + +# Source state helpers via path relative to this hook. +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HOOK_DIR/../lib/state.sh" + +state_increment_iteration + +iter=$(state_get iteration_count) +log_iteration "$(jq -nc --arg i "$iter" '{event:"iteration", iter:($i|tonumber)}')" + exit 0 diff --git a/plugin/hooks/stop.sh b/plugin/hooks/stop.sh index c7fa0fd..849ce13 100755 --- a/plugin/hooks/stop.sh +++ b/plugin/hooks/stop.sh @@ -1,4 +1,34 @@ #!/usr/bin/env bash -# FrinkLoop Stop hook — placeholder. -# Plan 2 will implement: re-feed PROMPT.md to keep the loop running until DONE. -exit 0 +# FrinkLoop Stop hook. +# Exit 0 → let the session end. +# Exit 2 → continue the loop (Claude Code re-prompts the model). +# FRINKLOOP_DIR must be exported by the session preamble. + +set -euo pipefail + +: "${FRINKLOOP_DIR:=}" + +if [ -z "$FRINKLOOP_DIR" ] || [ ! -f "$FRINKLOOP_DIR/state.json" ]; then + exit 0 +fi + +status=$(jq -r '.status' "$FRINKLOOP_DIR/state.json") + +case "$status" in + done|paused|blocked|quota-stopped|idle) + exit 0 + ;; + running) + if [ ! -f "$FRINKLOOP_DIR/tasks.json" ]; then + exit 0 + fi + pending_count=$(jq '[.milestones[].tasks[] | select(.status == "pending")] | length' "$FRINKLOOP_DIR/tasks.json") + if [ "$pending_count" -gt 0 ]; then + exit 2 + fi + exit 0 + ;; + *) + exit 0 + ;; +esac diff --git a/plugin/lib/loop.sh b/plugin/lib/loop.sh new file mode 100644 index 0000000..847e7ae --- /dev/null +++ b/plugin/lib/loop.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# FrinkLoop loop helpers — task picking, status mutations, decisions log. +# Caller must export FRINKLOOP_DIR and source plugin/lib/state.sh first. + +set -euo pipefail + +: "${FRINKLOOP_DIR:?FRINKLOOP_DIR must be set}" + +# Returns the id of the first in-progress or pending milestone, or empty string. +active_milestone() { + jq -r ' + .milestones[] + | select(.status == "in-progress" or .status == "pending") + | .id + ' "$FRINKLOOP_DIR/tasks.json" | head -1 +} + +# Returns the task id of the next task to work on, or empty string if none. +# Skips tasks whose depends_on still contain pending task ids. +pick_next_task() { + local mid + mid=$(active_milestone) + if [ -z "$mid" ]; then + echo "" + return 0 + fi + + jq -r --arg mid "$mid" ' + (.milestones[] | select(.id == $mid) | .tasks) as $tasks + | ($tasks | map(select(.status == "pending")) | map(.id)) as $pending_ids + | $tasks + | map(select(.status == "pending")) + | map(select( + ((.depends_on // []) | length == 0) + or + ((.depends_on // []) | all(. as $dep | $pending_ids | index($dep) | . == null)) + )) + | .[0].id // "" + ' "$FRINKLOOP_DIR/tasks.json" +} + +# Mark a task done by id; append an entry to decisions.md. +mark_task_done() { + local task_id="$1" + local note="${2:-}" + local path="$FRINKLOOP_DIR/tasks.json" + local tmp + tmp=$(mktemp) + jq --arg tid "$task_id" ' + .milestones |= map( + .tasks |= map( + if .id == $tid then .status = "done" else . end + ) + ) + ' "$path" > "$tmp" + mv "$tmp" "$path" + + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + printf "\n## %s — %s\n%s\n" "$ts" "$task_id" "$note" >> "$FRINKLOOP_DIR/decisions.md" +} + +# Queue a new fix task that depends on parent_task_id. +# Appends the fix task to the active milestone and returns the new task id. +queue_fix_task() { + local parent="$1" + local error_summary="$2" + local path="$FRINKLOOP_DIR/tasks.json" + local mid + mid=$(active_milestone) + + # Generate next id: T<N+1> zero-padded to 2 digits + local next_id + next_id=$(jq -r ' + [.milestones[].tasks[].id | ltrimstr("T") | tonumber] | max as $m + | ($m + 1) as $n + | "T" + (if $n < 10 then "0" else "" end) + ($n | tostring) + ' "$path") + + local tmp + tmp=$(mktemp) + jq --arg mid "$mid" --arg pid "$parent" --arg nid "$next_id" --arg err "$error_summary" ' + .milestones |= map( + if .id == $mid + then .tasks += [{ + "id": $nid, + "title": ("Fix: " + $err), + "status": "pending", + "kind": "fix", + "depends_on": [$pid], + "retries": 0 + }] + else . end + ) + ' "$path" > "$tmp" + mv "$tmp" "$path" + + echo "$next_id" +} + +# Mark a milestone done if and only if all its tasks are done. +# Returns 1 (no-op) if any task is still not done. +mark_milestone_done() { + local mid="$1" + local path="$FRINKLOOP_DIR/tasks.json" + local all_done + all_done=$(jq -r --arg mid "$mid" ' + .milestones[] | select(.id == $mid) + | (.tasks | map(.status == "done") | all) + ' "$path") + if [ "$all_done" != "true" ]; then + return 1 + fi + local tmp + tmp=$(mktemp) + jq --arg mid "$mid" ' + .milestones |= map(if .id == $mid then .status = "done" else . end) + ' "$path" > "$tmp" + mv "$tmp" "$path" +} diff --git a/plugin/lib/recovery.sh b/plugin/lib/recovery.sh new file mode 100644 index 0000000..6050649 --- /dev/null +++ b/plugin/lib/recovery.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# FrinkLoop crash recovery: detect mid-loop user edits, open blockers. + +set -euo pipefail + +: "${FRINKLOOP_DIR:?FRINKLOOP_DIR must be set}" + +# Returns 0 if working tree is clean, 1 if dirty. +detect_dirty_tree() { + local out + out=$(git status --porcelain 2>/dev/null) + if [ -z "$out" ]; then + return 0 + fi + return 1 +} + +open_blocker() { + local task_id="$1" + local reason="$2" + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + printf "\n## %s — BLOCKED on %s\n%s\n" "$ts" "$task_id" "$reason" >> "$FRINKLOOP_DIR/blockers.md" +} + +# Decides whether to resume the loop or open a blocker. +# Prints "resume" or "block" to stdout. +resume_or_block() { + if detect_dirty_tree; then + echo "resume" + return 0 + fi + open_blocker "<resume>" "Working tree dirty on resume — user may have edited files mid-loop. Manual cleanup required before resume." + echo "block" +} diff --git a/plugin/lib/schemas/qa-result.schema.json b/plugin/lib/schemas/qa-result.schema.json new file mode 100644 index 0000000..b20a502 --- /dev/null +++ b/plugin/lib/schemas/qa-result.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FrinkLoop qa.json (per-task verification artifact)", + "type": "object", + "required": ["schema_version", "task_id", "kind", "outcome", "ts"], + "additionalProperties": false, + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "task_id": { "type": "string" }, + "kind": { "type": "string", "enum": ["scaffold", "feature", "test", "fix", "doc", "deploy", "screenshot"] }, + "outcome": { "type": "string", "enum": ["pass", "fail"] }, + "ts": { "type": "string" }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "status"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail", "skip"] }, + "output_excerpt": { "type": "string" } + } + } + }, + "error_summary": { "type": "string" } + } +} diff --git a/plugin/lib/schemas/state.schema.json b/plugin/lib/schemas/state.schema.json index 35fdb87..da5a155 100644 --- a/plugin/lib/schemas/state.schema.json +++ b/plugin/lib/schemas/state.schema.json @@ -14,6 +14,7 @@ "status": { "type": "string", "enum": ["idle", "running", "paused", "blocked", "quota-stopped", "done"] - } + }, + "last_iteration_at": { "type": ["string", "null"] } } } diff --git a/plugin/lib/state.sh b/plugin/lib/state.sh index 2cf3355..f4c3af2 100644 --- a/plugin/lib/state.sh +++ b/plugin/lib/state.sh @@ -49,6 +49,7 @@ state_increment_iteration() { local current current=$(state_get iteration_count) state_set iteration_count "$((current + 1))" + state_set last_iteration_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" } log_iteration() { diff --git a/plugin/lib/verify.sh b/plugin/lib/verify.sh new file mode 100644 index 0000000..b99391e --- /dev/null +++ b/plugin/lib/verify.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# FrinkLoop verification helpers. +# Caller exports FRINKLOOP_DIR (project's .frinkloop) and PROJECT_DIR. +# Writes qa.json artifact at FRINKLOOP_DIR/qa.json. + +set -euo pipefail + +: "${FRINKLOOP_DIR:?FRINKLOOP_DIR must be set}" + +# Per-task verification — kind-driven. +# Argument: task JSON (one task object from tasks.json). +# Writes $FRINKLOOP_DIR/qa.json. Exits 0 on pass, non-zero on fail. +verify_task() { + local task_json="$1" + local task_id kind ts outcome + task_id=$(echo "$task_json" | jq -r '.id') + kind=$(echo "$task_json" | jq -r '.kind') + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local checks="[]" + outcome="pass" + + case "$kind" in + scaffold|doc|screenshot|deploy) + # Lightweight kinds: just confirm the working tree is not broken. + checks='[{"name":"git-status-readable","status":"pass"}]' + ;; + feature|fix|test) + # Require a tests directory + at least one test file. + if [ ! -d tests ] && [ ! -d test ] && [ ! -d __tests__ ] && [ ! -d src/__tests__ ]; then + outcome="fail" + checks='[{"name":"tests-dir-exists","status":"fail","output_excerpt":"no tests/ directory found"}]' + else + checks='[{"name":"tests-dir-exists","status":"pass"}]' + # If there's a package.json, try `npm test`; if pyproject.toml, try `pytest`. + if [ -f package.json ] && jq -e '.scripts.test' package.json >/dev/null 2>&1; then + if npm test >/dev/null 2>&1; then + checks=$(echo "$checks" | jq '. + [{"name":"npm-test","status":"pass"}]') + else + outcome="fail" + checks=$(echo "$checks" | jq '. + [{"name":"npm-test","status":"fail"}]') + fi + elif [ -f pyproject.toml ] || [ -f pytest.ini ]; then + if pytest >/dev/null 2>&1; then + checks=$(echo "$checks" | jq '. + [{"name":"pytest","status":"pass"}]') + else + outcome="fail" + checks=$(echo "$checks" | jq '. + [{"name":"pytest","status":"fail"}]') + fi + fi + fi + ;; + *) + outcome="fail" + checks='[{"name":"unknown-kind","status":"fail","output_excerpt":"kind not handled"}]' + ;; + esac + + jq -n \ + --arg task_id "$task_id" \ + --arg kind "$kind" \ + --arg outcome "$outcome" \ + --arg ts "$ts" \ + --argjson checks "$checks" \ + '{schema_version:1, task_id:$task_id, kind:$kind, outcome:$outcome, ts:$ts, checks:$checks}' \ + > "$FRINKLOOP_DIR/qa.json" + + [ "$outcome" = "pass" ] +} + +# Per-milestone verification — runs full test suite + build. +# Returns 0 on pass, non-zero on fail. +verify_milestone() { + local mid="$1" + local outcome="pass" + + if [ -f package.json ]; then + jq -e '.scripts.test' package.json >/dev/null 2>&1 && (npm test >/dev/null 2>&1 || outcome="fail") + jq -e '.scripts.build' package.json >/dev/null 2>&1 && (npm run build >/dev/null 2>&1 || outcome="fail") + fi + + [ "$outcome" = "pass" ] +} + +# Final verification gate — milestone verification + deploy ping (if configured). +verify_final() { + local last_mid + last_mid=$(jq -r '.milestones[-1].id' "$FRINKLOOP_DIR/tasks.json") + verify_milestone "$last_mid" || return 1 + # Deploy ping is Plan 8; skip for Plan 2. + return 0 +} diff --git a/plugin/skills/mvp-loop/PROMPT.md.tmpl b/plugin/skills/mvp-loop/PROMPT.md.tmpl new file mode 100644 index 0000000..f5d1517 --- /dev/null +++ b/plugin/skills/mvp-loop/PROMPT.md.tmpl @@ -0,0 +1,56 @@ +# FrinkLoop Loop — Invariant Prompt + +You are running an autonomous build loop. **Read this every iteration. Do not lose state.** + +## State files (read every iteration) + +- `<project>/.frinkloop/state.json` — current pointer (status, current_milestone, current_task, iteration_count, branch) +- `<project>/.frinkloop/tasks.json` — milestones and tasks +- `<project>/.frinkloop/spec.md` — frozen MVP spec (what done looks like, in-MVP, deferred) +- `<project>/.frinkloop/PROMPT.md` — this file (re-fed every iteration by the Stop hook) +- `<project>/.frinkloop/decisions.md` — append-only prose log +- `<project>/.frinkloop/blockers.md` — append-only blockers (only in flag-on-blocker mode) + +## What to do this iteration + +1. Read `state.json` to identify the active milestone and current task. +2. If `status == "done"`, emit `DONE` and stop. +3. Run `bash plugin/lib/loop.sh; pick_next_task` to get the next task id (or empty). +4. If empty, mark the active milestone done with `mark_milestone_done`, then check if the final milestone is done — if so, set status=done and emit `DONE`. +5. Otherwise, dispatch the appropriate subagent via the Task tool: + - kind=scaffold → scaffolder (Plan 3; for Plan 2 mark the task done manually if scaffolder is unavailable) + - kind=feature, fix, test, doc → builder + - (qa runs after, separately) +6. After the builder returns, run the qa subagent. +7. Run `bash plugin/lib/verify.sh; verify_task "<task json>"`. If it fails: + - Increment retries on the task; queue a fix task with `queue_fix_task <task_id> "<error summary>"`. +8. If verify passes, run `mark_task_done <task_id> "<one-line decision>"`. +9. Append a structured iteration entry — handled automatically by the post-iteration hook. + +## Compression + +Compression level is governed by `<project>/.frinkloop/config.yaml`. If `compression: full|ultra`, run subagent prompts through caveman before dispatch. Skill-body prose itself is compression-off. + +## When to emit DONE + +Emit the literal string `DONE` (uppercase, no punctuation, no extra text on the line) only when: +- All milestones in `tasks.json` have `status: "done"` +- Final verification gate (`bash plugin/lib/verify.sh; verify_final`) returned 0 + +The Stop hook reads `state.json` directly, not this prompt — but `DONE` in the assistant output is a backup signal that downstream tools can scrape. + +## Stop conditions handled by the Stop hook + +- `status == "paused"` → exit +- `status == "blocked"` → exit (write blockers.md first) +- `status == "quota-stopped"` → exit (Plan 7 launchd job will resume) +- `status == "done"` → exit +- `status == "running"` and no pending tasks → exit +- Otherwise → continue + +## Constraints + +- Compression off for the loop's own narration. On for subagent prompts when configured. +- Do NOT read decisions.md or iteration-log.jsonl every iteration — only read state.json + the active task in tasks.json + spec.md (the spec is small). +- Do NOT add extra fields to state.json or tasks.json beyond the schemas. +- Do NOT skip qa or verify — that's the integrity contract. diff --git a/plugin/skills/mvp-loop/SKILL.md b/plugin/skills/mvp-loop/SKILL.md index fce9d8d..743ec86 100644 --- a/plugin/skills/mvp-loop/SKILL.md +++ b/plugin/skills/mvp-loop/SKILL.md @@ -1,12 +1,71 @@ --- name: mvp-loop -description: FrinkLoop's autonomous build loop — Stop-hook spine with parallel subagent fan-out. Reads disk state, picks one task or batch, executes, verifies, logs. Use after intake-chat finishes scaffolding. +description: FrinkLoop's autonomous build loop — Stop-hook spine. Reads disk state, picks one task, dispatches a builder subagent, runs qa, verifies, marks done, logs. Use after intake-chat finishes scaffolding. Sequential in Plan 2; parallel fan-out arrives in Plan 4. --- -# mvp-loop (placeholder — implemented in Plan 2) +# mvp-loop -This skill will run the autonomous build loop per design spec §9. +This skill drives the FrinkLoop autonomous build loop. It runs inside one Claude Code session. The Stop hook keeps the session re-prompting until DONE. -For Plan 1 it is a placeholder. Invocation should print: +## Preconditions -> "FrinkLoop build loop arrives in Plan 2. For now, you have a frozen spec and config — review them in `<project>/.frinkloop/`." +The intake-chat skill (or `/frinkloop new`) must have produced: +- `<project>/.frinkloop/spec.md` +- `<project>/.frinkloop/config.yaml` +- `<project>/.frinkloop/tasks.json` +- `<project>/.frinkloop/state.json` (status=running) +- `<project>/.frinkloop/PROMPT.md` (copied from the template) + +`PROJECT_DIR` and `FRINKLOOP_DIR` must be exported in the session env. The session preamble sources `plugin/lib/state.sh`, `plugin/lib/loop.sh`, `plugin/lib/verify.sh`, and `plugin/lib/recovery.sh`. + +## Per-iteration algorithm + +Run these steps EVERY iteration. Read `<project>/.frinkloop/PROMPT.md` first thing every turn — that's the invariant. + +1. Source helpers (idempotent). +2. Read `state.json`. If `status` ∈ {paused, blocked, quota-stopped, done}, exit (Stop hook will let the session end). +3. Call `pick_next_task`. If empty: + - Look for an in-progress milestone. Run `mark_milestone_done <mid>`. + - If the LAST milestone is now done, run `verify_final`. If it returns 0, set `status=done`, emit `DONE`, exit. + - Otherwise the next milestone takes over; loop continues next iteration. +4. Read the picked task's full record from `tasks.json`. +5. Dispatch the right subagent via the Task tool: + - `kind=scaffold` → `scaffolder` (Plan 3; if unavailable in Plan 2, mark done after a manual confirmation in decisions.md) + - `kind=feature, fix, test, doc` → `builder` + - `kind=deploy, screenshot` → defer to Plan 8 (mark deferred for now) +6. After builder finishes, dispatch the `qa` subagent. It writes `qa.json`. +7. Call `verify_task '<task json>'`. If non-zero: + - Increment retries on the task in tasks.json. + - If retries < 3: `queue_fix_task <task_id> "<one-line error summary>"`. Continue next iteration. + - If retries == 3: open a blocker via `open_blocker <task_id> "verify failed 3x"`. Set `status=blocked`. Exit. +8. If verify passes: `mark_task_done <task_id> "<one-line rationale>"`. +9. The post-iteration hook handles iteration-log + state.iteration_count++. +10. End of turn. Stop hook decides whether to re-prompt. + +## TDD discipline + +Read `config.yaml` at start. If `tdd: true` (commercial mode default), every `kind=feature` task spawns a paired `kind=test` task that runs FIRST in the iteration order. The planner is responsible for inserting these test tasks. + +## HITL handoff (milestone-checkpoint mode) + +If `config.yaml` has `hitl: milestones`, after each milestone is marked done, set `status=paused` and emit a one-line summary. The Stop hook will exit. The user runs `/frinkloop resume <project>` to continue. + +## Compression + +Read `config.yaml`. If `compression ∈ {lite, full, ultra}`, prepend a caveman directive to subagent prompts when dispatching. Loop narration itself stays uncompressed. + +## What this skill is NOT + +- Not parallel — that's Plan 4 +- Not template-aware — that's Plan 3 +- Not deployment-aware — that's Plan 8 +- Not learning-aware — that's Plan 6 +- Not quota-aware — that's Plan 7 + +## On every dispatch + +When invoking a subagent, the prompt MUST include: +- The exact task JSON (id, title, kind, depends_on, retries) +- Path constraints: subagent only writes to `$PROJECT_DIR/`, never to the plugin +- Output contract: subagent must write its artifact to a known path under `$FRINKLOOP_DIR/` (qa.json for qa; nothing extra for builder — it just edits files and commits) +- Compression directive (if config says so) diff --git a/tests/plan-1/test_state_helpers.bats b/tests/plan-1/test_state_helpers.bats index 507f870..6019047 100644 --- a/tests/plan-1/test_state_helpers.bats +++ b/tests/plan-1/test_state_helpers.bats @@ -49,3 +49,11 @@ teardown() { run npx --no-install ajv validate -s plugin/lib/schemas/state.schema.json -d "$FRINKLOOP_DIR/state.json" --strict=false [ "$status" -eq 0 ] } + +@test "state_increment_iteration stamps last_iteration_at" { + state_init main + state_increment_iteration + run jq -r '.last_iteration_at' "$FRINKLOOP_DIR/state.json" + [ "$status" -eq 0 ] + [ "$output" != "null" ] +} diff --git a/tests/plan-2/test_commands_real.bats b/tests/plan-2/test_commands_real.bats new file mode 100644 index 0000000..85d98c8 --- /dev/null +++ b/tests/plan-2/test_commands_real.bats @@ -0,0 +1,19 @@ +#!/usr/bin/env bats + +@test "frinkloop-resume.md no longer says 'Resume arrives in Plan 2'" { + ! grep -q "Resume arrives in Plan 2" plugin/commands/frinkloop-resume.md +} + +@test "frinkloop-resume.md references state.json validation and recovery.sh" { + grep -q "state.json" plugin/commands/frinkloop-resume.md + grep -q "recovery.sh" plugin/commands/frinkloop-resume.md +} + +@test "frinkloop-pause.md no longer says 'Pause arrives in Plan 2'" { + ! grep -q "Pause arrives in Plan 2" plugin/commands/frinkloop-pause.md +} + +@test "frinkloop-pause.md sets status to paused and triggers handoff" { + grep -q "paused" plugin/commands/frinkloop-pause.md + grep -q "handoff" plugin/commands/frinkloop-pause.md +} diff --git a/tests/plan-2/test_e2e_iteration.bats b/tests/plan-2/test_e2e_iteration.bats new file mode 100644 index 0000000..6adaad9 --- /dev/null +++ b/tests/plan-2/test_e2e_iteration.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats + +setup() { + TMPDIR=$(mktemp -d) + export PROJECT_DIR="$TMPDIR/proj" + export FRINKLOOP_DIR="$PROJECT_DIR/.frinkloop" + mkdir -p "$FRINKLOOP_DIR" + + PLUGIN_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin" && pwd)" + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + cd "$PROJECT_DIR" + git init -q + git config user.email t@example.com + git config user.name t + + source "$PLUGIN_DIR/lib/state.sh" + source "$PLUGIN_DIR/lib/loop.sh" + source "$PLUGIN_DIR/lib/verify.sh" + source "$PLUGIN_DIR/lib/recovery.sh" + + state_init main + state_set status running + + cat > "$FRINKLOOP_DIR/tasks.json" <<EOF +{ + "schema_version": 1, + "milestones": [ + { "id": "m1", "title": "Setup", "status": "in-progress", + "tasks": [ + {"id":"T01","title":"Write README","status":"pending","kind":"doc"} + ] + } + ] +} +EOF + + echo "# initial" > README.md + git add README.md + git -c commit.gpgsign=false commit -q -m "init" + + export PLUGIN_DIR REPO_DIR +} + +teardown() { + cd / + rm -rf "$TMPDIR" +} + +@test "one full iteration: pick → builder simulated → qa → mark done" { + # 1. pick + task_id=$(pick_next_task) + [ "$task_id" = "T01" ] + + # 2. builder simulated: edit README and commit + echo "## Hello" >> README.md + git add README.md + git -c commit.gpgsign=false commit -q -m "doc(readme): expand" + + # 3. qa: run verify + task_json=$(jq --arg tid "$task_id" '.milestones[].tasks[] | select(.id==$tid)' "$FRINKLOOP_DIR/tasks.json") + run verify_task "$task_json" + [ "$status" -eq 0 ] + [ -f "$FRINKLOOP_DIR/qa.json" ] + + # 4. mark done + mark_task_done "$task_id" "Expanded README" + + # 5. milestone complete → mark milestone done + mark_milestone_done m1 + run jq -r '.milestones[0].status' "$FRINKLOOP_DIR/tasks.json" + [ "$output" = "done" ] + + # 6. final: state shows done after we set it explicitly + state_set status done + run "$PLUGIN_DIR/hooks/stop.sh" + [ "$status" -eq 0 ] # status=done → stop hook lets session exit +} + +@test "verify failure path: queue_fix_task gets called and Stop hook keeps looping" { + task_id=$(pick_next_task) + task_json=$(jq --arg tid "$task_id" '.milestones[].tasks[] | select(.id==$tid)' "$FRINKLOOP_DIR/tasks.json") + + # Force fail: change task kind to feature so verify expects tests/, which doesn't exist + jq --arg tid "$task_id" '(.milestones[0].tasks[] | select(.id==$tid).kind) = "feature"' "$FRINKLOOP_DIR/tasks.json" > /tmp/t && mv /tmp/t "$FRINKLOOP_DIR/tasks.json" + + run verify_task "$(jq --arg tid "$task_id" '.milestones[].tasks[] | select(.id==$tid)' "$FRINKLOOP_DIR/tasks.json")" + [ "$status" -ne 0 ] + + queue_fix_task "$task_id" "no tests dir" + + # New fix task added + run jq -r '.milestones[0].tasks | length' "$FRINKLOOP_DIR/tasks.json" + [ "$output" -eq 2 ] + + # Stop hook should keep looping (status running, pending tasks exist) + run "$PLUGIN_DIR/hooks/stop.sh" + [ "$status" -eq 2 ] +} diff --git a/tests/plan-2/test_hooks.bats b/tests/plan-2/test_hooks.bats new file mode 100644 index 0000000..85bb807 --- /dev/null +++ b/tests/plan-2/test_hooks.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats + +setup() { + TMPDIR=$(mktemp -d) + export FRINKLOOP_DIR="$TMPDIR/.frinkloop" + mkdir -p "$FRINKLOOP_DIR" + PLUGIN_LIB_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin/lib" && pwd)" + source "$PLUGIN_LIB_DIR/state.sh" +} + +teardown() { + rm -rf "$TMPDIR" +} + +@test "stop hook exits 0 when state.json is missing" { + STOP_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/stop.sh" + run "$STOP_HOOK" + [ "$status" -eq 0 ] +} + +@test "stop hook exits 0 when status is done" { + state_init main + state_set status done + STOP_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/stop.sh" + run "$STOP_HOOK" + [ "$status" -eq 0 ] +} + +@test "stop hook exits 0 when status is paused" { + state_init main + state_set status paused + STOP_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/stop.sh" + run "$STOP_HOOK" + [ "$status" -eq 0 ] +} + +@test "stop hook exits 2 when status is running and tasks pending" { + state_init main + state_set status running + cat > "$FRINKLOOP_DIR/tasks.json" <<EOF +{ + "schema_version": 1, + "milestones": [ + { "id": "m1", "title": "x", "status": "in-progress", + "tasks": [{"id": "T01", "title": "x", "status": "pending", "kind": "feature"}] + } + ] +} +EOF + STOP_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/stop.sh" + run "$STOP_HOOK" + [ "$status" -eq 2 ] +} + +@test "stop hook exits 0 when status is running but no pending tasks" { + state_init main + state_set status running + cat > "$FRINKLOOP_DIR/tasks.json" <<EOF +{ + "schema_version": 1, + "milestones": [ + { "id": "m1", "title": "x", "status": "done", + "tasks": [{"id": "T01", "title": "x", "status": "done", "kind": "feature"}] + } + ] +} +EOF + STOP_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/stop.sh" + run "$STOP_HOOK" + [ "$status" -eq 0 ] +} + +@test "post-iteration hook increments iteration_count and appends a log line" { + state_init main + POST_ITER_HOOK="$(cd "$BATS_TEST_DIRNAME/../../plugin/hooks" && pwd)/post-iteration.sh" + run "$POST_ITER_HOOK" + [ "$status" -eq 0 ] + run jq -r '.iteration_count' "$FRINKLOOP_DIR/state.json" + [ "$output" = "1" ] + [ -f "$FRINKLOOP_DIR/iteration-log.jsonl" ] + run wc -l < "$FRINKLOOP_DIR/iteration-log.jsonl" + [ "$output" -eq 1 ] +} diff --git a/tests/plan-2/test_loop_helpers.bats b/tests/plan-2/test_loop_helpers.bats new file mode 100644 index 0000000..1d4ac6d --- /dev/null +++ b/tests/plan-2/test_loop_helpers.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +setup() { + TMPDIR=$(mktemp -d) + export FRINKLOOP_DIR="$TMPDIR/.frinkloop" + mkdir -p "$FRINKLOOP_DIR" + source plugin/lib/state.sh + source plugin/lib/loop.sh + state_init main + cat > "$FRINKLOOP_DIR/tasks.json" <<EOF +{ + "schema_version": 1, + "milestones": [ + { + "id": "m1", + "title": "Scaffold", + "status": "in-progress", + "tasks": [ + {"id": "T01", "title": "Run giget", "status": "done", "kind": "scaffold"}, + {"id": "T02", "title": "Apply tailwind recipe", "status": "pending", "kind": "scaffold"}, + {"id": "T03", "title": "Add login form", "status": "pending", "kind": "feature", "depends_on": ["T02"]} + ] + }, + { + "id": "m2", + "title": "Polish", + "status": "pending", + "tasks": [ + {"id": "T04", "title": "Write README", "status": "pending", "kind": "doc"} + ] + } + ] +} +EOF +} + +teardown() { + rm -rf "$TMPDIR" +} + +@test "pick_next_task picks the first non-blocked pending task in the active milestone" { + run pick_next_task + [ "$status" -eq 0 ] + [ "$output" = "T02" ] +} + +@test "pick_next_task respects depends_on" { + # T03 depends on T02 which is still pending; pick_next_task must NOT return T03 + run pick_next_task + [ "$output" != "T03" ] +} + +@test "pick_next_task returns empty when active milestone has no actionable tasks" { + jq '(.milestones[0].tasks[1].status) = "done"' "$FRINKLOOP_DIR/tasks.json" > /tmp/t && mv /tmp/t "$FRINKLOOP_DIR/tasks.json" + jq '(.milestones[0].tasks[2].status) = "done"' "$FRINKLOOP_DIR/tasks.json" > /tmp/t && mv /tmp/t "$FRINKLOOP_DIR/tasks.json" + run pick_next_task + [ "$output" = "" ] +} + +@test "mark_task_done flips a task status and writes decisions entry" { + mark_task_done T02 "Applied tailwind via shadcn-style recipe" + run jq -r '.milestones[0].tasks[1].status' "$FRINKLOOP_DIR/tasks.json" + [ "$output" = "done" ] + grep -q "T02" "$FRINKLOOP_DIR/decisions.md" +} + +@test "queue_fix_task adds a fix task with kind=fix and depends_on parent" { + queue_fix_task T03 "form submit handler crashes on empty input" + run jq -r '.milestones[0].tasks | length' "$FRINKLOOP_DIR/tasks.json" + [ "$output" -eq 4 ] + run jq -r '.milestones[0].tasks[3].kind' "$FRINKLOOP_DIR/tasks.json" + [ "$output" = "fix" ] + run jq -r '.milestones[0].tasks[3].depends_on[0]' "$FRINKLOOP_DIR/tasks.json" + [ "$output" = "T03" ] +} + +@test "mark_milestone_done flips milestone status when all tasks done" { + mark_task_done T02 "ok" + mark_task_done T03 "ok" + mark_milestone_done m1 + run jq -r '.milestones[0].status' "$FRINKLOOP_DIR/tasks.json" + [ "$output" = "done" ] +} + +@test "active_milestone returns the first in-progress or pending milestone" { + run active_milestone + [ "$output" = "m1" ] +} diff --git a/tests/plan-2/test_recovery.bats b/tests/plan-2/test_recovery.bats new file mode 100644 index 0000000..d7e6b1b --- /dev/null +++ b/tests/plan-2/test_recovery.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +setup() { + TMPDIR=$(mktemp -d) + export PROJECT_DIR="$TMPDIR/proj" + export FRINKLOOP_DIR="$PROJECT_DIR/.frinkloop" + mkdir -p "$FRINKLOOP_DIR" + cd "$PROJECT_DIR" + git init -q + git config user.email t@example.com + git config user.name t + # Create .gitkeep so .frinkloop/ is tracked and tree stays clean + touch "$FRINKLOOP_DIR/.gitkeep" + echo "# initial" > README.md + git add .frinkloop README.md + git -c commit.gpgsign=false commit -q -m "init" + # Pre-resolve plugin dir from test file location (workaround for cd breaking relative paths) + export PLUGIN_LIB_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin/lib" && pwd)" + source "$PLUGIN_LIB_DIR/state.sh" + # Create state.json and add it to git so tree stays clean + state_init main + git add "$FRINKLOOP_DIR/state.json" + git -c commit.gpgsign=false commit -q -m "init state" + source "$PLUGIN_LIB_DIR/recovery.sh" +} + +teardown() { + cd / + rm -rf "$TMPDIR" +} + +@test "detect_dirty_tree returns 0 when working tree is clean" { + run detect_dirty_tree + [ "$status" -eq 0 ] +} + +@test "detect_dirty_tree returns 1 when working tree has unstaged changes" { + echo "modified" >> README.md + run detect_dirty_tree + [ "$status" -eq 1 ] +} + +@test "open_blocker writes blockers.md entry" { + open_blocker "T03" "user manually edited working tree mid-loop" + [ -f "$FRINKLOOP_DIR/blockers.md" ] + grep -q "T03" "$FRINKLOOP_DIR/blockers.md" + grep -q "user manually edited" "$FRINKLOOP_DIR/blockers.md" +} + +@test "resume_or_block returns 'resume' on clean tree" { + run resume_or_block + [ "$output" = "resume" ] +} + +@test "resume_or_block returns 'block' on dirty tree and writes blockers.md" { + echo "wat" > extra.md + git add extra.md + run resume_or_block + [ "$output" = "block" ] + [ -f "$FRINKLOOP_DIR/blockers.md" ] +} diff --git a/tests/plan-2/test_skill_bodies.bats b/tests/plan-2/test_skill_bodies.bats new file mode 100644 index 0000000..135aa62 --- /dev/null +++ b/tests/plan-2/test_skill_bodies.bats @@ -0,0 +1,46 @@ +#!/usr/bin/env bats + +@test "PROMPT.md.tmpl exists and references key state files" { + [ -f plugin/skills/mvp-loop/PROMPT.md.tmpl ] + grep -q "state.json" plugin/skills/mvp-loop/PROMPT.md.tmpl + grep -q "tasks.json" plugin/skills/mvp-loop/PROMPT.md.tmpl + grep -q "spec.md" plugin/skills/mvp-loop/PROMPT.md.tmpl + grep -q "PROMPT.md" plugin/skills/mvp-loop/PROMPT.md.tmpl +} + +@test "PROMPT.md.tmpl has a DONE marker the Stop hook can recognize" { + grep -q "DONE" plugin/skills/mvp-loop/PROMPT.md.tmpl +} + +@test "mvp-loop SKILL.md is no longer the Plan 1 placeholder" { + ! grep -q "placeholder — implemented in Plan 2" plugin/skills/mvp-loop/SKILL.md +} + +@test "mvp-loop SKILL.md describes the per-iteration steps" { + grep -q "pick_next_task" plugin/skills/mvp-loop/SKILL.md + grep -q "verify_task" plugin/skills/mvp-loop/SKILL.md + grep -q "mark_task_done" plugin/skills/mvp-loop/SKILL.md + grep -q "queue_fix_task" plugin/skills/mvp-loop/SKILL.md +} + +@test "mvp-loop SKILL.md references all 3 subagent roles" { + grep -q "planner" plugin/skills/mvp-loop/SKILL.md + grep -q "builder" plugin/skills/mvp-loop/SKILL.md + grep -q "qa" plugin/skills/mvp-loop/SKILL.md +} + +@test "planner agent has real body and references spec.md + tasks.json" { + ! grep -q "Placeholder. Will be implemented" plugin/agents/planner.md + grep -q "spec.md" plugin/agents/planner.md + grep -q "tasks.json" plugin/agents/planner.md +} + +@test "builder agent has real body and emphasizes commit-per-task" { + ! grep -q "Placeholder. Will be implemented" plugin/agents/builder.md + grep -q "git commit" plugin/agents/builder.md +} + +@test "qa agent has real body and writes qa.json" { + ! grep -q "Placeholder. Will be implemented" plugin/agents/qa.md + grep -q "qa.json" plugin/agents/qa.md +} diff --git a/tests/plan-2/test_verify.bats b/tests/plan-2/test_verify.bats new file mode 100644 index 0000000..95a515b --- /dev/null +++ b/tests/plan-2/test_verify.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +setup() { + TMPDIR=$(mktemp -d) + export PROJECT_DIR="$TMPDIR/proj" + export FRINKLOOP_DIR="$PROJECT_DIR/.frinkloop" + mkdir -p "$FRINKLOOP_DIR" + cd "$PROJECT_DIR" + git init -q + git config user.email t@example.com + git config user.name t + # Pre-resolve plugin dir from test file location (workaround for cd breaking relative paths) + export PLUGIN_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin" && pwd)" + source "$PLUGIN_DIR/lib/verify.sh" +} + +teardown() { + cd / + rm -rf "$TMPDIR" +} + +@test "verify_task accepts a doc kind without running tests" { + echo "# README" > README.md + run verify_task '{"id":"T04","kind":"doc","title":"Write README"}' + [ "$status" -eq 0 ] +} + +@test "verify_task fails when test kind has no tests dir" { + run verify_task '{"id":"T05","kind":"test","title":"Add tests"}' + [ "$status" -ne 0 ] +} + +@test "verify_task writes a qa-result artifact" { + echo "# README" > README.md + verify_task '{"id":"T04","kind":"doc","title":"Write README"}' + [ -f "$FRINKLOOP_DIR/qa.json" ] + run jq -r '.task_id' "$FRINKLOOP_DIR/qa.json" + [ "$output" = "T04" ] + run jq -r '.outcome' "$FRINKLOOP_DIR/qa.json" + [ "$output" = "pass" ] +} + +@test "qa.json validates against schema" { + echo "# README" > README.md + verify_task '{"id":"T04","kind":"doc","title":"Write README"}' + # Run npx from the repo root where node_modules lives (PLUGIN_DIR/../) + local repo_root + repo_root="$(cd "$PLUGIN_DIR/.." && pwd)" + run bash -c "cd '$repo_root' && npx --no-install ajv validate -s '$PLUGIN_DIR/lib/schemas/qa-result.schema.json' -d '$FRINKLOOP_DIR/qa.json' --strict=false" + [ "$status" -eq 0 ] +}