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
8 changes: 5 additions & 3 deletions .claude/hooks/issues-surface.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,10 +32,11 @@ source_val="$(printf '%s' "$payload" \
rows="$(awk '
/^## Open items/ { inopen=1; next }
/^## / { if (inopen) inopen=0 }
inopen && /^\| #[0-9]/ {
inopen && /^\| #[0-9A-HJKMNP-TV-Z]/ {
n=split($0, c, "|")
id=c[2]; pri=c[3]; typ=c[4]; sum=c[5]
gsub(/^[ \t]+|[ \t]+$/, "", id)
sub(/[ \t]+<!--[ \t]*issue-ulid:[^>]+-->[ \t]*$/, "", id)
gsub(/^[ \t]+|[ \t]+$/, "", pri)
gsub(/^[ \t]+|[ \t]+$/, "", typ)
gsub(/^[ \t]+|[ \t]+$/, "", sum)
Expand DownExpand Up@@ -63,10 +64,11 @@ queue_rows="$(awk '
NR==FNR {
if ($0 ~ /^## Open items/) { inopen=1; next }
if ($0 ~ /^## /) { inopen=0 }
if (inopen && $0 ~ /^\| #[0-9]/) {
if (inopen && $0 ~ /^\| #[0-9A-HJKMNP-TV-Z]/) {
split($0, oc, "|")
oid=oc[2]; odetail=oc[6]
gsub(/^[ \t]+|[ \t]+$/, "", oid)
sub(/[ \t]+<!--[ \t]*issue-ulid:[^>]+-->[ \t]*$/, "", oid)
gsub(/^[ \t]+|[ \t]+$/, "", odetail)
detail[oid]=odetail
}
Expand DownExpand Up@@ -114,7 +116,7 @@ fi

# Keep the priority summary complementary to the queue instead of repeating
# the same recommended IDs in both sections.
queued_ids=" $(printf '%s\n' "$queue_rows" | grep -oE '#[0-9]+' | tr '\n' ' ' || true)"
queued_ids=" $(printf '%s\n' "$queue_rows" | grep -oE '#([0-9A-HJKMNP-TV-Z]{6,16}|[0-9]{3,})' | tr '\n' ' ' || true)"
unqueued_rows="$(printf '%s\n' "$rows" | awk -F'\t' -v queued="$queued_ids" '
index(queued, " " $2 " ") == 0
' || true)"
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts-index.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# Scripts index

Curated map of `scripts/` (238 files) and the `package.json` script surface (246 entries),
Curated map of `scripts/` (239 files) and the `package.json` script surface (246 entries),
grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative
command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run <x>`
referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above.
Expand Down
173 changes: 88 additions & 85 deletions scripts/check-outstanding-issues.mjs

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions scripts/issue-id.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { createHash, randomBytes } from "node:crypto";

const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const CROCKFORD_CHARS = "[0-9A-HJKMNP-TV-Z]";

export const ISSUE_ULID_PATTERN = new RegExp(`^[0-7]${CROCKFORD_CHARS}{25}$`);
export const LEGACY_ISSUE_ID_PATTERN = /^#\d{3,}$/;
export const DISPLAY_ISSUE_ID_PATTERN = new RegExp(`^#${CROCKFORD_CHARS}{6,16}$`);
const ISSUE_ID_CELL_PATTERN = new RegExp(
`^(#(?:\\d{3,}|${CROCKFORD_CHARS}{6,16}))(?:\\s+<!--\\s*issue-ulid:([0-7]${CROCKFORD_CHARS}{25})\\s*-->)?$`,
);
const ISSUE_CITATION_PATTERN = new RegExp(`#(?:${CROCKFORD_CHARS}{6,16}|\\d{3,})`, "g");

function encodeBase32(value, length) {
let remaining = BigInt(value);
const output = Array.from({ length }, () => "0");
for (let index = length - 1; index >= 0; index -= 1) {
output[index] = CROCKFORD[Number(remaining & 31n)];
remaining >>= 5n;
}
if (remaining !== 0n) throw new Error(`value does not fit in ${length} Crockford base32 characters`);
return output.join("");
}

function entropyValue(bytes) {
if (!(bytes instanceof Uint8Array) || bytes.length !== 10) {
throw new Error("ULID entropy must be exactly 10 bytes");
}
let value = 0n;
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
return value;
}

export function issueUlid(timestamp = Date.now(), entropy = randomBytes(10)) {
if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > 0xffffffffffff) {
throw new Error("ULID timestamp must be a non-negative 48-bit integer");
}
return `${encodeBase32(BigInt(timestamp), 10)}${encodeBase32(entropyValue(entropy), 16)}`;
}

/** Stable identity for legacy version-1 add requests during the schema transition. */
export function issueUlidFromRequest(createdOn, requestId) {
const timestamp = Date.parse(`${createdOn}T00:00:00.000Z`);
if (!Number.isSafeInteger(timestamp)) throw new Error(`cannot derive an issue ULID from createdOn=${createdOn}`);
const entropy = createHash("sha256").update(String(requestId)).digest().subarray(0, 10);
return issueUlid(timestamp, entropy);
}

export function isIssueUlid(value) {
return ISSUE_ULID_PATTERN.test(String(value ?? ""));
}

export function canonicalLegacyIssueId(number) {
return `#${String(number).padStart(3, "0")}`;
}

export function isIssueDisplayId(value) {
const id = String(value ?? "");
if (DISPLAY_ISSUE_ID_PATTERN.test(id)) return true;
if (!LEGACY_ISSUE_ID_PATTERN.test(id)) return false;
const number = Number(id.slice(1));
return Number.isSafeInteger(number) && id === canonicalLegacyIssueId(number);
}

export function displayIdForUlid(ulid, length = 6) {
if (!isIssueUlid(ulid)) throw new Error(`invalid issue ULID: ${ulid}`);
if (!Number.isInteger(length) || length < 6 || length > 16) {
throw new Error("issue display-id length must be between 6 and 16");
}
return `#${ulid.slice(10, 10 + length)}`;
}

export function allocateDisplayId(ulid, usedIds) {
const allocated = usedIds instanceof Set ? usedIds : new Set(usedIds);
for (let length = 6; length <= 16; length += 1) {
const candidate = displayIdForUlid(ulid, length);
if (!allocated.has(candidate)) return candidate;
}
throw new Error(`issue ULID ${ulid} duplicates an existing durable identity`);
}

export function issueIdCell(displayId, ulid) {
if (!DISPLAY_ISSUE_ID_PATTERN.test(String(displayId ?? ""))) {
throw new Error(`invalid collision-free issue display id: ${displayId}`);
}
if (!isIssueUlid(ulid)) throw new Error(`invalid issue ULID: ${ulid}`);
const suffix = ulid.slice(10);
if (!suffix.startsWith(displayId.slice(1))) {
throw new Error(`issue display id ${displayId} is not derived from ULID ${ulid}`);
}
return `${displayId} <!-- issue-ulid:${ulid} -->`;
}

export function parseIssueIdCell(cell) {
const raw = String(cell ?? "").trim();
const match = raw.match(ISSUE_ID_CELL_PATTERN);
if (!match) {
const numeric = raw.match(/^#(\d+)$/);
const number = numeric ? Number(numeric[1]) : null;
return { id: raw, number: Number.isSafeInteger(number) ? number : null, ulid: null, valid: false };
}
const [, id, ulid = null] = match;
if (ulid) {
const valid = DISPLAY_ISSUE_ID_PATTERN.test(id) && ulid.slice(10).startsWith(id.slice(1));
return { id, number: null, ulid, valid };
}
if (!LEGACY_ISSUE_ID_PATTERN.test(id)) return { id, number: null, ulid: null, valid: false };
const number = Number(id.slice(1));
return {
id,
number,
ulid: null,
valid: Number.isSafeInteger(number) && id === canonicalLegacyIssueId(number),
};
}

export function issueIdCitations(value) {
return [...String(value ?? "").matchAll(ISSUE_CITATION_PATTERN)].map((match) => match[0]);
}
6 changes: 4 additions & 2 deletions scripts/issues-report.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { parseIssues } from "./check-outstanding-issues.mjs";
import { issueIdCitations } from "./issue-id.mjs";
import { splitCells } from "./outstanding-issues.mjs";

const LEDGER_PATH = "docs/outstanding-issues.md";
Expand DownExpand Up@@ -31,7 +32,7 @@ function queueRows(markdown) {
if (cells.length !== 7) continue;
rows.push({
order: Number(cells[0]),
ids: [...cells[1].matchAll(/#\d+/g)].map((match) => match[0]),
ids: issueIdCitations(cells[1]),
acuity: cells[2],
capability: cells[3],
when: cells[4],
Expand DownExpand Up@@ -75,7 +76,8 @@ export function buildIssuesReport(markdown, source) {
.map((row) => {
const cells = splitCells(row.raw);
return {
id: cells[0],
// parseIssues strips the durable ULID comment from the human-facing id.
id: row.id,
priority: cells[1],
type: cells[2],
summary: cells[3],
Expand Down
42 changes: 34 additions & 8 deletions scripts/ledger-inbox.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@
* Conflict-free intake for the outstanding-issues ledger.
*
* Feature branches write one immutable request file; only a deliberately serialized
* `reconcile` operation edits docs/outstanding-issues.md and allocates numeric IDs.
* This keeps a busy PR queue from contending on the next-id marker or one table row.
* `reconcile` operation edits docs/outstanding-issues.md. Add requests carry a
* durable ULID, so independent branches no longer contend on a numeric marker.
*/
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
Expand All@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";

import { addIssue, resolveIssue, updateIssue } from "./outstanding-issues.mjs";
import { ISSUES_PATH, checkIssues } from "./check-outstanding-issues.mjs";
import { isIssueDisplayId, isIssueUlid, issueUlid, issueUlidFromRequest } from "./issue-id.mjs";

const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const INBOX_DIR = "docs/outstanding-issues-inbox";
Expand DownExpand Up@@ -41,21 +42,27 @@ function requestPath(id) {
export function validateRequest(request) {
const problems = [];
if (!request || typeof request !== "object") return ["request must be an object"];
if (request.version !== 1) problems.push("version must be 1");
if (![1, 2].includes(request.version)) problems.push("version must be 1 or 2");
if (!REQUEST_ID.test(request.id ?? "")) problems.push("id must be a UUID");
if (!/^\d{4}-\d{2}-\d{2}$/.test(request.createdOn ?? "")) problems.push("createdOn must be YYYY-MM-DD");
if (!ACTIONS.has(request.action)) problems.push("action must be add, done, update, or cancel");
if (!request.payload || typeof request.payload !== "object") problems.push("payload must be an object");
if (request.action === "add") {
for (const field of ["pri", "type", "summary"])
if (!request.payload?.[field]) problems.push(`add requires ${field}`);
if (request.payload?.issueUlid !== undefined && !isIssueUlid(request.payload.issueUlid)) {
problems.push("add issueUlid must be a valid ULID");
}
if (request.version === 2 && request.payload?.issueUlid === undefined) {
problems.push("version 2 add requires a valid issueUlid");
}
}
if (request.action === "done") {
if (!/^#\d{3,}$/.test(request.payload?.id ?? "")) problems.push("done requires a canonical #NNN id");
if (!isIssueDisplayId(request.payload?.id)) problems.push("done requires a canonical issue display id");
if (!request.payload?.outcome) problems.push("done requires outcome");
}
if (request.action === "update") {
if (!/^#\d{3,}$/.test(request.payload?.id ?? "")) problems.push("update requires a canonical #NNN id");
if (!isIssueDisplayId(request.payload?.id)) problems.push("update requires a canonical issue display id");
// `pri` counts as a mutation on its own: a re-prioritisation with no prose
// change is a legitimate and common triage edit, and leaving it out here
// made `--pri` unusable alone even once the CLI could emit it (ledger #313).
Expand All@@ -82,7 +89,10 @@ export function applyRequest(markdown, request) {
throw new Error("cancel requests must be applied through batch reconciliation");
}
const options = { date: request.createdOn };
if (request.action === "add") return addIssue(markdown, request.payload, options);
if (request.action === "add") {
const durableId = request.payload.issueUlid ?? issueUlidFromRequest(request.createdOn, request.id);
return addIssue(markdown, request.payload, { ...options, issueUlid: durableId });
}
if (request.action === "done") return resolveIssue(markdown, request.payload.id, request.payload.outcome, options);
return updateIssue(markdown, request.payload.id, request.payload);
}
Expand DownExpand Up@@ -374,6 +384,7 @@ function createRequest(action, argv) {
summary: argValue(argv, "summary"),
detail: argValue(argv, "detail"),
source: argValue(argv, "source"),
issueUlid: issueUlid(),
}
: action === "done"
? { id: argv[1], outcome: argValue(argv, "outcome") }
Expand All@@ -392,7 +403,7 @@ function createRequest(action, argv) {
detail: argValue(argv, "detail"),
source: argValue(argv, "source"),
};
const request = { version: 1, id: randomUUID(), createdOn: date(), action, payload };
const request = { version: 2, id: randomUUID(), createdOn: date(), action, payload };
const problems = validateRequest(request);
if (problems.length > 0) throw new Error(problems.join("; "));
const relative = requestPath(request.id);
Expand DownExpand Up@@ -584,7 +595,22 @@ function selfTest() {
}

const added = applyRequest(base, add);
if (!added.includes("#002")) throw new Error("self-test failed: queued add did not preserve ledger invariants");
const replayed = applyRequest(base, add);
const legacyUlid = issueUlidFromRequest(add.createdOn, add.id);
if (!added.includes(`issue-ulid:${legacyUlid}`) || added !== replayed) {
throw new Error("self-test failed: legacy queued add did not derive a stable durable id");
}
const v2Add = {
...add,
version: 2,
payload: { ...add.payload, issueUlid: issueUlid(1, new Uint8Array(10)) },
};
if (validateRequest(v2Add).length > 0 || !applyRequest(base, v2Add).includes(v2Add.payload.issueUlid)) {
throw new Error("self-test failed: version 2 add did not preserve its durable id");
}
if (validateRequest({ ...v2Add, payload: { ...v2Add.payload, issueUlid: "invalid" } }).length === 0) {
throw new Error("self-test failed: invalid version 2 durable id accepted");
}
const resolved = applyRequest(base, done);
if (resolved.includes("`#001`"))
throw new Error("self-test failed: queued resolve did not preserve ledger invariants");
Expand Down
Loading
Loading