feat(skills): add develop workflow
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user