Plan Forge — API

Objective in, construction plan out — from your own tools.

API tokens Open the app

Forge plans from your own tools

Send one line of objective plus the facts about your project, and get back a dependency-ordered construction plan: one-PR-sized steps in the order they must happen, each carrying a self-contained context brief a fresh agent can execute cold, a real verification command, an observable exit criterion and a model-tier hint - followed by an honest review that raises the decisions only you can settle instead of guessing at your codebase. You can also send an existing plan and have it audited and revised against the same standards. Everything the web app does goes through the SkillSafe App API - plain JSON over HTTPS - so you can wire plan forging into an issue tracker, a CI job, or the front of your own agent pipeline. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. There is no /apps/{slug}/ path segment - the app slug plan-forge is bound to your token when the token is minted at POST /guest, and every later call is simply addressed to the shared paths below. Every request sends Authorization: Bearer <token>, and JSON bodies go out with Content-Type: application/json.

MethodPathWhat it does
POST/guestMint an anonymous token bound to this app. Body: {"slug":"plan-forge"}.
GET/meWho the token belongs to, and the credit balance.
POST/estimateWorst-case price of a run with this exact input. Free, creates no job.
POST/runStart a metered run, returns a job_id. Poll GET /jobs/{job_id}.
POST/run-streamSame body as /run, answers with server-sent events.

The envelope

Every response is wrapped. On success you get {"ok":true,"data":{ … }}; on failure {"ok":false,"error":{"code":"…","message":"…"}}. Read data, never the top level, and branch on error.code rather than on the message text - messages are written for humans and may change.

Statuserror.codeMeaning
401unauthorizedMissing, expired or revoked token. Mint a new one - see step 1.
402payment_requiredNot enough credits to place the hold. Top up, or check /estimate first.
404not_foundUnknown job id (or an unknown app slug at /guest).
422 / 400validation_errorThe input object failed validation - usually neither objective nor existing was supplied, or a field was the wrong type.
429rate_limitedToo many requests. Back off and retry; honour Retry-After when present.
5xxinternal_errorTransient platform error. Retry with backoff and the same Idempotency-Key.

Two things that trip people up. First, the body of /run and /estimate is the input object itself - send {"objective": "...", "mode": "Plan"}, not {"input": {...}}. Second, browsers enforce CORS on this API, so run these examples from a server, a script or a terminal - not from another website's frontend.

Step 0 — A tiny client

Every call below is one HTTP request, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it. Replace YOUR_TOKEN with the token from step 1 - or read it from the SKILLSAFE_TOKEN environment variable, as the compiled languages do here.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"    # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"ok":true,"data":...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1; in real code read it from your shell environment

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not payload.get("ok", res.ok):
        err = payload.get("error", {})
        raise RuntimeError(f"{err.get('code', res.status_code)}: {err.get('message', res.reason)}")
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1; in real code read it from your shell environment

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      ...extraHeaders,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (json.ok === false || !res.ok) {
    throw new Error(`${json.error?.code ?? res.status}: ${json.error?.message ?? res.statusText}`);
  }
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		OK    bool            `json:"ok"`
		Data  json.RawMessage `json:"data"`
		Error *struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if env.Error != nil || res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson, ...)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody, String... hdrs) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody));
        for (int i = 0; i + 1 < hdrs.length; i += 2) b.header(hdrs[i], hdrs[i + 1]);
        var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body()); // {"ok":false,"error":{...}}
        return res.body();                                                   // {"ok":true,"data":{...}}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  headers.each { |k, v| req[k] = v }
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  unless payload["ok"]
    raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
  end
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null, array $headers = []): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ], $headers),
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new Exception(($payload["error"]["code"] ?? "error") . ": "
            . ($payload["error"]["message"] ?? "request failed"));
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(
        HttpMethod method, string path, object? body = null,
        (string, string)? extraHeader = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (extraHeader is { } h) req.Headers.Add(h.Item1, h.Item2);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!json.TryGetProperty("ok", out var ok) || !ok.GetBoolean())
        {
            var e = json.GetProperty("error");
            throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
        }
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token is free to mint, needs no browser, and is enough for GET /me and the free POST /estimate. For metered forge runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export - it puts export SKILLSAFE_TOKEN="…" on your clipboard, ready to paste into the terminal you are working in. The token page also shows which kind of token this browser currently holds and lets you replace or forget it, so you never need to go digging in developer tools.

The slug in the body is the only place the app is named. The returned token carries that binding, which is why none of the later paths mention plan-forge. Treat the token like a password: it can spend your credits.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"plan-forge"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "plan-forge"})["token"]
print(token)
const { token } = await api("POST", "/guest", { slug: "plan-forge" });
console.log(token);
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}
if err := call("POST", "/guest", map[string]string{"slug": "plan-forge"}, &guest); err != nil {
	log.Fatal(err)
}
fmt.Println(guest.Token)
String envelope = api("POST", "/guest", """
    {"slug":"plan-forge"}""");
