Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 49 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
built for agents that author and review Foundation Plans with their users.

The package is not released yet. This repository contains the auditable command shell, local Foundation Plan
initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in
reviewed increments.
initialization, subject identity generation, conditional whole-document push, and whole-graph analysis status
polling; release behavior will arrive in reviewed increments.

## Requirements

Expand Down Expand Up @@ -63,27 +63,55 @@ later override must match it.

If a failure happens after sending the request, the CLI reports that the outcome may be ambiguous and leaves local
state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not
fully verify. Until First Draft has a read or reconciliation endpoint, an accepted request whose response cannot be
verified may require manual recovery. If a verified response cannot replace local state, preserve the printed
recovery state; an adjacent `.tmp` file may contain the same private recovery copy.

Every handled failure from `plan init`, `plan subject-id`, or `plan push` writes exactly one JSON object to standard
error. Agents should branch on its stable `error` value, not on the human-readable `detail`:

| Commands | `error` | Exit | Meaning |
| ------------------------------------------- | ----------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plan init`, `plan subject-id`, `plan push` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. |
| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. |
| `plan push` | `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. |
| `plan push` | `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. |
| `plan push` | `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. |
| `plan push` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. |
| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. |
fully verify. Until First Draft has a Foundation Plan head reconciliation endpoint, an accepted request whose
response cannot be verified may require manual recovery. If a verified response cannot replace local state,
preserve the printed recovery state; an adjacent `.tmp` file may contain the same private recovery copy.

## Read analysis status

After a successful push:

```sh
firstdraft plan status
firstdraft plan status --wait
```

Without `--wait`, the command makes one `GET` and prints the current analysis as one JSON object. With `--wait`, it
polls sequentially once per second for at most two minutes and stops at `valid`, `issues_found`, `analysis_failed`,
or `superseded`. Every validated analysis status is a successful read with exit 0; agents should branch on the
`analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as
a transport failure.

Status reads require the API origin pinned by a successful push. They never select an origin from the current
environment, expose the private ETag, follow redirects, or modify local state. Each request has a bounded timeout,
every response is byte-bounded and fully validated, and polling will not silently switch to a replacement analysis.
The wait repeats only validated `processing` responses and stops on its first failed read. A network failure is safe
to retry a bounded number of times because the command sends only `GET` requests. If `status_unavailable` persists,
inspect the API origin pinned in `.firstdraft/state.json`; an invalid server response instead requires reconciling the
CLI and server contract.

Every handled failure from `plan init`, `plan subject-id`, `plan push`, or `plan status` writes exactly one JSON
object to standard error. Agents should branch on its stable `error` value, not on the human-readable `detail`:

| Commands | `error` | Exit | Meaning |
| ---------------------------------------------------------- | ----------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plan init`, `plan subject-id`, `plan push`, `plan status` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. |
| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. |
| `plan push` | `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. |
| `plan push`, `plan status` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. |
| `plan status` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. |
| `plan push` | `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. |
| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. |
| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. |
| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. |
| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. |
| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. |
| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. |

Handled failure output never includes command arguments, local Plan bytes, runtime paths, raw filesystem or network
errors, or unvalidated response bodies. Optional fields inside a validated diagnostic are omitted when absent or
when the CLI cannot validate their complete shape. Exit status remains a broad shell-level class; the `error` value
is the machine-readable recovery contract.
errors, or unvalidated response bodies. Optional fields inside a validated rejection diagnostic are omitted when
absent or when the CLI cannot validate their complete shape. Exit status remains a broad shell-level class; the
`error` value is the machine-readable recovery contract.

Root-level and `plan` command-group usage failures remain human-readable text on standard error with exit 2. A
failure before a subcommand can begin, such as an unavailable working directory, also remains uncaught, as do
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ if (result.status !== 0) {
"README.md",
"bin/firstdraft.js",
"package.json",
"src/api-response.js",
"src/cli.js",
"src/commands/plan-init.js",
"src/commands/plan-push.js",
"src/commands/plan-status.js",
"src/file-system.js",
"src/plan-state.js",
"src/uuid-v7.js",
"src/version.js",
]);
Expand Down
16 changes: 16 additions & 0 deletions scripts/smoke-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,22 @@ try {
detail:
"Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.",
});

