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.
| Method | Path | What it does |
|---|---|---|
POST | /guest | Mint an anonymous token bound to this app. Body: {"slug":"plan-forge"}. |
GET | /me | Who the token belongs to, and the credit balance. |
POST | /estimate | Worst-case price of a run with this exact input. Free, creates no job. |
POST | /run | Start a metered run, returns a job_id. Poll GET /jobs/{job_id}. |
POST | /run-stream | Same 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.
| Status | error.code | Meaning |
|---|---|---|
401 | unauthorized | Missing, expired or revoked token. Mint a new one - see step 1. |
402 | payment_required | Not enough credits to place the hold. Top up, or check /estimate first. |
404 | not_found | Unknown job id (or an unknown app slug at /guest). |
422 / 400 | validation_error | The input object failed validation - usually neither objective nor existing was supplied, or a field was the wrong type. |
429 | rate_limited | Too many requests. Back off and retry; honour Retry-After when present. |
5xx | internal_error | Transient 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
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
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
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
| Field | Type | Notes |
|---|---|---|
objective | string, required unless existing is given | One 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. |
context | string, optional | Your 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. |
existing | string, optional | An 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. |
mode | string | "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. |
lint | array of strings, optional | Findings 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_note | string, optional | Only 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
| Field | Meaning |
|---|---|
hold_credits | Worst-case cost. This is the amount held when you call /run; the unused part is released when the run settles. |
min_credits | Floor price for a run with this input. |
model | The model that will do the work - "gpt-5.6-terra". |
model_alias | Its stable public alias - "gpt-terra". Prefer this in anything you log or display. |
markup_bps | Platform markup in basis points; 1000 is 10 percent. |
sponsor_enabled | True when someone else is sponsoring runs of this app, so your balance is not what pays. |
byok | True 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
/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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.
| Line | Rule |
|---|---|
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.