// the token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "plan-forge" })["token"]
puts token
$token = api("POST", "/guest", ["slug" => "plan-forge"])["token"];
echo "$token\n";
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "plan-forge" });
var token = guest.GetProperty("token").GetString();
Console.WriteLine(token);

The web app stores this browser's token in localStorage under skillsafe_app_token:plan-forge, on the app's own origin. The token page reads, reveals, copies and replaces it for you.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. This is also the cheapest way to find out that a stored token has gone stale - a rejected token answers 401 unauthorized here, before you have committed a long context to a run.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# { "subject_type": "user", "subject_id": "usr_...", "credits": 12480 }
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits")
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits, "credits");
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s: %d credits\n", me.SubjectType, me.SubjectID, me.Credits)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} " +
                  $"{me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run - the input object directly, not wrapped in input. Nothing is charged and no job is created, so estimating is free. Useful before you pipe a large context or a long existing plan in and want a ceiling first.

Input fields

FieldTypeNotes
objectivestring, required unless existing is givenOne line saying what should end up built or changed. Clipped to 500 characters, so keep it to the outcome and put the detail in context.
contextstring, optionalYour project's facts: stack, layout, constraints, what already exists. The plan builds only on what you state here - anything essential you leave out comes back as an open decision in the review, never as a guessed file name. Clipped to 20000 characters.
existingstring, optionalAn existing plan in markdown to audit and revise. Clipped to 40000 characters. If you send this you may omit objective; sending both keeps the revision pointed at the objective you state.
modestring"Plan" writes a new plan from the objective. "Revise" audits the plan in existing against the same standards, keeps what is sound, and returns the revised plan with the changes accounted for.
lintarray of strings, optionalFindings from a client-side analyzer, passed through as untrusted hints - the model weighs them, it does not obey them. At most 12 entries, each formatted "<rule> @ line <n>: <excerpt>", for example "hollow-verify @ line 42: - Verify: make sure it works". The web app fills this from its in-browser plan analyzer; API callers may omit it.
retry_notestring, optionalOnly set by the app's automatic reformat retry, when a first reply did not follow the output contract. Leave it out of your own calls.

What comes back

FieldMeaning
hold_creditsWorst-case cost. This is the amount held when you call /run; the unused part is released when the run settles.
min_creditsFloor price for a run with this input.
modelThe model that will do the work - "gpt-5.6-terra".
model_aliasIts stable public alias - "gpt-terra". Prefer this in anything you log or display.
markup_bpsPlatform markup in basis points; 1000 is 10 percent.
sponsor_enabledTrue when someone else is sponsoring runs of this app, so your balance is not what pays.
byokTrue when the run bills to your own provider key rather than to credits.
cat > context.txt <<'CONTEXT'
Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
app/controllers/uploads_controller.rb and are resized inline with the
image_processing gem, which is what blocks the web workers. Sidekiq is already
running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
CONTEXT

jq -n --rawfile ctx context.txt \
  '{objective: "Move image processing out of the web process into a worker queue",
    context: $ctx,
    mode: "Plan",
    lint: []}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, min_credits, model_alias, byok}'
CONTEXT = """Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
app/controllers/uploads_controller.rb and are resized inline with the
image_processing gem, which is what blocks the web workers. Sidekiq is already
running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
"""

payload = {
    "objective": "Move image processing out of the web process into a worker queue",
    "context": CONTEXT,
    "mode": "Plan",
    "lint": [],
}

est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits")
print("floor:", est["min_credits"], "on", est["model_alias"])
const context = [
  "Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in",
  "app/controllers/uploads_controller.rb and are resized inline with the",
  "image_processing gem, which is what blocks the web workers. Sidekiq is already",
  "running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.",
].join("\n");

const payload = {
  objective: "Move image processing out of the web process into a worker queue",
  context,
  mode: "Plan",
  lint: [],
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model_alias);
const context = "Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in\n" +
	"app/controllers/uploads_controller.rb and are resized inline with the\n" +
	"image_processing gem, which is what blocks the web workers. Sidekiq is already\n" +
	"running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.\n"