const invalidStatus = spawnPackedCli(
["plan", "status", "--canary-secret-option"],
installationDirectory,
);
assertHandledFailure(invalidStatus, 2, {
error: "invalid_arguments",
detail: "Invalid arguments. Run 'firstdraft plan status --help' for usage.",
});

const localStatus = spawnPackedCli(["plan", "status"], installationDirectory);
assertHandledFailure(localStatus, 1, {
error: "local_input_unreadable",
detail:
"Could not read valid local First Draft state. No network request was made. Run 'firstdraft plan init' if this directory is not initialized; otherwise repair the private state before retrying.",
});
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
Expand Down
152 changes: 152 additions & 0 deletions src/api-response.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;

export class FirstDraftNetworkError extends Error {
/**
* @param {string} message
* @param {{cause?: Error, status?: number}} [options]
*/
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.status = options.status;
}
}

export class FirstDraftProtocolError extends Error {
/** @param {string} message @param {{status: number}} options */
constructor(message, options) {
super(message);
this.status = options.status;
}
}

/**
* @param {typeof globalThis.fetch} fetchFunction
* @param {URL} endpoint
* @param {RequestInit} request
*/
export async function sendRequest(fetchFunction, endpoint, request) {
try {
return await fetchFunction(endpoint, request);
} catch (error) {
if (!(error instanceof Error)) throw error;

throw new FirstDraftNetworkError("The First Draft request failed.", {
cause: error,
});
}
}

/** @param {Response} response */
export async function readResponseBody(response) {
const bytes = await readResponseBytes(response);

let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
if (!(error instanceof TypeError)) throw error;

return null;
}

try {
return JSON.parse(text);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;

return null;
}
}

/** @param {Response} response */
export function responseMediaType(response) {
return (
response.headers
.get("content-type")
?.split(";", 1)[0]
?.trim()
.toLowerCase() ?? ""
);
}

/** @param {Response} response @param {unknown} body */
export function isProblemBody(response, body) {
return (
responseMediaType(response) === "application/problem+json" &&
isRecord(body) &&
(body.type === undefined || body.type === "about:blank") &&
typeof body.title === "string" &&
body.status === response.status &&
typeof body.code === "string" &&
typeof body.detail === "string"
);
}

/**
* @param {unknown} value
* @returns {value is Record<string, unknown> & {severity: "error" | "warning"}}
*/
export function isDiagnostic(value) {
return (
isRecord(value) &&
typeof value.code === "string" &&
(value.severity === "error" || value.severity === "warning") &&
typeof value.message === "string"
);
}

/** @param {Response} response */
async function readResponseBytes(response) {
const declaredLength = response.headers.get("content-length");
if (
declaredLength !== null &&
/^\d+$/.test(declaredLength) &&
Number(declaredLength) > MAX_RESPONSE_BYTES
) {
if (response.body !== null) {
await response.body.cancel().catch(() => undefined);
}
throw new FirstDraftProtocolError(
"The First Draft response is too large.",
{ status: response.status },
);
}

if (response.body === null) return Buffer.alloc(0);

const reader = response.body.getReader();
const chunks = [];
let byteLength = 0;

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

byteLength += value.byteLength;
if (byteLength > MAX_RESPONSE_BYTES) {
await reader.cancel().catch(() => undefined);
throw new FirstDraftProtocolError(
"The First Draft response is too large.",
{ status: response.status },
);
}
chunks.push(Buffer.from(value));
}
} catch (error) {
if (error instanceof FirstDraftProtocolError) throw error;
if (!(error instanceof Error)) throw error;

throw new FirstDraftNetworkError("The First Draft response failed.", {
cause: error,
status: response.status,
});
}

return Buffer.concat(chunks, byteLength);
}

/** @param {unknown} value @returns {value is Record<string, unknown>} */
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading