feat(skills): add develop workflow

This commit is contained in:
jb
2026-09-04 10:47:04 -04:00
parent a794a1e10a
commit 8c44c07c6a
9 changed files with 1074 additions and 1 deletions
+95
View File
@@ -0,0 +1,95 @@
---
name: develop-workflow
description: Runs feature work and other multi-step code changes through repository research, an approved implementation plan, isolated Git worktrees, explicit UI and risk gates, verification, independent review, and local integration. Use by default for features, non-trivial bug fixes, refactors, migrations, dependency changes, security-sensitive work, and UI changes. Excludes questions, research-only requests, and obvious low-risk edits requiring at most two steps.
---
# Develop workflow
Own orchestration in the parent agent. Treat Git and the retained workflow record as recovery evidence; use task tools only as the live progress view. Give subagents bounded assignments, then verify their claims against repository state.
## 0. Classify the request
Choose the smallest sufficient path before mutation:
- **Answer:** explain or research read-only. Make no changes.
- **Light:** make one obvious low-risk edit requiring at most two steps. Inspect, edit, run focused checks, and summarize. Record why this is safe when near the boundary.
- **Full:** use the phases below for three or more steps, multiple files or modules, ambiguity, architecture, dependencies, migrations, security implications, non-trivial UI, or meaningful regression risk.
Promote light work to full before editing when discovery reveals meaningful scope or risk. Apply risk gates to every path.
**Complete when:** observed scope and risk justify the selected path.
## 1. Preflight and isolate
For the full path, require Git worktrees, a writable repository record, `ask_user`, Git-capable independent reviewers, and target-interface validation when UI applies. Stop before product mutation when a mandatory capability is unavailable. In print or JSON mode, emit the pending gate or validation instructions and stop.
Read repository instructions, relevant docs/ADRs/tests, and Git state. Capture the source branch and exact base commit. Create `./.worktrees/<feature>` on a descriptive feature branch. Treat collisions, unmerged paths, unexpected ownership, and an unignored worktree directory as blockers. For a dirty source checkout, list omitted changes and obtain explicit approval before proceeding.
Use explicit `cd <worktree> && ...` or `git -C <worktree> ...`; prove the top-level path before every shell mutation. Prepare a unique record from [`assets/workflow-record.md`](assets/workflow-record.md) and publish it atomically with the checkpoint helper in [evidence and recovery](references/evidence-and-recovery.md). Create the task tree, run applicable documentation checks, and commit the initial record before product mutation. Use the helper for every later meaningful update.
**Complete when:** worktree identity is proven and the initial record commit names the next safe action.
## 2. Discover and decide requirements
Launch independent read-only research lanes directly when concerns can be investigated separately; state that no files are owned. Give each child a self-contained prompt. Keep the parent active on other work and verify that every required result was delivered. Treat missing delivery as missing evidence.
Reconcile repository evidence into scope, out-of-scope items, acceptance criteria, affected surfaces, constraints, and risks. Use `ask_user` only for decisions repository evidence cannot answer. Checkpoint decisions.
**Complete when:** requirements and unresolved questions are explicit.
## 3. Design and obtain plan approval
Record an exact implementation plan containing:
- acceptance criteria mapped to slices;
- one named owner and concrete path ownership per mutating assignment;
- existing seams and patterns;
- focused and full verification commands with realistic data needs;
- UI classification, dependencies, migrations, production effects, and risk gates;
- review fixed point and specification source;
- integration and rollback strategy;
- a 15-minute default deadline per mutating assignment, or an explicitly approved alternative.
Present the complete record through `artifact-presenter`, then obtain explicit approval with `ask_user`. Checkpoint and commit the decision before production edits. Return to this gate after any material scope, acceptance, ownership, verification, or architecture change.
**Complete when:** the user explicitly approved the exact committed plan.
## 4. Resolve non-trivial UI direction
For non-trivial UI, invoke `prototype` and create three structurally different throwaway candidates before production-component edits. Ask approval for a smaller explicit count when three meaningful alternatives are impossible. Validate candidates through `browser-harness` for web UI or the real PTY/application for terminal/native UI, including representative responsive sizes.
Retain candidates on a dedicated throwaway branch and record its commit. Present them and obtain direction approval through `ask_user`. Checkpoint the selected direction before production mutation. Rewrite the chosen direction as production code rather than promoting prototype code. Record why minor or non-visual UI skips this phase.
**Complete when:** target-interface evidence, retained prototype commit, selected direction, and reflected plan are recorded.
## 5. Implement owned slices
State exclusive file ownership before parallel mutation. Serialize by default; parallelize only disjoint low-integration paths. Give each implementer the approved plan, decisions, owned paths, acceptance criteria, test seam, allowed commands, and deadline. Tell workers to stop at the deadline and leave commits, integration, user interaction, and mutation delegation to the parent.
Capture branch, HEAD, status, changed paths, and a patch artifact before and after every mutating assignment. Freeze all mutation on unexpected commits, out-of-ownership paths, deadline expiry, missing delivery, or unknown child liveness. Follow the recovery choices in [evidence and recovery](references/evidence-and-recovery.md); never retry mutating work until the previous child is proven terminal.
Use TDD at agreed seams where practical. Run focused tests after behavioral slices. Inspect each returned diff and repository state before marking it complete.
**Complete when:** every acceptance criterion has intentional owned code and test evidence.
## 6. Verify the integrated worktree
Inspect the complete diff and status. Run focused behavioral tests, then repository-required typecheck, lint, build, and full tests. Exercise realistic E2E data across process/UI boundaries. Validate changed UI in its target interface and representative sizes. Synchronize affected documentation.
Record exact commands, exit codes, and concise outcomes. Keep flaky or environmental failures visible until resolved or escalated. Never infer success from a child transcript.
**Complete when:** the approved verification matrix passes against the integrated worktree state.
## 7. Commit, synchronize, and review
Create focused commits after their checks pass. Follow [Git and integration](references/git-and-integration.md) to fetch/rebase, reverify changed trees, and produce the pinned review brief and exact diff fingerprint. Follow [review and remediation](references/review-and-remediation.md) for two independent review axes, mutator exclusion, findings, and fresh-review rules.
**Complete when:** standards and specification reviewers approve the exact verified product diff.
## 8. Integrate and close
Follow [Git and integration](references/git-and-integration.md). Recheck the source branch and checkout, fast-forward only when evidence remains valid, then create one metadata-only closure commit touching only the record. Run a cheap final sanity check. Remove the workflow-owned worktree and branch only after reachability and clean-state proofs.
Mark tasks complete and report product/closure commits, review command and digest, reviewer identities, record path, and verification evidence. Keep push, publish, deployment, production access, unsafe cleanup, conflict-driven behavior changes, force operations, and non-fast-forward integration behind explicit just-in-time approval.
**Complete when:** reviewed work and closure are reachable from the intended local source branch and temporary state is safely removed.
@@ -0,0 +1,70 @@
# <Feature> workflow record
## Status
- Status: active | waiting | blocked | complete
- Current phase:
- Next safe action:
## Request and scope
- Request:
- In scope:
- Out of scope:
## Git identity
- Source branch:
- Base commit:
- Feature branch:
- Worktree:
- Omitted source changes:
## Acceptance criteria
- [ ]
## Approved implementation plan and gates
- Plan/specification source:
- Exact plan:
- Plan approval:
- UI classification and direction approval:
- Risk decisions and scope:
## Ownership
| Assignment | Owner identity | Deadline | Exclusive paths | Before/after Git evidence | Outcome |
| --- | --- | --- | --- | --- | --- |
## Verification matrix
| Surface | Command | Exit code | Outcome |
| --- | --- | --- | --- |
## Review
- Base and product HEAD:
- Clean status and tree hash:
- Commit list:
- Exact diff command:
- SHA-256:
- Mutator identities:
- Standards reviewer identity, digest confirmation, and verdict:
- Specification reviewer identity, digest confirmation, and verdict:
- Findings and remediation cycles:
- Before/after reviewer Git evidence:
## Integration and rollback
- Synchronization and re-verification:
- Fast-forward result:
- Product and closure commits:
- Reachability and cleanup:
- Rollback strategy:
## Checkpoints and blockers
- Last committed valid checkpoint:
- Blockers:
- Exact next safe action:
@@ -0,0 +1,45 @@
# Evidence and recovery
## Checkpoint records
Keep one concise Markdown record in the repository's established plan location; otherwise use `docs/plans/<feature-branch-slug>.md`. Require a unique path before creation. Store status/current phase, request/scope, Git identity, acceptance criteria, exact approved plan and gate decisions, assignment ownership, verification outcomes, review identities/findings/fingerprint, integration, blockers, and the next safe action.
Keep transcripts, leases, heartbeats, notification receipts, full command output, and model-authored execution graphs out of the record. Link durable artifacts or commits instead. Update only at meaningful boundaries; task tools hold high-frequency progress.
Prepare the complete candidate outside the record's same-directory temporary namespace and run:
```bash
node skills/develop/develop-workflow/scripts/checkpoint-record.mjs \
<record.md> --input <candidate.md> --expected-sha256 <current-digest>
```
Omit `--expected-sha256` only for first creation; require an absent target. Commit the initial record and every approved gate before dependent product mutation. A stale digest, lock, temporary artifact with no valid record, validation failure, or filesystem error blocks progress. Preserve artifacts for inspection; never hand-delete recovery evidence to make a command pass.
## Assignment evidence
Before every mutating assignment, record:
- display name, profile, stable child/session IDs when exposed;
- deadline and exclusive owned paths;
- branch, HEAD, clean/dirty status, changed paths, and patch artifact.
After delivery, capture the same Git evidence and inspect the patch. Treat the entire ambiguous profile/run as a mutator when stable identity is unavailable. Reject unexpected commits and paths outside ownership.
On an ownership or Git invariant failure, freeze mutation and present evidence-backed choices: inspect, accept and replan, restore with a non-destructive patch, or abort. Obtain a user decision before proceeding.
On deadline expiry or missing delivery, request stop through the available control surface and prove the process/session terminal before reassignment. A completed UI indicator is not output or liveness proof. Keep the workflow blocked for human termination/takeover when liveness remains unknown.
## Fresh-session recovery
Reconstruct observed reality before trusting prose:
1. Read repository instructions; inspect worktrees, branches, refs, status, commits, and diff.
2. Discover candidate records from observed branches/worktrees and repository conventions.
3. Treat a workflow branch/worktree without a record as an orphan. Preserve it; inspect branch, base, reflog, status, commits, and diff; ask whether to adopt it with a record, quarantine it, or remove it after separate cleanup approval.
4. Compare the record's base, branch, and checkpoint with Git evidence.
5. Preserve atomic-update temporary files. Use the last committed valid record until recovery is approved.
6. Rebuild task state from completed evidence and the recorded next action.
7. Freeze interrupted mutation until its diff is inspected and prior child liveness is terminal.
8. Resume from the last proven checkpoint.
Ask when evidence conflicts, scope may have changed, child liveness is unknown, or recovery needs risk. Treat the record as a recovery map, not a transaction log.
@@ -0,0 +1,40 @@
# Git and local integration
## Isolation
Record the source branch and exact base before creating `./.worktrees/<feature>` on `feature/<feature>`. Prove every shell location with explicit `cd` or `git -C`; verify `git rev-parse --show-toplevel` before mutation. Block on collisions, unmerged paths, unexpected ownership, or an unignored worktree root. Never rename, reset, clean, stash, copy source changes, or remove an orphan silently.
A dirty source checkout requires bounded status/path evidence and explicit approval because its changes are omitted. Preserve that evidence in the record.
## Synchronize and pin review
After focused commits and verification:
1. Fetch the latest source branch when a remote exists.
2. Rebase the feature branch onto the latest source commit.
3. Rerun affected checks and full required verification whenever rebase changes the effective tree or resolves conflicts.
4. Record the review base, product HEAD, clean status, tree hash, and commit list.
5. Generate exact review bytes:
```bash
git diff --binary --full-index --find-renames <base>...<product-head> --
```
6. Hash those bytes with SHA-256 and record the command and digest. Regenerate from the durable commits rather than committing a duplicate patch.
Ask before conflict resolution changes approved behavior, history rewrite of shared work, force operations, or non-fast-forward integration.
## Fast-forward integration
After both review axes approve the exact fingerprint:
1. Confirm the source branch still equals the final rebase base. Return to synchronize, verify, and review if it advanced.
2. Confirm the source checkout exists, is clean, and is safe to update. Stop when dirty or missing.
3. Fast-forward the source branch to the reviewed feature branch; create no merge commit.
4. In the source checkout, update only the workflow record with bound review and integration evidence.
5. Prove the closure diff touches only that record, run applicable documentation checks, and create one metadata-only closure commit.
6. Run a cheap final sanity check.
7. Prove the product and closure commits reachable from the source branch.
8. Remove the completed worktree, then delete its feature branch.
Treat cleanup in step 8 as pre-authorized only after every preceding clean-state and reachability proof. Ask before any other destructive cleanup. Pushing, publishing, deployment, and production access remain separate user actions.
@@ -0,0 +1,33 @@
# Independent review and remediation
## Identity and capability
Exclude every identity that authored or mutated the reviewed diff. Record reviewer display name, profile, child ID, and session ID when available. When stable identity is unavailable, exclude the entire ambiguous profile/run. Keep reviewers read-only and separate for:
- **Standards:** repository instructions and documented conventions.
- **Specification:** the approved workflow record and linked source plan/spec.
Use Git-capable reviewer profiles with repository read access and no declared `write` or `edit` tools. Shell access can still mutate; capture status, tree, HEAD, and changed paths before and after review. Any mutation invalidates the verdict and marks that identity as a mutator.
## Review brief
Generate the brief in the parent. Include:
- pinned base and product HEAD;
- clean status and product tree hash;
- commit list;
- exact `git diff --binary --full-index --find-renames <base>...<product-head> --` command;
- SHA-256 of those exact bytes;
- approved record/specification sources;
- repository standards sources;
- all excluded mutator identities.
Launch standards and specification reviewers through separate direct subagent calls. Avoid wrapped parallel launch until its delivery defect is proven fixed. Require each reviewer to regenerate the pinned diff, confirm its digest before review, remain read-only, and bind every finding to file/hunk evidence and severity. Treat a missing result or digest mismatch as no verdict.
Approval binds to base, product HEAD, clean status, tree hash, and diff digest. Permit a later closure commit outside that fingerprint only after proving it changes the workflow record alone.
## Findings and remediation
Validate findings against repository evidence. Send accepted fixes to an original implementer or another non-reviewer, never the reviewer. Preserve exclusive path ownership and assignment evidence. Rerun affected checks and the full required suite, create focused commits, and generate a new fingerprint.
Launch fresh independent reviewers for the changed diff. Escalate after two unsuccessful remediation cycles or whenever a finding requires material scope, acceptance, ownership, verification, or architecture changes; return to plan approval before further production mutation.
@@ -0,0 +1,460 @@
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** The headings that make up a retained develop-workflow record. */
export const REQUIRED_SECTIONS = Object.freeze([
"Status",
"Request and scope",
"Git identity",
"Acceptance criteria",
"Approved implementation plan and gates",
"Ownership",
"Verification matrix",
"Review",
"Integration and rollback",
"Checkpoints and blockers",
]);
const REQUIRED_SECTION_SET = new Set(REQUIRED_SECTIONS);
const FILE_MODE = 0o600;
const MAX_TEMP_NAME_ATTEMPTS = 16;
/**
* Error raised for a checkpoint precondition or atomic-update failure.
* `code` is stable for callers that need to distinguish a stale record or lock.
*/
export class CheckpointError extends Error {
constructor(code, message, options = {}) {
super(message, options);
this.name = "CheckpointError";
this.code = code;
}
}
const nodeIo = Object.freeze({
mkdir: (...args) => fs.mkdir(...args),
open: (...args) => fs.open(...args),
readFile: (...args) => fs.readFile(...args),
rename: (...args) => fs.rename(...args),
unlink: (...args) => fs.unlink(...args),
randomUUID: () => randomUUID(),
});
/** The default filesystem adapter, useful when composing or testing the helper. */
export const defaultIo = nodeIo;
function asBytes(input) {
if (typeof input === "string") return Buffer.from(input, "utf8");
if (input instanceof Uint8Array) return Buffer.from(input);
if (input instanceof ArrayBuffer) return Buffer.from(new Uint8Array(input));
throw new CheckpointError(
"INVALID_INPUT",
"checkpoint input must be a UTF-8 string or byte array",
);
}
function headingText(line, level) {
const prefix = "#".repeat(level);
const match = line.match(new RegExp(`^${prefix}(?!#)[ \\t]+(.+?)[ \\t]*$`));
if (!match) return null;
// Closing ATX hashes are syntax, not part of the heading title.
return match[1].replace(/[ \\t]+#+[ \\t]*$/, "").trim();
}
/**
* Validate a retained record without accessing the destination path.
* Markdown headings inside fenced code blocks are not record sections.
*/
export function validateRecord(input) {
const bytes = asBytes(input);
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new CheckpointError("INVALID_RECORD", "checkpoint input is not valid UTF-8", {
cause: error,
});
}
const h1 = [];
const h2 = [];
let fence = null;
for (const line of text.split(/\r?\n/)) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1][0];
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
continue;
}
if (fence !== null) continue;
const first = headingText(line, 1);
if (first !== null) h1.push(first);
const second = headingText(line, 2);
if (second !== null) h2.push(second);
}
if (h1.length !== 1 || h1[0].length === 0) {
throw new CheckpointError(
"INVALID_RECORD",
`checkpoint must contain exactly one non-empty H1 (found ${h1.length})`,
);
}
const counts = new Map();
for (const section of h2) counts.set(section, (counts.get(section) ?? 0) + 1);
const missing = REQUIRED_SECTIONS.filter((section) => !counts.has(section));
const duplicate = REQUIRED_SECTIONS.filter((section) => (counts.get(section) ?? 0) > 1);
const unknown = h2.filter((section) => !REQUIRED_SECTION_SET.has(section));
if (missing.length > 0 || duplicate.length > 0 || unknown.length > 0) {
const details = [];
if (missing.length > 0) details.push(`missing: ${missing.join(", ")}`);
if (duplicate.length > 0) details.push(`duplicate: ${duplicate.join(", ")}`);
if (unknown.length > 0) details.push(`unknown: ${[...new Set(unknown)].join(", ")}`);
throw new CheckpointError("INVALID_RECORD", `invalid checkpoint sections (${details.join("; ")})`);
}
return { bytes, text, h1: h1[0], sections: [...REQUIRED_SECTIONS] };
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function expectedDigest(value) {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value)) {
throw new CheckpointError("INVALID_DIGEST", "expected SHA-256 must be exactly 64 hexadecimal characters");
}
return value.toLowerCase();
}
function errorCode(error) {
return error && typeof error === "object" && "code" in error ? error.code : undefined;
}
function isMissing(error) {
return errorCode(error) === "ENOENT";
}
function isAlreadyExists(error) {
return errorCode(error) === "EEXIST";
}
function messageFor(error) {
return error instanceof Error ? error.message : String(error);
}
function withIo(io) {
return { ...nodeIo, ...io };
}
async function closeQuietly(handle) {
if (handle === null) return undefined;
try {
await handle.close();
} catch (error) {
return error;
}
return undefined;
}
async function unlinkQuietly(io, path) {
try {
await io.unlink(path);
return undefined;
} catch (error) {
return error;
}
}
async function readExisting(io, target) {
try {
return { exists: true, bytes: await io.readFile(target) };
} catch (error) {
if (isMissing(error)) return { exists: false, bytes: undefined };
throw new CheckpointError("READ_FAILED", `could not read checkpoint target: ${messageFor(error)}`, {
cause: error,
});
}
}
async function openUniqueTemp(io, target) {
const directory = dirname(target);
for (let attempt = 0; attempt < MAX_TEMP_NAME_ATTEMPTS; attempt += 1) {
const id = io.randomUUID();
const path = `${target}.tmp-${process.pid}-${id}`;
try {
const handle = await io.open(path, "wx", FILE_MODE);
return { path, handle, directory };
} catch (error) {
// A collision is not stale-file cleanup. Leave the old file alone and try a new name.
if (isAlreadyExists(error)) continue;
throw new CheckpointError("TEMP_OPEN_FAILED", `could not create checkpoint temporary file: ${messageFor(error)}`, {
cause: error,
});
}
}
throw new CheckpointError("TEMP_OPEN_FAILED", "could not create a unique checkpoint temporary file");
}
async function writeAll(handle, bytes) {
let offset = 0;
while (offset < bytes.byteLength) {
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
const written = typeof result === "number" ? result : result.bytesWritten;
if (!Number.isInteger(written) || written <= 0) {
throw new CheckpointError("WRITE_FAILED", "checkpoint temporary file write made no progress");
}
offset += written;
}
}
async function syncDirectory(io, directory) {
let handle = null;
let operationError;
try {
handle = await io.open(directory, "r");
await handle.sync();
} catch (error) {
operationError = new CheckpointError("DIRECTORY_SYNC_FAILED", `could not flush checkpoint directory: ${messageFor(error)}`, {
cause: error,
});
}
const closeError = await closeQuietly(handle);
if (operationError !== undefined) throw operationError;
if (closeError !== undefined) {
throw new CheckpointError("DIRECTORY_CLOSE_FAILED", `could not close checkpoint directory: ${messageFor(closeError)}`, {
cause: closeError,
});
}
}
function requestFromArgs(targetOrRequest, input, options) {
if (typeof targetOrRequest === "string" || targetOrRequest instanceof URL) {
return { ...(options ?? {}), target: targetOrRequest, input };
}
if (targetOrRequest === null || typeof targetOrRequest !== "object") {
throw new CheckpointError("INVALID_REQUEST", "checkpoint request must provide a target and input");
}
return targetOrRequest;
}
/**
* Atomically create or replace a validated workflow record.
*
* Object form: `checkpointRecord({ target, input, expectedSha256, io })`.
* Positional form is also supported by `recordCheckpoint(target, input, options)`.
*/
export async function checkpointRecord(targetOrRequest, input, options) {
const request = requestFromArgs(targetOrRequest, input, options);
const targetValue = request.target;
if (typeof targetValue !== "string" && !(targetValue instanceof URL)) {
throw new CheckpointError("INVALID_REQUEST", "checkpoint target is required");
}
const target = resolve(targetValue instanceof URL ? fileURLToPath(targetValue) : targetValue);
const source = request.input ?? request.content;
// This deliberately happens before opening the lock or reading the destination.
const { bytes } = validateRecord(source);
const expected = expectedDigest(request.expectedSha256 ?? request.expectedDigest);
const io = withIo(request.io);
const lock = `${target}.lock`;
const directory = dirname(target);
try {
await io.mkdir(directory, { recursive: true, mode: 0o700 });
} catch (error) {
throw new CheckpointError(
"DIRECTORY_CREATE_FAILED",
`could not create checkpoint directory: ${messageFor(error)}`,
{ cause: error },
);
}
let lockHandle = null;
let tempHandle = null;
let tempPath = null;
let primaryError;
let result;
try {
try {
lockHandle = await io.open(lock, "wx", FILE_MODE);
} catch (error) {
if (isAlreadyExists(error)) {
throw new CheckpointError("LOCK_EXISTS", "checkpoint target is locked; refusing to recover or overwrite the lock", {
cause: error,
});
}
throw new CheckpointError("LOCK_FAILED", `could not acquire checkpoint lock: ${messageFor(error)}`, {
cause: error,
});
}
const current = await readExisting(io, target);
if (current.exists) {
if (expected === undefined) {
throw new CheckpointError(
"EXPECTED_SHA256_REQUIRED",
"replacing an existing checkpoint requires --expected-sha256",
);
}
const actual = sha256(current.bytes);
if (actual !== expected) {
throw new CheckpointError(
"STALE_DIGEST",
`checkpoint target digest is stale (expected ${expected}, found ${actual})`,
);
}
} else if (expected !== undefined) {
throw new CheckpointError(
"TARGET_MISSING",
"checkpoint target is absent; do not provide an expected SHA-256 when creating it",
);
}
const temporary = await openUniqueTemp(io, target);
tempPath = temporary.path;
tempHandle = temporary.handle;
let temporaryError;
try {
await writeAll(tempHandle, bytes);
} catch (error) {
temporaryError = error instanceof CheckpointError
? error
: new CheckpointError("WRITE_FAILED", `could not write checkpoint temporary file: ${messageFor(error)}`, { cause: error });
}
if (temporaryError === undefined) {
try {
await tempHandle.sync();
} catch (error) {
temporaryError = new CheckpointError("TEMP_SYNC_FAILED", `could not flush checkpoint temporary file: ${messageFor(error)}`, {
cause: error,
});
}
}
const closeError = await closeQuietly(tempHandle);
tempHandle = null;
if (temporaryError !== undefined) throw temporaryError;
if (closeError !== undefined) {
throw new CheckpointError("TEMP_CLOSE_FAILED", `could not close checkpoint temporary file: ${messageFor(closeError)}`, {
cause: closeError,
});
}
try {
await io.rename(tempPath, target);
} catch (error) {
throw new CheckpointError("RENAME_FAILED", `could not atomically replace checkpoint target: ${messageFor(error)}`, {
cause: error,
});
}
tempPath = null;
await syncDirectory(io, directory);
result = {
target,
created: !current.exists,
sha256: sha256(bytes),
};
} catch (error) {
primaryError = error;
}
// Cleanup is best effort. In particular, never remove a pre-existing stale temp
// or lock and never replace an operation error with a cleanup error.
const tempCloseError = await closeQuietly(tempHandle);
if (tempCloseError !== undefined && primaryError === undefined) {
primaryError = new CheckpointError("TEMP_CLOSE_FAILED", `could not close checkpoint temporary file: ${messageFor(tempCloseError)}`, {
cause: tempCloseError,
});
}
if (tempPath !== null) {
const tempUnlinkError = await unlinkQuietly(io, tempPath);
if (tempUnlinkError !== undefined && !isMissing(tempUnlinkError) && primaryError === undefined) {
primaryError = new CheckpointError("TEMP_CLEANUP_FAILED", `could not remove checkpoint temporary file: ${messageFor(tempUnlinkError)}`, {
cause: tempUnlinkError,
});
}
}
const lockCloseError = await closeQuietly(lockHandle);
if (lockCloseError !== undefined && primaryError === undefined) {
primaryError = new CheckpointError("LOCK_CLOSE_FAILED", `could not close checkpoint lock: ${messageFor(lockCloseError)}`, {
cause: lockCloseError,
});
}
if (lockHandle !== null) {
const lockUnlinkError = await unlinkQuietly(io, lock);
if (lockUnlinkError !== undefined && !isMissing(lockUnlinkError) && primaryError === undefined) {
primaryError = new CheckpointError("LOCK_CLEANUP_FAILED", `could not remove checkpoint lock: ${messageFor(lockUnlinkError)}`, {
cause: lockUnlinkError,
});
}
}
if (primaryError !== undefined) throw primaryError;
return result;
}
export async function recordCheckpoint(target, input, options = {}) {
return checkpointRecord(target, input, options);
}
export const writeCheckpoint = recordCheckpoint;
function usage() {
return "Usage: node checkpoint-record.mjs <target> [--input <path>] [--expected-sha256 <digest>]";
}
function parseCli(argv) {
let target;
let inputPath;
let expectedSha256;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--input") {
if (inputPath !== undefined || index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
throw new CheckpointError("CLI_USAGE", usage());
}
inputPath = argv[++index];
} else if (argument === "--expected-sha256") {
if (expectedSha256 !== undefined || index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
throw new CheckpointError("CLI_USAGE", usage());
}
expectedSha256 = argv[++index];
} else if (argument.startsWith("--")) {
throw new CheckpointError("CLI_USAGE", usage());
} else if (target === undefined) {
target = argument;
} else {
throw new CheckpointError("CLI_USAGE", usage());
}
}
if (target === undefined) throw new CheckpointError("CLI_USAGE", usage());
return { target, inputPath, expectedSha256 };
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
export async function main(argv = process.argv.slice(2)) {
try {
const { target, inputPath, expectedSha256 } = parseCli(argv);
const input = inputPath === undefined ? await readStdin() : await fs.readFile(inputPath);
await checkpointRecord({ target, input, expectedSha256 });
return 0;
} catch (error) {
process.stderr.write(`checkpoint-record: ${messageFor(error)}\n`);
return 2;
}
}
const invokedPath = process.argv[1] === undefined ? null : resolve(process.argv[1]);
if (invokedPath !== null && invokedPath === fileURLToPath(import.meta.url)) {
process.exitCode = await main();
}
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { once } from "node:events";
import { spawn } from "node:child_process";
import test from "node:test";
import {
checkpointRecord,
defaultIo,
REQUIRED_SECTIONS,
validateRecord,
} from "../scripts/checkpoint-record.mjs";
const scriptPath = fileURLToPath(new URL("../scripts/checkpoint-record.mjs", import.meta.url));
function record(title = "Workflow checkpoint", sections = REQUIRED_SECTIONS) {
return `# ${title}\n\n${sections.map((section) => `## ${section}\n\ncheckpoint data\n`).join("\n")}`;
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
async function temporaryDirectory() {
return fs.mkdtemp(join(tmpdir(), "checkpoint-record-test-"));
}
async function exists(path) {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
function faultIo({ write, flush, rename, directoryFlush, cleanup } = {}) {
return {
...defaultIo,
open: async (path, flags, mode) => {
const handle = await defaultIo.open(path, flags, mode);
const isTemp = flags === "wx" && path.includes(".tmp-");
const isDirectory = flags === "r";
if (!(isTemp && (write || flush)) && !(isDirectory && directoryFlush)) return handle;
return {
write: async (...args) => {
if (isTemp && write) throw new Error("interrupted write");
return handle.write(...args);
},
sync: async () => {
if ((isTemp && flush) || (isDirectory && directoryFlush)) throw new Error("interrupted flush");
return handle.sync();
},
close: (...args) => handle.close(...args),
};
},
rename: async (...args) => {
if (rename) throw new Error("rename failed");
return defaultIo.rename(...args);
},
unlink: async (path) => {
if (cleanup) throw new Error("cleanup failed");
return defaultIo.unlink(path);
},
};
}
async function assertRejectsCode(promise, code) {
await assert.rejects(promise, (error) => {
assert.equal(error.code, code);
return true;
});
}
test("creates a missing record and parent directory atomically with restrictive modes", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "nested", "record.md");
const input = record();
const result = await checkpointRecord({ target, input });
assert.equal(result.created, true);
assert.equal(result.sha256, digest(input));
assert.equal(await fs.readFile(target, "utf8"), input);
assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
assert.equal((await fs.stat(join(directory, "nested"))).mode & 0o777, 0o700);
assert.equal(await exists(`${target}.lock`), false);
assert.equal(
(await fs.readdir(join(directory, "nested"))).filter((name) => name.includes(".tmp-")).length,
0,
);
});
test("replaces a record only with its current SHA-256 digest", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
const after = record("After");
await fs.writeFile(target, before, { mode: 0o600 });
const result = await checkpointRecord({ target, input: after, expectedSha256: digest(before) });
assert.equal(result.created, false);
assert.equal(await fs.readFile(target, "utf8"), after);
assert.equal(result.sha256, digest(after));
});
test("rejects malformed, missing, duplicate, and unknown sections before touching the target", async () => {
const cases = [
["missing H1", record().replace(/^# .*\n\n/, "")],
["missing section", record().replace("## Review\n\ncheckpoint data\n\n", "")],
["duplicate section", `${record()}\n## Status\n\nagain\n`],
["unknown section", `${record()}\n## Unrecognized\n\nnope\n`],
];
for (const [name, input] of cases) {
const directory = await temporaryDirectory();
const target = join(directory, `${name.replaceAll(" ", "-")}.md`);
await assertRejectsCode(checkpointRecord({ target, input }), "INVALID_RECORD");
assert.equal(await exists(target), false, `${name} created target`);
assert.equal(await exists(`${target}.lock`), false, `${name} created lock`);
}
});
test("requires a digest for replacement and rejects stale digests without changing bytes", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
const after = record("After");
await fs.writeFile(target, before, { mode: 0o600 });
await assertRejectsCode(checkpointRecord({ target, input: after }), "EXPECTED_SHA256_REQUIRED");
await assertRejectsCode(
checkpointRecord({ target, input: after, expectedSha256: digest(record("Other")) }),
"STALE_DIGEST",
);
assert.equal(await fs.readFile(target, "utf8"), before);
assert.equal(await exists(`${target}.lock`), false);
});
test("serializes concurrent updates with an exclusive per-target lock", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const lock = `${target}.lock`;
const first = record("First");
const second = record("Second");
let firstLockOpened;
const lockOpened = new Promise((resolve) => { firstLockOpened = resolve; });
let releaseFirstLock;
const firstLockHeld = new Promise((resolve) => { releaseFirstLock = resolve; });
let firstOpen = true;
const io = {
...defaultIo,
open: async (path, flags, mode) => {
const handle = await defaultIo.open(path, flags, mode);
if (path === lock && flags === "wx" && firstOpen) {
firstOpen = false;
firstLockOpened();
await firstLockHeld;
}
return handle;
},
};
const firstPromise = checkpointRecord({ target, input: first, io });
await lockOpened;
const secondPromise = checkpointRecord({ target, input: second, io });
await assertRejectsCode(secondPromise, "LOCK_EXISTS");
releaseFirstLock();
await firstPromise;
assert.equal(await fs.readFile(target, "utf8"), first);
assert.equal(await exists(`${target}.lock`), false);
});
test("fails closed on a pre-existing stale lock and leaves it for recovery", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const lock = `${target}.lock`;
const input = record();
await fs.writeFile(lock, "stale lock", { mode: 0o600 });
await assertRejectsCode(checkpointRecord({ target, input }), "LOCK_EXISTS");
assert.equal(await fs.readFile(lock, "utf8"), "stale lock");
assert.equal(await exists(target), false);
});
test("preserves pre-existing stale temporary files", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const stale = `${target}.tmp-stale-recovery`;
await fs.writeFile(stale, "recover me", { mode: 0o600 });
await checkpointRecord({ target, input: record() });
assert.equal(await fs.readFile(stale, "utf8"), "recover me");
assert.equal(await exists(`${target}.lock`), false);
});
test("cleans its own temporary file after an interrupted write and preserves the prior target", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
await fs.writeFile(target, before, { mode: 0o600 });
await assertRejectsCode(
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: faultIo({ write: true }) }),
"WRITE_FAILED",
);
assert.equal(await fs.readFile(target, "utf8"), before);
assert.equal(await exists(`${target}.lock`), false);
assert.deepEqual((await fs.readdir(directory)).filter((name) => name.includes(".tmp-")), []);
});
test("cleans its own temporary file after a flush failure and does not mask the primary error", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
await fs.writeFile(target, before, { mode: 0o600 });
const cleanupFailingIo = faultIo({ flush: true, cleanup: true });
await assertRejectsCode(
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: cleanupFailingIo }),
"TEMP_SYNC_FAILED",
);
assert.equal(await fs.readFile(target, "utf8"), before);
// Remove artifacts made intentionally unremovable by this fault adapter.
for (const name of await fs.readdir(directory)) await fs.unlink(join(directory, name));
});
test("rename failure leaves the previous target byte-for-byte intact", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
await fs.writeFile(target, before, { mode: 0o600 });
// The adapter's rename failure is wrapped as an atomic operation error.
await assert.rejects(
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: faultIo({ rename: true }) }),
(error) => {
assert.equal(error.code, "RENAME_FAILED");
assert.match(error.message, /rename failed/);
return true;
},
);
assert.equal(await fs.readFile(target, "utf8"), before);
assert.equal(await exists(`${target}.lock`), false);
});
test("directory flush failure is reported after rename and leaves no lock or owned temp", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const before = record("Before");
const after = record("After");
await fs.writeFile(target, before, { mode: 0o600 });
await assertRejectsCode(
checkpointRecord({ target, input: after, expectedSha256: digest(before), io: faultIo({ directoryFlush: true }) }),
"DIRECTORY_SYNC_FAILED",
);
assert.equal(await fs.readFile(target, "utf8"), after);
assert.equal(await exists(`${target}.lock`), false);
assert.deepEqual((await fs.readdir(directory)).filter((name) => name.includes(".tmp-")), []);
});
test("cleanup errors never mask a lock or write failure", async () => {
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const io = faultIo({ write: true, cleanup: true });
await assert.rejects(checkpointRecord({ target, input: record(), io }), /interrupted write/);
for (const name of await fs.readdir(directory)) await fs.unlink(join(directory, name));
});
test("strict CLI reports usage and operation errors with exit code 2", async () => {
const missingTarget = spawn(process.execPath, [scriptPath], { stdio: ["ignore", "pipe", "pipe"] });
const [missingResult] = await once(missingTarget, "close");
assert.equal(missingResult, 2);
const directory = await temporaryDirectory();
const target = join(directory, "record.md");
const child = spawn(process.execPath, [scriptPath, target], { stdio: ["pipe", "pipe", "pipe"] });
child.stdin.end(record());
const [result] = await once(child, "close");
assert.equal(result, 0);
assert.equal(await fs.readFile(target, "utf8"), record());
const inputPath = join(directory, "input.md");
const inputRecord = record("From input file");
await fs.writeFile(inputPath, inputRecord);
const inputTarget = join(directory, "input-target.md");
const inputChild = spawn(process.execPath, [scriptPath, inputTarget, "--input", inputPath], { stdio: ["ignore", "pipe", "pipe"] });
const [inputResult] = await once(inputChild, "close");
assert.equal(inputResult, 0);
assert.equal(await fs.readFile(inputTarget, "utf8"), inputRecord);
const bad = spawn(process.execPath, [scriptPath, target, "--expected-sha256", "bad"], { stdio: ["pipe", "pipe", "pipe"] });
bad.stdin.end(record("Changed"));
const [badResult] = await once(bad, "close");
assert.equal(badResult, 2);
});