payload := map[string]any{
	"objective": "Move image processing out of the web process into a worker queue",
	"context":   context,
	"mode":      "Plan",
	"lint":      []string{},
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	BYOK        bool   `json:"byok"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	log.Fatal(err)
}
fmt.Printf("worst case: %d credits on %s\n", est.HoldCredits, est.ModelAlias)
String context = """
    Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
    app/controllers/uploads_controller.rb and are resized inline with the
    image_processing gem, which is what blocks the web workers. Sidekiq is already
    running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
    """;

// toJsonString() is your JSON library's string escaper (Jackson: writeValueAsString).
String jsonPayload = """
    {"objective": "Move image processing out of the web process into a worker queue",
     "context": %s,
     "mode": "Plan",
     "lint": []}
    """.formatted(toJsonString(context));

String envelope = api("POST", "/estimate", jsonPayload);
// data.hold_credits, data.min_credits, data.model_alias, data.markup_bps, data.byok
CONTEXT = <<~CONTEXT
  Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
  app/controllers/uploads_controller.rb and are resized inline with the
  image_processing gem, which is what blocks the web workers. Sidekiq is already
  running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
CONTEXT

payload = { objective: "Move image processing out of the web process into a worker queue",
            context: CONTEXT,
            mode: "Plan",
            lint: [] }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model_alias"]}"
$context = <<<'CONTEXT'
Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
app/controllers/uploads_controller.rb and are resized inline with the
image_processing gem, which is what blocks the web workers. Sidekiq is already
running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
CONTEXT;

$payload = [
    "objective" => "Move image processing out of the web process into a worker queue",
    "context"   => $context,
    "mode"      => "Plan",
    "lint"      => [],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model_alias']}\n";
var context = """
    Rails 7.1 monolith, Postgres 15, deployed on Fly.io. Image uploads land in
    app/controllers/uploads_controller.rb and are resized inline with the
    image_processing gem, which is what blocks the web workers. Sidekiq is already
    running for mail. No feature flag system. Tests are RSpec, run with `bundle exec rspec`.
    """;

var payload = new {
    objective = "Move image processing out of the web process into a worker queue",
    context,
    mode = "Plan",
    lint = Array.Empty<string>(),
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits " +
                  $"on {est.GetProperty("model_alias")}");

To revise instead of write, swap the payload for {"mode":"Revise","existing":"<your plan in markdown>","context":"…"} and, if you have run a plan analyzer over it, add up to twelve lint hints such as "cycle @ line 88: - Depends on: Step 6". Hints are evidence, not instructions - a finding the plan can justify is answered in the review rather than obeyed.

Step 4 — Forge the plan and wait for it

POST /run
GET /jobs/{job_id}

/run takes the same input object as /estimate, places the credit hold and returns a job_id. Poll /jobs/{job_id} every one to two seconds until status is succeeded or failed; a full plan usually takes 30-90 seconds. Always send an Idempotency-Key header so a network retry cannot start a second, double-charged run - reuse the same key for every retry of the same logical request, and change it only when you genuinely want a new plan.

The result is in output - typically nested as output.output, and always a plain-text string (this app does not return JSON; see step 6 for the contract). Two fields on the job are worth branching on: charged_credits is the settled price, and truncated: true means the reply was cut short by the credits available for the run, so the tail of the plan is missing and you should not feed it to an agent.

# One key per logical request. Reuse it on retries; do not regenerate in a loop.
IDEM="pf-$(uuidgen)"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

[ "$STATUS" = "failed" ] && { echo "$JOB" | jq -r '.data.error'; exit 1; }

echo "$JOB" | jq -r '.data.output.output // .data.output' > plan.md
echo "charged: $(echo "$JOB" | jq -r '.data.charged_credits') credits"

if [ "$(echo "$JOB" | jq -r '.data.truncated // false')" = "true" ]; then
  echo "WARNING: reply was truncated - the plan is incomplete" >&2
fi

head -4 plan.md   # TITLE / VERDICT / SUMMARY
import time, uuid

idem = "pf-" + str(uuid.uuid4())   # one key per logical request; reuse on retry
job_id = api("POST", "/run", payload, **{"Idempotency-Key": idem})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict):
    raw = raw.get("output", raw)
text = raw if isinstance(raw, str) else json.dumps(raw)

print("charged:", job.get("charged_credits"), "credits")
if job.get("truncated"):
    print("WARNING: reply was truncated - the plan is incomplete")

with open("plan.md", "w", encoding="utf-8") as fh:
    fh.write(text)
print(text.split("\n\n", 1)[0])   # TITLE / VERDICT / SUMMARY
import { writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const idem = "pf-" + randomUUID();   // one key per logical request; reuse on retry
const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": idem });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const text = typeof raw === "string" ? raw : String(raw);

console.log("charged:", job.charged_credits, "credits");
if (job.truncated) console.warn("WARNING: reply was truncated - the plan is incomplete");

writeFileSync("plan.md", text);
console.log(text.split("\n\n")[0]);   // TITLE / VERDICT / SUMMARY
idem := "pf-" + uuid.NewString() // one key per logical request; reuse on retry

body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
var startEnv struct {
	Data struct {
		JobID string `json:"job_id"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&startEnv)
res.Body.Close()

var job struct {
	Status         string          `json:"status"`
	Error          string          `json:"error"`
	Truncated      bool            `json:"truncated"`
	ChargedCredits int64           `json:"charged_credits"`
	Output         json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+startEnv.Data.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

// output is usually {"output": "<plain text>"}; fall back to a bare string.
var wrapper struct {
	Output string `json:"output"`
}
text := ""
if err := json.Unmarshal(job.Output, &wrapper); err == nil && wrapper.Output != "" {
	text = wrapper.Output
} else {
	json.Unmarshal(job.Output, &text)
}
if job.Truncated {
	fmt.Println("WARNING: reply was truncated - the plan is incomplete")
}
fmt.Printf("charged: %d credits\n", job.ChargedCredits)
os.WriteFile("plan.md", []byte(text), 0o644)
String idem = "pf-" + java.util.UUID.randomUUID();  // reuse this on retries

String envelope = api("POST", "/run", jsonPayload, "Idempotency-Key", idem);
String jobId = /* data.job_id via your JSON library */ "";

String job;
String status;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    status = /* data.status */ "";
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
if (status.equals("failed")) throw new RuntimeException(/* data.error */ "run failed");

// The plan is at data.output.output as a plain-text STRING (not JSON), with
// data.charged_credits as the settled price and data.truncated flagging a cut-off
// reply. Write it straight to disk, then parse it with the step 6 grammar:
//   Files.writeString(Path.of("plan.md"), planText);
require "securerandom"

idem = "pf-#{SecureRandom.uuid}"   # one key per logical request; reuse on retry
started = api("POST", "/run", payload, { "Idempotency-Key" => idem })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"]
raw = raw.fetch("output", raw) if raw.is_a?(Hash)
text = raw.to_s

puts "charged: #{job["charged_credits"]} credits"
warn "WARNING: reply was truncated - the plan is incomplete" if job["truncated"]

File.write("plan.md", text)
puts text.split("\n\n").first   # TITLE / VERDICT / SUMMARY
$idem = "pf-" . bin2hex(random_bytes(16));  // one key per logical request; reuse on retry
$started = api("POST", "/run", $payload, ["Idempotency-Key: $idem"]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw  = $job["output"];
$text = is_array($raw) ? ($raw["output"] ?? "") : (string) $raw;

echo "charged: {$job['charged_credits']} credits\n";
if (!empty($job["truncated"])) {
    fwrite(STDERR, "WARNING: reply was truncated - the plan is incomplete\n");
}

file_put_contents("plan.md", $text);
echo explode("\n\n", $text)[0] . "\n";   // TITLE / VERDICT / SUMMARY
var idem = "pf-" + Guid.NewGuid();   // one key per logical request; reuse on retry

var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload,
    ("Idempotency-Key", idem));
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
string status;
do
{
    await Task.Delay(1500);
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    status = job.GetProperty("status").GetString()!;
} while (status != "succeeded" && status != "failed");

if (status == "failed")
    throw new Exception(job.TryGetProperty("error", out var e) ? e.ToString() : "run failed");

var outEl = job.GetProperty("output");
var text = outEl.ValueKind == JsonValueKind.Object
    ? outEl.GetProperty("output").GetString()!
    : outEl.GetString()!;

Console.WriteLine($"charged: {job.GetProperty("charged_credits")} credits");
if (job.TryGetProperty("truncated", out var t) && t.GetBoolean())
    Console.Error.WriteLine("WARNING: reply was truncated - the plan is incomplete");

await File.WriteAllTextAsync("plan.md", text);

Idempotency-Key is accepted on both /run and /run-stream. The safe pattern is to derive it from whatever identifies the work - an issue id, a commit sha, a row id - so that a re-delivered webhook or a retried job replays the same run instead of paying for a second one.

Step 5 — Stream the plan as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show the plan arriving instead of a spinner - which matters here, because a full plan with six fields per step is long. The app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted.
delta{text}A chunk of the reply, in order. Append it. Watching for the PLAN: and REVIEW: markers and for ### Step headings gives you a real progress signal, since the total length is not known in advance.
done{job_id, status, charged_credits, output, truncated}The final, authoritative result. Read the plan from output.output rather than trusting the concatenated deltas.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"TITLE: Move image processing to a worker queue\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":512,
#        "truncated":false,"output":{"output":"TITLE: ...\nVERDICT: ...\n..."}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": idem},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(f"{data.get('code')}: {data.get('message')}")

text = result["output"]["output"]                       # authoritative
print("\ncharged:", result["charged_credits"], "credits")
if result.get("truncated"):
    print("WARNING: reply was truncated - the plan is incomplete")
with open("plan.md", "w", encoding="utf-8") as fh:
    fh.write(text)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idem,
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null, seen = "";

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") {
      seen += data.text ?? "";
      const steps = (seen.match(/^### Step \d+:/gm) ?? []).length;
      if (steps) console.log(`step ${steps} drafted`);
    }
    if (name === "done") done = data;
    if (name === "error") throw new Error(`${data.code}: ${data.message}`);
  }
}

const text = done.output.output;
console.log(`${done.charged_credits} credits`);
if (done.truncated) console.warn("WARNING: reply was truncated - the plan is incomplete");
writeFileSync("plan.md", text);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatalf("%v: %v", data["code"], data["message"])
		}
	}
}

text := final["output"].(map[string]any)["output"].(string)
fmt.Printf("\ncharged: %v credits\n", final["charged_credits"])
os.WriteFile("plan.md", []byte(text), 0o644)
// Java 17+ - read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", idem)
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");        // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// Parse `done`: output.output is the plan TEXT, charged_credits the settled price,
// truncated flags a cut-off reply. Write it to plan.md and parse with step 6.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise "#{data["code"]}: #{data["message"]}"
          end
        end
      end
    end
  end
end

text = done["output"]["output"]
puts "\ncharged: #{done["charged_credits"]} credits"
warn "WARNING: reply was truncated - the plan is incomplete" if done["truncated"]
File.write("plan.md", text)
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: $idem",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }            // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") {
                    throw new Exception(($data["code"] ?? "error") . ": " . ($data["message"] ?? ""));
                }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$text = $done["output"]["output"];
echo "\ncharged: {$done['charged_credits']} credits\n";
file_put_contents("plan.md", $text);
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", idem);

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");                 // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine($"\ncharged: {final.RootElement.GetProperty("charged_credits")} credits");
await File.WriteAllTextAsync("plan.md", text);

In a browser the native EventSource only speaks GET, and this endpoint is a POST - read the fetch response body incrementally, as the JavaScript sample does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream, so check the Content-Type before you start splitting frames.

Step 6 — Parse the output contract

The reply is plain text, not JSON, and it follows a fixed grammar. Parsing it is a handful of line rules:

TITLE: Move image processing off the web request path
VERDICT: Ready to execute
SUMMARY: Six steps, three waves, no schema changes; the only fork is retention policy.

PLAN:
### Step 1: Add an ImageProcessingJob shell behind Sidekiq
- Depends on: none
- Model tier: small
- Context brief: Rails 7.1 monolith. Sidekiq is already configured for mail in
  config/initializers/sidekiq.rb. Uploads are handled by UploadsController#create.
- Tasks: Create app/jobs/image_processing_job.rb with a perform(upload_id) that
  loads the Upload and calls the existing resize path. Do not call it yet.
- Verify: bundle exec rspec spec/jobs/image_processing_job_spec.rb
- Done when: The job class enqueues and performs against a fixture upload in the test suite.

### Step 2: Move the resize call into the job
- Depends on: Step 1
- Model tier: medium
- Context brief: ...
- Tasks: ...
- Verify: bundle exec rspec spec/controllers/uploads_controller_spec.rb
- Done when: UploadsController#create returns without doing any image work inline.

REVIEW:
Assumptions: Sidekiq retries are acceptable for resize failures.
Open decisions: how long to keep original uploads once a derivative exists - only you
can settle this, and step 5 changes depending on the answer.
Anti-patterns checked: no step depends on a later step; no step exceeds one PR.
Plan confidence: 78%, the queue path is well established but retention is unsettled.
LineRule
TITLE: <title>The very first line.
VERDICT: <v>Exactly one of Ready to execute, Needs decisions, Not plan-shaped. This is the field to gate automation on.
SUMMARY: <line>One line, then a blank line.
PLAN:A line reading exactly that. Everything until REVIEW: is the plan body.
### Step N: <title>Starts a step. Each step carries six fields, in this order: - Depends on:, - Model tier:, - Context brief:, - Tasks:, - Verify:, - Done when:. A field's value may continue onto indented following lines.
REVIEW:A line reading exactly that. Everything after it is the review.
Plan confidence: NN%, <clause>.The last line of the review, and of the whole reply.

The samples below split one reply into title, verdict, summary, the step list and the review, then exit non-zero unless the verdict is Ready to execute - the shape of a CI gate.

TITLE=$(grep -m1 '^TITLE: '   plan.md | cut -d' ' -f2-)
VERDICT=$(grep -m1 '^VERDICT: ' plan.md | cut -d' ' -f2-)
SUMMARY=$(grep -m1 '^SUMMARY: ' plan.md | cut -d' ' -f2-)

# plan body: between the PLAN: and REVIEW: markers
awk '/^PLAN:$/{p=1;next} /^REVIEW:$/{p=0} p' plan.md > plan-body.md
awk '/^REVIEW:$/{r=1;next} r' plan.md > review.md

CONFIDENCE=$(grep -o 'Plan confidence: [0-9]\+%' review.md | grep -o '[0-9]\+')

echo "$TITLE [$VERDICT] - confidence ${CONFIDENCE}%"
echo "$SUMMARY"
grep -c '^### Step ' plan-body.md | xargs echo "steps:"
grep '^### Step ' plan-body.md
grep '^- Verify: ' plan-body.md    # every step must have one

[ "$VERDICT" = "Ready to execute" ] || { echo "not ready - a human decides first"; exit 1; }
import re

text = open("plan.md", encoding="utf-8").read()

def header(name):
    m = re.search(rf"^{name}: (.+)$", text, re.M)
    return m.group(1).strip() if m else ""

title, verdict, summary = header("TITLE"), header("VERDICT"), header("SUMMARY")

body   = text.split("\nPLAN:\n", 1)[-1].split("\nREVIEW:\n", 1)[0]
review = text.split("\nREVIEW:\n", 1)[-1] if "\nREVIEW:\n" in text else ""

FIELDS = ["Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when"]
steps = []
for chunk in re.split(r"^### Step ", body, flags=re.M)[1:]:
    num, _, rest = chunk.partition(":")
    step = {"n": int(num.strip()), "title": rest.splitlines()[0].strip()}
    for i, f in enumerate(FIELDS):
        stop = "|".join(re.escape(x) for x in FIELDS[i + 1:]) or "$"
        m = re.search(rf"^- {re.escape(f)}:\s*(.*?)(?=^- (?:{stop}):|\Z)",
                      rest, re.M | re.S)
        step[f] = " ".join(m.group(1).split()) if m else ""
    steps.append(step)

conf = re.search(r"^Plan confidence: (\d+)%,\s*(.+)$", review, re.M)

print(f"{title} [{verdict}] - {len(steps)} steps")
print(summary)
for s in steps:
    print(f'  Step {s["n"]}: {s["title"]}  (after: {s["Depends on"]}, tier: {s["Model tier"]})')
    print(f'      verify: {s["Verify"]}')
    print(f'      done:   {s["Done when"]}')
if conf:
    print(f"confidence: {conf.group(1)}% - {conf.group(2)}")

missing = [s["n"] for s in steps if not s["Verify"] or not s["Done when"]]
if missing:
    raise SystemExit(f"steps missing verify/exit criteria: {missing}")
if verdict != "Ready to execute":
    raise SystemExit(f"verdict is {verdict!r} - a human decides before executing")
import { readFileSync } from "node:fs";

const text = readFileSync("plan.md", "utf8");
const header = (n) => new RegExp(`^${n}: (.+)$`, "m").exec(text)?.[1].trim() ?? "";

const title = header("TITLE");
const verdict = header("VERDICT");
const summary = header("SUMMARY");

const body = text.split("\nPLAN:\n")[1]?.split("\nREVIEW:\n")[0] ?? "";
const review = text.split("\nREVIEW:\n")[1] ?? "";

const FIELDS = ["Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when"];
const steps = body.split(/^### Step /m).slice(1).map((chunk) => {
  const n = Number(chunk.slice(0, chunk.indexOf(":")).trim());
  const stepTitle = chunk.slice(chunk.indexOf(":") + 1).split("\n")[0].trim();
  const step = { n, title: stepTitle };
  FIELDS.forEach((f, i) => {
    const stop = FIELDS.slice(i + 1).join("|") || "$";
    const m = new RegExp(`^- ${f}:\\s*([\\s\\S]*?)(?=^- (?:${stop}):|$(?![\\s\\S]))`, "m").exec(chunk);
    step[f] = m ? m[1].trim().replace(/\s+/g, " ") : "";
  });
  return step;
});

const conf = /^Plan confidence: (\d+)%,\s*(.+)$/m.exec(review);

console.log(`${title} [${verdict}] - ${steps.length} steps`);
console.log(summary);
for (const s of steps) {
  console.log(`  Step ${s.n}: ${s.title} (after: ${s["Depends on"]}, tier: ${s["Model tier"]})`);
  console.log(`      verify: ${s.Verify}`);
  console.log(`      done:   ${s["Done when"]}`);
}
if (conf) console.log(`confidence: ${conf[1]}% - ${conf[2]}`);

if (verdict !== "Ready to execute") {
  throw new Error(`verdict is "${verdict}" - a human decides before executing`);
}
data, _ := os.ReadFile("plan.md")
text := string(data)

header := func(name string) string {
	m := regexp.MustCompile(`(?m)^` + name + `: (.+)$`).FindStringSubmatch(text)
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m[1])
}
title, verdict, summary := header("TITLE"), header("VERDICT"), header("SUMMARY")

body := ""
if _, after, ok := strings.Cut(text, "\nPLAN:\n"); ok {
	body, _, _ = strings.Cut(after, "\nREVIEW:\n")
}
_, review, _ := strings.Cut(text, "\nREVIEW:\n")

type Step struct {
	N                                                  int
	Title                                              string
	DependsOn, ModelTier, Brief, Tasks, Verify, DoneWhen string
}
fields := []string{"Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when"}
chunks := regexp.MustCompile(`(?m)^### Step `).Split(body, -1)

var steps []Step
for _, chunk := range chunks[1:] {
	numStr, rest, _ := strings.Cut(chunk, ":")
	n, _ := strconv.Atoi(strings.TrimSpace(numStr))
	s := Step{N: n, Title: strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0])}
	vals := make([]string, len(fields))
	for i, f := range fields {
		stop := `\z`
		if i+1 < len(fields) {
			stop = `^- (?:` + strings.Join(fields[i+1:], "|") + `):`
		}
		re := regexp.MustCompile(`(?ms)^- ` + f + `:\s*(.*?)(?:` + stop + `)`)
		if m := re.FindStringSubmatch(chunk); m != nil {
			vals[i] = strings.Join(strings.Fields(m[1]), " ")
		}
	}
	s.DependsOn, s.ModelTier, s.Brief = vals[0], vals[1], vals[2]
	s.Tasks, s.Verify, s.DoneWhen = vals[3], vals[4], vals[5]
	steps = append(steps, s)
}

conf := regexp.MustCompile(`(?m)^Plan confidence: (\d+)%,\s*(.+)$`).FindStringSubmatch(review)

fmt.Printf("%s [%s] - %d steps\n%s\n", title, verdict, len(steps), summary)
for _, s := range steps {
	fmt.Printf("  Step %d: %s (after: %s, tier: %s)\n", s.N, s.Title, s.DependsOn, s.ModelTier)
	fmt.Printf("      verify: %s\n      done:   %s\n", s.Verify, s.DoneWhen)
}
if conf != nil {
	fmt.Printf("confidence: %s%% - %s\n", conf[1], conf[2])
}
if verdict != "Ready to execute" {
	log.Fatalf("verdict is %q - a human decides before executing", verdict)
}
import java.nio.file.*;
import java.util.*;
import java.util.regex.*;

String text = Files.readString(Path.of("plan.md"));

java.util.function.Function<String, String> header = name -> {
    Matcher m = Pattern.compile("^" + name + ": (.+)$", Pattern.MULTILINE).matcher(text);
    return m.find() ? m.group(1).trim() : "";
};
String title = header.apply("TITLE");
String verdict = header.apply("VERDICT");
String summary = header.apply("SUMMARY");

String body = text.contains("\nPLAN:\n")
    ? text.split("\nPLAN:\n", 2)[1].split("\nREVIEW:\n", 2)[0] : "";
String review = text.contains("\nREVIEW:\n") ? text.split("\nREVIEW:\n", 2)[1] : "";

List<String> FIELDS = List.of("Depends on", "Model tier", "Context brief",
                              "Tasks", "Verify", "Done when");

for (String chunk : body.split("(?m)^### Step ")) {
    if (chunk.isBlank()) continue;
    int n = Integer.parseInt(chunk.substring(0, chunk.indexOf(':')).trim());
    String stepTitle = chunk.substring(chunk.indexOf(':') + 1).split("\n", 2)[0].trim();
    Map<String, String> f = new LinkedHashMap<>();
    for (int i = 0; i < FIELDS.size(); i++) {
        String stop = i + 1 < FIELDS.size()
            ? "^- (?:" + String.join("|", FIELDS.subList(i + 1, FIELDS.size())) + "):"
            : "\\z";
        Matcher m = Pattern.compile("^- " + FIELDS.get(i) + ":\\s*(.*?)(?:" + stop + ")",
            Pattern.MULTILINE | Pattern.DOTALL).matcher(chunk);
        f.put(FIELDS.get(i), m.find() ? m.group(1).trim().replaceAll("\\s+", " ") : "");
    }
    System.out.printf("  Step %d: %s (after: %s, tier: %s)%n",
        n, stepTitle, f.get("Depends on"), f.get("Model tier"));
    System.out.printf("      verify: %s%n      done:   %s%n",
        f.get("Verify"), f.get("Done when"));
}

Matcher conf = Pattern.compile("^Plan confidence: (\\d+)%,\\s*(.+)$", Pattern.MULTILINE)
                      .matcher(review);
System.out.printf("%s [%s]%n%s%n", title, verdict, summary);
if (conf.find()) System.out.printf("confidence: %s%% - %s%n", conf.group(1), conf.group(2));
if (!"Ready to execute".equals(verdict)) {
    throw new IllegalStateException("verdict is " + verdict + " - a human decides first");
}
text = File.read("plan.md")

header = ->(name) { text[/^#{name}: (.+)$/, 1].to_s.strip }
title, verdict, summary = header["TITLE"], header["VERDICT"], header["SUMMARY"]

body   = text.split("\nPLAN:\n", 2)[1].to_s.split("\nREVIEW:\n", 2)[0].to_s
review = text.split("\nREVIEW:\n", 2)[1].to_s

FIELDS = ["Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when"]

steps = body.split(/^### Step /)[1..].to_a.map do |chunk|
  num, rest = chunk.split(":", 2)
  step = { n: num.strip.to_i, title: rest.lines.first.to_s.strip }
  FIELDS.each_with_index do |f, i|
    stop = FIELDS[(i + 1)..]
    stop = stop.empty? ? '\z' : "^- (?:#{stop.join("|")}):"
    m = chunk.match(/^- #{Regexp.escape(f)}:\s*(.*?)(?:#{stop})/m)
    step[f] = m ? m[1].split.join(" ") : ""
  end
  step
end

conf = review.match(/^Plan confidence: (\d+)%,\s*(.+)$/)

puts "#{title} [#{verdict}] - #{steps.size} steps"
puts summary
steps.each do |s|
  puts "  Step #{s[:n]}: #{s[:title]} (after: #{s["Depends on"]}, tier: #{s["Model tier"]})"
  puts "      verify: #{s["Verify"]}"
  puts "      done:   #{s["Done when"]}"
end
puts "confidence: #{conf[1]}% - #{conf[2]}" if conf

abort("verdict is #{verdict.inspect} - a human decides first") unless verdict == "Ready to execute"
$text = file_get_contents("plan.md");

$header = function (string $name) use ($text): string {
    return preg_match("/^$name: (.+)$/m", $text, $m) ? trim($m[1]) : "";
};
$title   = $header("TITLE");
$verdict = $header("VERDICT");
$summary = $header("SUMMARY");

$body   = explode("\nREVIEW:\n", explode("\nPLAN:\n", $text, 2)[1] ?? "", 2)[0];
$review = explode("\nREVIEW:\n", $text, 2)[1] ?? "";

$FIELDS = ["Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when"];
$chunks = preg_split('/^### Step /m', $body);
array_shift($chunks);

$steps = [];
foreach ($chunks as $chunk) {
    [$num, $rest] = explode(":", $chunk, 2);
    $step = ["n" => (int) trim($num), "title" => trim(strtok($rest, "\n"))];
    foreach ($FIELDS as $i => $f) {
        $tail = array_slice($FIELDS, $i + 1);
        $stop = $tail ? "^- (?:" . implode("|", $tail) . "):" : '\z';
        $step[$f] = preg_match("/^- " . preg_quote($f, "/") . ":\s*(.*?)(?:$stop)/ms", $chunk, $m)
            ? preg_replace('/\s+/', " ", trim($m[1])) : "";
    }
    $steps[] = $step;
}

preg_match('/^Plan confidence: (\d+)%,\s*(.+)$/m', $review, $conf);

echo "$title [$verdict] - " . count($steps) . " steps\n$summary\n";
foreach ($steps as $s) {
    echo "  Step {$s['n']}: {$s['title']} (after: {$s['Depends on']}, tier: {$s['Model tier']})\n";
    echo "      verify: {$s['Verify']}\n      done:   {$s['Done when']}\n";
}
if ($conf) { echo "confidence: {$conf[1]}% - {$conf[2]}\n"; }

if ($verdict !== "Ready to execute") {
    fwrite(STDERR, "verdict is \"$verdict\" - a human decides first\n");
    exit(1);
}
using System.Text.RegularExpressions;

var text = await File.ReadAllTextAsync("plan.md");

string Header(string name)
{
    var m = Regex.Match(text, $"^{name}: (.+)$", RegexOptions.Multiline);
    return m.Success ? m.Groups[1].Value.Trim() : "";
}
var title = Header("TITLE");
var verdict = Header("VERDICT");
var summary = Header("SUMMARY");

var afterPlan = text.Split("\nPLAN:\n", 2);
var body = afterPlan.Length > 1 ? afterPlan[1].Split("\nREVIEW:\n", 2)[0] : "";
var parts = text.Split("\nREVIEW:\n", 2);
var review = parts.Length > 1 ? parts[1] : "";

string[] fields = { "Depends on", "Model tier", "Context brief", "Tasks", "Verify", "Done when" };
var chunks = Regex.Split(body, @"(?m)^### Step ").Skip(1).ToList();

Console.WriteLine($"{title} [{verdict}] - {chunks.Count} steps");
Console.WriteLine(summary);
foreach (var chunk in chunks)
{
    var colon = chunk.IndexOf(':');
    var n = int.Parse(chunk[..colon].Trim());
    var stepTitle = chunk[(colon + 1)..].Split('\n')[0].Trim();
    var vals = new Dictionary<string, string>();
    for (var i = 0; i < fields.Length; i++)
    {
        var stop = i + 1 < fields.Length
            ? "^- (?:" + string.Join("|", fields[(i + 1)..]) + "):"
            : @"\z";
        var m = Regex.Match(chunk, $@"^- {fields[i]}:\s*(.*?)(?:{stop})",
            RegexOptions.Multiline | RegexOptions.Singleline);
        vals[fields[i]] = m.Success ? Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ") : "";
    }
    Console.WriteLine($"  Step {n}: {stepTitle} (after: {vals["Depends on"]}, tier: {vals["Model tier"]})");
    Console.WriteLine($"      verify: {vals["Verify"]}");
    Console.WriteLine($"      done:   {vals["Done when"]}");
}

var conf = Regex.Match(review, @"^Plan confidence: (\d+)%,\s*(.+)$", RegexOptions.Multiline);
if (conf.Success) Console.WriteLine($"confidence: {conf.Groups[1].Value}% - {conf.Groups[2].Value}");

if (verdict != "Ready to execute")
    throw new InvalidOperationException($"verdict is \"{verdict}\" - a human decides first");

This is an AI-generated plan, not a guarantee. It knows only what you put in objective and context - it has not read your repository. Treat VERDICT and the open decisions in the review as the gate: a Needs decisions plan has a fork in it that only you can settle, and a Not plan-shaped reply means the objective was too vague to decompose. Check every - Verify: command is one you can actually run before handing a step to an agent.