UNPKG

hologit

Version:

Hologit automates the projection of layered composite file trees based on flat, declarative plans

1,451 lines (1,424 loc) 54.1 kB
#!/usr/bin/env node // GENERATED by scripts/build-cli.ts from src/cli/ — do not edit. Run `bun run build`. // node_modules/axi-sdk-js/dist/cli.js import { basename } from "node:path"; // node_modules/axi-sdk-js/dist/errors.js var AxiError = class extends Error { code; suggestions; constructor(message, code, suggestions = []) { super(message); this.code = code; this.suggestions = suggestions; this.name = "AxiError"; } }; function exitCodeForError(error) { if (error instanceof AxiError && error.code === "VALIDATION_ERROR") { return 2; } return 1; } // node_modules/axi-sdk-js/dist/output.js import { homedir } from "node:os"; // node_modules/@toon-format/toon/dist/index.mjs var NULL_LITERAL = "null"; var DELIMITERS = { comma: ",", tab: " ", pipe: "|" }; var DEFAULT_DELIMITER = DELIMITERS.comma; function escapeString(value) { return value.replace(/\\/g, `\\\\`).replace(/"/g, `\\"`).replace(/\n/g, `\\n`).replace(/\r/g, `\\r`).replace(/\t/g, `\\t`).replace(/[\u0000-\u001F]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); } function isBooleanOrNullLiteral(token) { return token === "true" || token === "false" || token === "null"; } function normalizeValue(value) { if (value === null) return null; if (typeof value === "object" && value !== null && "toJSON" in value && typeof value.toJSON === "function") { const next = value.toJSON(); if (next !== value) return normalizeValue(next); } if (typeof value === "string" || typeof value === "boolean") return value; if (typeof value === "number") { if (Object.is(value, -0)) return 0; if (!Number.isFinite(value)) return null; return value; } if (typeof value === "bigint") { if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) return Number(value); return value.toString(); } if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) return value.map(normalizeValue); if (value instanceof Set) return Array.from(value).map(normalizeValue); if (value instanceof Map) return Object.fromEntries(Array.from(value, ([k, v]) => [String(k), normalizeValue(v)])); if (isPlainObject(value)) { const encodedValues = {}; for (const key in value) if (Object.hasOwn(value, key)) encodedValues[key] = normalizeValue(value[key]); return encodedValues; } return null; } function isJsonPrimitive(value) { return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } function isJsonArray(value) { return Array.isArray(value); } function isJsonObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function isEmptyObject(value) { return Object.keys(value).length === 0; } function isPlainObject(value) { if (value === null || typeof value !== "object") return false; const prototype = Object.getPrototypeOf(value); return prototype === null || prototype === Object.prototype; } function isArrayOfPrimitives(value) { return value.length === 0 || value.every((item) => isJsonPrimitive(item)); } function isArrayOfArrays(value) { return value.length === 0 || value.every((item) => isJsonArray(item)); } function isArrayOfObjects(value) { return value.length === 0 || value.every((item) => isJsonObject(item)); } var NUMERIC_LIKE_PATTERN = /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i; var LEADING_ZERO_PATTERN = /^0\d+$/; function isValidUnquotedKey(key) { return /^[A-Z_][\w.]*$/i.test(key); } function isIdentifierSegment(key) { return /^[A-Z_]\w*$/i.test(key); } function isSafeUnquoted(value, delimiter = DEFAULT_DELIMITER) { if (!value) return false; if (value !== value.trim()) return false; if (isBooleanOrNullLiteral(value) || isNumericLike(value)) return false; if (value.includes(":")) return false; if (value.includes('"') || value.includes("\\")) return false; if (/[[\]{}]/.test(value)) return false; if (/[\u0000-\u001F]/.test(value)) return false; if (value.includes(delimiter)) return false; if (value.startsWith("-")) return false; return true; } function isNumericLike(value) { return NUMERIC_LIKE_PATTERN.test(value) || LEADING_ZERO_PATTERN.test(value); } function tryFoldKeyChain(key, value, siblings, options, rootLiteralKeys, pathPrefix, flattenDepth) { if (options.keyFolding !== "safe") return; if (!isJsonObject(value)) return; const { segments, tail, leafValue } = collectSingleKeyChain(key, value, flattenDepth ?? options.flattenDepth); if (segments.length < 2) return; if (!segments.every((seg) => isIdentifierSegment(seg))) return; const foldedKey = buildFoldedKey(segments); const absolutePath = pathPrefix ? `${pathPrefix}.${foldedKey}` : foldedKey; if (siblings.includes(foldedKey)) return; if (rootLiteralKeys && rootLiteralKeys.has(absolutePath)) return; return { foldedKey, remainder: tail, leafValue, segmentCount: segments.length }; } function collectSingleKeyChain(startKey, startValue, maxDepth) { const segments = [startKey]; let currentValue = startValue; while (segments.length < maxDepth) { if (!isJsonObject(currentValue)) break; const keys = Object.keys(currentValue); if (keys.length !== 1) break; const nextKey = keys[0]; const nextValue = currentValue[nextKey]; segments.push(nextKey); currentValue = nextValue; } if (!isJsonObject(currentValue) || isEmptyObject(currentValue)) return { segments, tail: void 0, leafValue: currentValue }; return { segments, tail: currentValue, leafValue: currentValue }; } function buildFoldedKey(segments) { return segments.join("."); } function encodePrimitive(value, delimiter) { if (value === null) return NULL_LITERAL; if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); return encodeStringLiteral(value, delimiter); } function encodeStringLiteral(value, delimiter = DEFAULT_DELIMITER) { if (isSafeUnquoted(value, delimiter)) return value; return `"${escapeString(value)}"`; } function encodeKey(key) { if (isValidUnquotedKey(key)) return key; return `"${escapeString(key)}"`; } function encodeAndJoinPrimitives(values, delimiter = DEFAULT_DELIMITER) { return values.map((v) => encodePrimitive(v, delimiter)).join(delimiter); } function formatHeader(length, options) { const key = options?.key; const fields = options?.fields; const delimiter = options?.delimiter ?? ","; let header = ""; if (key != null) header += encodeKey(key); header += `[${length}${delimiter !== DEFAULT_DELIMITER ? delimiter : ""}]`; if (fields) { const quotedFields = fields.map((f) => encodeKey(f)); header += `{${quotedFields.join(delimiter)}}`; } header += ":"; return header; } function* encodeJsonValue(value, options, depth) { if (isJsonPrimitive(value)) { const encodedPrimitive = encodePrimitive(value, options.delimiter); if (encodedPrimitive !== "") yield encodedPrimitive; return; } if (isJsonArray(value)) yield* encodeArrayLines(void 0, value, depth, options); else if (isJsonObject(value)) yield* encodeObjectLines(value, depth, options); } function* encodeObjectLines(value, depth, options, rootLiteralKeys, pathPrefix, remainingDepth) { const keys = Object.keys(value); if (depth === 0 && !rootLiteralKeys) rootLiteralKeys = new Set(keys.filter((k) => k.includes("."))); const effectiveFlattenDepth = remainingDepth ?? options.flattenDepth; for (const [key, val] of Object.entries(value)) yield* encodeKeyValuePairLines(key, val, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth); } function* encodeKeyValuePairLines(key, value, depth, options, siblings, rootLiteralKeys, pathPrefix, flattenDepth) { const currentPath = pathPrefix ? `${pathPrefix}.${key}` : key; const effectiveFlattenDepth = flattenDepth ?? options.flattenDepth; if (options.keyFolding === "safe" && siblings) { const foldResult = tryFoldKeyChain(key, value, siblings, options, rootLiteralKeys, pathPrefix, effectiveFlattenDepth); if (foldResult) { const { foldedKey, remainder, leafValue, segmentCount } = foldResult; const encodedFoldedKey = encodeKey(foldedKey); if (remainder === void 0) { if (isJsonPrimitive(leafValue)) { yield indentedLine(depth, `${encodedFoldedKey}: ${encodePrimitive(leafValue, options.delimiter)}`, options.indent); return; } else if (isJsonArray(leafValue)) { yield* encodeArrayLines(foldedKey, leafValue, depth, options); return; } else if (isJsonObject(leafValue) && isEmptyObject(leafValue)) { yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent); return; } } if (isJsonObject(remainder)) { yield indentedLine(depth, `${encodedFoldedKey}:`, options.indent); const remainingDepth = effectiveFlattenDepth - segmentCount; const foldedPath = pathPrefix ? `${pathPrefix}.${foldedKey}` : foldedKey; yield* encodeObjectLines(remainder, depth + 1, options, rootLiteralKeys, foldedPath, remainingDepth); return; } } } const encodedKey = encodeKey(key); if (isJsonPrimitive(value)) yield indentedLine(depth, `${encodedKey}: ${encodePrimitive(value, options.delimiter)}`, options.indent); else if (isJsonArray(value)) yield* encodeArrayLines(key, value, depth, options); else if (isJsonObject(value)) { yield indentedLine(depth, `${encodedKey}:`, options.indent); if (!isEmptyObject(value)) yield* encodeObjectLines(value, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth); } } function* encodeArrayLines(key, value, depth, options) { if (value.length === 0) { yield indentedLine(depth, key != null ? `${encodeKey(key)}: []` : "[]", options.indent); return; } if (isArrayOfPrimitives(value)) { yield indentedLine(depth, encodeInlineArrayLine(value, options.delimiter, key), options.indent); return; } if (isArrayOfArrays(value)) { if (value.every((arr) => isArrayOfPrimitives(arr))) { yield* encodeArrayOfArraysAsListItemsLines(key, value, depth, options); return; } } if (isArrayOfObjects(value)) { const header = extractTabularHeader(value); if (header) yield* encodeArrayOfObjectsAsTabularLines(key, value, header, depth, options); else yield* encodeMixedArrayAsListItemsLines(key, value, depth, options); return; } yield* encodeMixedArrayAsListItemsLines(key, value, depth, options); } function* encodeArrayOfArraysAsListItemsLines(prefix, values, depth, options) { yield indentedLine(depth, formatHeader(values.length, { key: prefix, delimiter: options.delimiter }), options.indent); for (const arr of values) if (isArrayOfPrimitives(arr)) { const arrayLine = encodeInlineArrayLine(arr, options.delimiter); yield indentedListItem(depth + 1, arrayLine, options.indent); } } function encodeInlineArrayLine(values, delimiter, prefix) { const header = formatHeader(values.length, { key: prefix, delimiter }); const joinedValue = encodeAndJoinPrimitives(values, delimiter); if (values.length === 0) return header; return `${header} ${joinedValue}`; } function* encodeArrayOfObjectsAsTabularLines(prefix, rows, header, depth, options) { yield indentedLine(depth, formatHeader(rows.length, { key: prefix, fields: header, delimiter: options.delimiter }), options.indent); yield* writeTabularRowsLines(rows, header, depth + 1, options); } function extractTabularHeader(rows) { if (rows.length === 0) return; const firstRow = rows[0]; const firstKeys = Object.keys(firstRow); if (firstKeys.length === 0) return; if (isTabularArray(rows, firstKeys)) return firstKeys; } function isTabularArray(rows, header) { for (const row of rows) { if (Object.keys(row).length !== header.length) return false; for (const key of header) { if (!(key in row)) return false; if (!isJsonPrimitive(row[key])) return false; } } return true; } function* writeTabularRowsLines(rows, header, depth, options) { for (const row of rows) yield indentedLine(depth, encodeAndJoinPrimitives(header.map((key) => row[key]), options.delimiter), options.indent); } function* encodeMixedArrayAsListItemsLines(prefix, items, depth, options) { yield indentedLine(depth, formatHeader(items.length, { key: prefix, delimiter: options.delimiter }), options.indent); for (const item of items) yield* encodeListItemValueLines(item, depth + 1, options); } function* encodeObjectAsListItemLines(obj, depth, options) { if (isEmptyObject(obj)) { yield indentedLine(depth, "-", options.indent); return; } const entries = Object.entries(obj); const [firstKey, firstValue] = entries[0]; const restEntries = entries.slice(1); if (isJsonArray(firstValue) && isArrayOfObjects(firstValue)) { const header = extractTabularHeader(firstValue); if (header) { yield indentedListItem(depth, formatHeader(firstValue.length, { key: firstKey, fields: header, delimiter: options.delimiter }), options.indent); yield* writeTabularRowsLines(firstValue, header, depth + 2, options); if (restEntries.length > 0) yield* encodeObjectLines(Object.fromEntries(restEntries), depth + 1, options); return; } } const encodedKey = encodeKey(firstKey); if (isJsonPrimitive(firstValue)) yield indentedListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`, options.indent); else if (isJsonArray(firstValue)) if (firstValue.length === 0) yield indentedListItem(depth, `${encodedKey}: []`, options.indent); else if (isArrayOfPrimitives(firstValue)) yield indentedListItem(depth, `${encodedKey}${encodeInlineArrayLine(firstValue, options.delimiter)}`, options.indent); else { yield indentedListItem(depth, `${encodedKey}${formatHeader(firstValue.length, { delimiter: options.delimiter })}`, options.indent); for (const item of firstValue) yield* encodeListItemValueLines(item, depth + 2, options); } else if (isJsonObject(firstValue)) { yield indentedListItem(depth, `${encodedKey}:`, options.indent); if (!isEmptyObject(firstValue)) yield* encodeObjectLines(firstValue, depth + 2, options); } if (restEntries.length > 0) yield* encodeObjectLines(Object.fromEntries(restEntries), depth + 1, options); } function* encodeListItemValueLines(value, depth, options) { if (isJsonPrimitive(value)) yield indentedListItem(depth, encodePrimitive(value, options.delimiter), options.indent); else if (isJsonArray(value)) if (isArrayOfPrimitives(value)) yield indentedListItem(depth, encodeInlineArrayLine(value, options.delimiter), options.indent); else { yield indentedListItem(depth, formatHeader(value.length, { delimiter: options.delimiter }), options.indent); for (const item of value) yield* encodeListItemValueLines(item, depth + 1, options); } else if (isJsonObject(value)) yield* encodeObjectAsListItemLines(value, depth, options); } function indentedLine(depth, content, indentSize) { return " ".repeat(indentSize * depth) + content; } function indentedListItem(depth, content, indentSize) { return indentedLine(depth, "- " + content, indentSize); } function applyReplacer(root, replacer) { const replacedRoot = replacer("", root, []); if (replacedRoot === void 0) return transformChildren(root, replacer, []); return transformChildren(normalizeValue(replacedRoot), replacer, []); } function transformChildren(value, replacer, path) { if (isJsonObject(value)) return transformObject(value, replacer, path); if (isJsonArray(value)) return transformArray(value, replacer, path); return value; } function transformObject(obj, replacer, path) { const result = {}; for (const [key, value] of Object.entries(obj)) { const childPath = [...path, key]; const replacedValue = replacer(key, value, childPath); if (replacedValue === void 0) continue; result[key] = transformChildren(normalizeValue(replacedValue), replacer, childPath); } return result; } function transformArray(arr, replacer, path) { const result = []; for (let i = 0; i < arr.length; i++) { const value = arr[i]; const childPath = [...path, i]; const replacedValue = replacer(String(i), value, childPath); if (replacedValue === void 0) continue; const normalizedValue = normalizeValue(replacedValue); result.push(transformChildren(normalizedValue, replacer, childPath)); } return result; } function encode(input, options) { return Array.from(encodeLines(input, options)).join("\n"); } function encodeLines(input, options) { const normalizedValue = normalizeValue(input); const resolvedOptions = resolveOptions(options); return encodeJsonValue(resolvedOptions.replacer ? applyReplacer(normalizedValue, resolvedOptions.replacer) : normalizedValue, resolvedOptions, 0); } function resolveOptions(options) { return { indent: options?.indent ?? 2, delimiter: options?.delimiter ?? DEFAULT_DELIMITER, keyFolding: options?.keyFolding ?? "off", flattenDepth: options?.flattenDepth ?? Number.POSITIVE_INFINITY, replacer: options?.replacer }; } // node_modules/axi-sdk-js/dist/output.js function collapseHomeDirectory(path, homeDir = homedir()) { if (!path.startsWith(homeDir)) { return path; } return `~${path.slice(homeDir.length)}`; } function homeHeaderOutput(options) { return { bin: collapseHomeDirectory(options.execPath ?? process.argv[1] ?? "", options.homeDir), description: options.description }; } function errorOutput(message, code, suggestions = []) { const output = { error: message, code }; if (suggestions.length > 0) { output.help = suggestions; } return output; } function renderOutput(output) { if (typeof output === "string") { return output; } return encode(output); } function renderError(message, code, suggestions = []) { return renderOutput(errorOutput(message, code, suggestions)); } // node_modules/axi-sdk-js/dist/cli.js function defaultFormatError(error) { if (error instanceof AxiError) { return { output: `${renderError(error.message, error.code, error.suggestions)} `, exitCode: exitCodeForError(error) }; } const message = error instanceof Error ? error.message : String(error); return { output: `${renderError(message, "UNKNOWN")} `, exitCode: 1 }; } function defaultUnknownCommand(command) { return `${renderError(`Unknown command: ${command}`, "VALIDATION_ERROR", [ "Run `--help` to see available commands" ])} `; } async function runAxiCli(options) { options.initialize?.(); const stdout = options.stdout ?? process.stdout; const argv = options.argv ?? process.argv.slice(2); if (argv.length === 1 && argv[0] === "--help") { stdout.write(options.topLevelHelp); return; } if (argv.length === 1 && isVersionFlag(argv[0])) { if (!options.version) { stdout.write(`${renderError("Version is not configured for this tool", "VALIDATION_ERROR")} `); process.exitCode = 2; return; } stdout.write(`${options.version} `); return; } const command = argv[0]; if (!command) { const context2 = await options.resolveContext?.({ command: void 0, args: [] }); await runHandler(options.home, [], context2, stdout, options, true); return; } if (command.startsWith("-")) { stdout.write(renderLeadingFlagError(command)); process.exitCode = 2; return; } const args = argv.slice(1); if (args.includes("--help")) { const help = options.getCommandHelp?.(command); if (help) { stdout.write(help); return; } } const handler = options.commands[command]; if (!handler) { stdout.write((options.renderUnknownCommand ?? defaultUnknownCommand)(command)); process.exitCode = 2; return; } const context = await options.resolveContext?.({ command, args }); await runHandler(handler, args, context, stdout, options, false); } async function runHandler(handler, args, context, stdout, options, isHomeView) { try { const output = await handler(args, context); stdout.write(`${renderCommandOutput(output, options, isHomeView)} `); } catch (error) { const formatted = (options.formatError ?? defaultFormatError)(error); stdout.write(formatted.output); process.exitCode = formatted.exitCode; } } function renderLeadingFlagError(flag) { const bin = basename(process.argv[1] ?? "tool") || "tool"; return `${renderError("Flags must come after the command", "VALIDATION_ERROR", [ `Run \`${bin} <command> [args] [flags]\``, `Move \`${flag}\` after the command instead of before it` ])} `; } function isVersionFlag(flag) { return flag === "-v" || flag === "-V" || flag === "--version"; } function renderCommandOutput(output, options, isHomeView) { if (!isHomeView) { return renderOutput(output); } const header = homeHeaderOutput({ description: options.description }); if (typeof output === "string") { return `${renderOutput(header)} ${output}`; } return renderOutput(mergeHomeHeader(header, output)); } function mergeHomeHeader(header, output) { const rest = { ...output }; delete rest.bin; delete rest.description; return { ...header, ...rest }; } // node_modules/axi-sdk-js/dist/hooks.js function isManagedHook(hook, marker) { return typeof hook?.command === "string" && hook.command.includes(marker); } function computeSessionStartHookUpdate(settings, spec) { const updated = structuredClone(settings); let changed = false; if (!updated.hooks) { updated.hooks = {}; changed = true; } if (Array.isArray(updated.hooks.session_start)) { const legacyHooks = updated.hooks.session_start.filter((hook) => !isManagedHook(hook, spec.marker)); if (legacyHooks.length !== updated.hooks.session_start.length) { changed = true; if (legacyHooks.length === 0) { delete updated.hooks.session_start; } else { updated.hooks.session_start = legacyHooks; } } } if (!Array.isArray(updated.hooks.SessionStart)) { updated.hooks.SessionStart = []; changed = true; } for (const group of updated.hooks.SessionStart) { if (!Array.isArray(group.hooks)) { continue; } for (const hook of group.hooks) { if (!isManagedHook(hook, spec.marker)) { continue; } const timeout = spec.timeoutSeconds ?? 10; const isCorrect = hook.command === spec.command && hook.type === "command" && hook.timeout === timeout; if (isCorrect && !changed) { return [settings, false]; } hook.command = spec.command; hook.type = "command"; hook.timeout = timeout; return [updated, true]; } } updated.hooks.SessionStart.push({ matcher: "", hooks: [ { type: "command", command: spec.command, timeout: spec.timeoutSeconds ?? 10 } ] }); return [updated, true]; } // src/cli/reference.ts var DESCRIPTION = "Query the SpecOps plans DAG for the current repo \u2014 what's ready to work on, what's blocked, and the dependency graph. A thin determinism layer over a files-first plans/ workflow."; // src/cli/args.ts function parseArgs(args, booleanFlags = []) { const positionals = []; const flags = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith("--")) { const eq = arg.indexOf("="); if (eq !== -1) { flags[arg.slice(2, eq)] = arg.slice(eq + 1); continue; } const name = arg.slice(2); if (booleanFlags.includes(name)) { flags[name] = true; } else { const value = args[++i]; if (value === void 0) { throw new AxiError(`Flag --${name} requires a value`, "VALIDATION_ERROR"); } flags[name] = value; } } else { positionals.push(arg); } } return { positionals, flags }; } function plansDirArg(parsed) { const dir = parsed.flags.dir; if (typeof dir === "string" && dir) return dir; return parsed.positionals[0] ?? "./plans"; } // src/cli/plans.ts import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { basename as basename2, join } from "node:path"; var VALID_STATUSES = [ "planned", "in-progress", "done", "blocked", "cancelled" ]; function readFrontmatter(filePath) { const text = readFileSync(filePath, "utf8"); const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return null; return match[1]; } function parseScalar(block, key) { const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"); const m = block.match(re); if (!m) return null; return m[1].replace(/^["']|["']$/g, ""); } function parseInlineList(block, key) { const re = new RegExp(`^${key}:\\s*\\[(.*?)\\]\\s*$`, "m"); const m = block.match(re); if (!m) return null; return m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); } function parseBlockList(block, key) { const lines = block.split(/\r?\n/); const startRe = new RegExp(`^${key}:\\s*$`); let i = lines.findIndex((l) => startRe.test(l)); if (i === -1) return null; const items = []; for (i += 1; i < lines.length; i += 1) { const line = lines[i]; const m = line.match(/^\s+-\s+(.+?)\s*$/); if (!m) break; items.push(m[1].replace(/^["']|["']$/g, "")); } return items; } function parseList(block, key) { const inline = parseInlineList(block, key); if (inline !== null) return inline; const blockList = parseBlockList(block, key); if (blockList !== null) return blockList; return []; } function parsePlan(filePath) { const fm = readFrontmatter(filePath); if (fm === null) return null; const slug = basename2(filePath, ".md"); const status2 = parseScalar(fm, "status") || "unknown"; const depends = parseList(fm, "depends"); const awaits = parseList(fm, "awaits"); const pr = parseScalar(fm, "pr"); return { slug, file: filePath, status: status2, depends, awaits, pr: pr ? Number(pr) : null }; } function loadPlans(dir) { const stat = statSync(dir); if (!stat.isDirectory()) { throw new Error(`not a directory: ${dir}`); } const entries = readdirSync(dir).filter((n) => n.endsWith(".md")).filter((n) => n !== "README.md").filter((n) => !n.startsWith("_")); const plans = /* @__PURE__ */ new Map(); const warnings = []; for (const name of entries) { const full = join(dir, name); const plan = parsePlan(full); if (plan === null) { warnings.push(`${name}: no YAML frontmatter, skipping`); continue; } if (!VALID_STATUSES.includes(plan.status)) { warnings.push(`${plan.slug}: unknown status "${plan.status}"`); } plans.set(plan.slug, plan); } for (const plan of plans.values()) { for (const dep of plan.depends) { if (!plans.has(dep)) { warnings.push(`${plan.slug}: depends on "${dep}" which has no plan file`); } } } for (const plan of plans.values()) { if (plan.status !== "blocked") continue; if (plan.awaits.length > 0) continue; const hasOpenDeps = plan.depends.some((dep) => { const d = plans.get(dep); return d && d.status !== "done" && d.status !== "cancelled"; }); if (!hasOpenDeps) { warnings.push( `${plan.slug}: status: blocked with no awaits: and no unfinished depends \u2014 what's blocking it?` ); } } return { plans, warnings }; } function topoSort(plans) { const inDegree = /* @__PURE__ */ new Map(); const dependents = /* @__PURE__ */ new Map(); for (const slug of plans.keys()) { inDegree.set(slug, 0); dependents.set(slug, []); } for (const plan of plans.values()) { for (const dep of plan.depends) { if (!plans.has(dep)) continue; inDegree.set(plan.slug, inDegree.get(plan.slug) + 1); dependents.get(dep).push(plan.slug); } } const ready = []; for (const [slug, deg] of inDegree.entries()) { if (deg === 0) ready.push(slug); } ready.sort(); const order = []; while (ready.length > 0) { const slug = ready.shift(); order.push(slug); for (const child of dependents.get(slug)) { inDegree.set(child, inDegree.get(child) - 1); if (inDegree.get(child) === 0) { ready.push(child); ready.sort(); } } } const cycles = []; for (const [slug, deg] of inDegree.entries()) { if (deg > 0) cycles.push(slug); } return { order, cycles }; } function computeDownstreamCounts(plans) { const dependents = /* @__PURE__ */ new Map(); for (const slug of plans.keys()) dependents.set(slug, []); for (const plan of plans.values()) { for (const dep of plan.depends) { if (dependents.has(dep)) dependents.get(dep).push(plan.slug); } } const isOpen = (slug) => { const p = plans.get(slug); return !!p && p.status !== "done" && p.status !== "cancelled"; }; const memo = /* @__PURE__ */ new Map(); function walk(slug, seen) { if (memo.has(slug)) return memo.get(slug); if (seen.has(slug)) return /* @__PURE__ */ new Set(); seen.add(slug); const reachable = /* @__PURE__ */ new Set(); for (const child of dependents.get(slug)) { if (isOpen(child)) reachable.add(child); for (const r of walk(child, seen)) reachable.add(r); } seen.delete(slug); memo.set(slug, reachable); return reachable; } const counts = /* @__PURE__ */ new Map(); for (const slug of plans.keys()) { counts.set(slug, walk(slug, /* @__PURE__ */ new Set()).size); } return counts; } function classify(plan, plans) { const openDeps = []; for (const dep of plan.depends) { const depPlan = plans.get(dep); if (!depPlan) { openDeps.push(`${dep} (no plan file)`); continue; } if (depPlan.status !== "done" && depPlan.status !== "cancelled") { openDeps.push(`${dep} [${depPlan.status}]`); } } if (plan.status === "in-progress") return { state: "in-progress", openDeps }; if (plan.status === "blocked") return { state: "blocked-status", openDeps }; if (plan.awaits.length > 0) return { state: "awaiting", openDeps }; if (openDeps.length > 0) return { state: "blocked-by-deps", openDeps }; return { state: "ready", openDeps }; } function analyzePlans(dir) { const { plans, warnings } = loadPlans(dir); const downstream = computeDownstreamCounts(plans); const { order, cycles } = topoSort(plans); if (cycles.length > 0) { warnings.push(`cycle detected among: ${cycles.join(", ")}`); } const ready = []; const inProgress = []; const awaiting = []; const blockedByDeps = []; const blockedByStatus = []; const done = []; const cancelled = []; const slugs = order.length ? order : [...plans.keys()]; for (const slug of slugs) { const plan = plans.get(slug); if (plan.status === "done") { done.push(plan); continue; } if (plan.status === "cancelled") { cancelled.push(plan); continue; } const c = classify(plan, plans); const entry = { plan, openDeps: c.openDeps }; if (c.state === "ready") ready.push(entry); else if (c.state === "in-progress") inProgress.push(entry); else if (c.state === "awaiting") awaiting.push(entry); else if (c.state === "blocked-status") blockedByStatus.push(entry); else blockedByDeps.push(entry); } ready.sort((a, b) => { const da = downstream.get(a.plan.slug) || 0; const db = downstream.get(b.plan.slug) || 0; if (db !== da) return db - da; return a.plan.slug.localeCompare(b.plan.slug); }); return { plans, warnings, cycles, downstream, ready, inProgress, awaiting, blockedByDeps, blockedByStatus, done, cancelled }; } function plansDirExists(dir) { return existsSync(dir) && statSync(dir).isDirectory(); } // src/cli/toon.ts function renderObject(obj) { return encode(obj); } function renderList(label, items) { return encode({ [label]: items }); } function renderLines(label, lines) { const clean = lines.filter(Boolean); if (clean.length === 0) return ""; return `${label}[${clean.length}]: ${clean.map((l) => ` ${l}`).join("\n")}`; } function renderHelp(lines) { return renderLines("help", lines); } function renderOutput2(blocks) { return blocks.filter(Boolean).join("\n"); } // src/cli/invocation.ts import { accessSync, constants } from "node:fs"; import { homedir as homedir2 } from "node:os"; import { fileURLToPath } from "node:url"; var cached; function cliInvocation() { if (cached) return cached; let bundle; try { bundle = fileURLToPath(import.meta.url); } catch { bundle = process.argv[1] ?? "specops"; } const shim = bundle.replace(/\.mjs$/, ""); try { if (shim !== bundle) { accessSync(shim, constants.X_OK); cached = quote(collapseHome(shim)); return cached; } } catch { } cached = `node ${quote(collapseHome(bundle))}`; return cached; } function collapseHome(p) { const home = homedir2(); return home && p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p; } function quote(p) { return /\s/.test(p) ? `"${p}"` : p; } // src/cli/commands/common.ts import { resolve } from "node:path"; import { homedir as homedir3 } from "node:os"; function resolvePlansDir(parsed) { return resolve(plansDirArg(parsed)); } function collapseHome2(p) { const home = homedir3(); return home && p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p; } function summaryLine(a) { return `${a.ready.length} ready \xB7 ${a.inProgress.length} in-progress \xB7 ${a.awaiting.length} awaiting \xB7 ${a.blockedByDeps.length} blocked-by-deps \xB7 ${a.blockedByStatus.length} blocked \xB7 ${a.done.length} done \xB7 ${a.cancelled.length} cancelled`; } function joinAwaits(c) { return c.plan.awaits.join("; "); } function joinDeps(c) { return c.openDeps.length ? c.openDeps.join("; ") : "none"; } // src/cli/git.ts import { execFileSync } from "node:child_process"; var UNIT = ""; function recentlyDone(plansDir, doneSlugs, limit) { if (doneSlugs.length === 0 || limit <= 0) return []; let out; try { out = execFileSync( "git", [ "log", "-p", "-U0", "--no-color", "-G", "status:", `--format=${UNIT}%H %cs`, "--", ...doneSlugs.map((s) => `${s}.md`) ], { cwd: plansDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 } ); } catch { return []; } const wanted = new Set(doneSlugs); const seen = /* @__PURE__ */ new Set(); const results = []; let commit = ""; let date = ""; let file; for (const line of out.split("\n")) { if (line.startsWith(UNIT)) { const sp = line.indexOf(" "); commit = line.slice(1, sp); date = line.slice(sp + 1).trim(); file = void 0; continue; } if (line.startsWith("+++ ")) { const m = line.match(/([^/\s]+)\.md/); file = m ? m[1] : void 0; continue; } if (line.startsWith("--- ")) continue; if (file && !seen.has(file) && wanted.has(file) && /^\+status:\s*done\b/.test(line)) { seen.add(file); results.push({ slug: file, date, commit }); if (results.length >= limit) break; } } return results; } // src/cli/commands/home.ts var RECENT_DONE_LIMIT = 3; async function homeCommand(args) { const parsed = parseArgs(args, []); const dir = resolvePlansDir(parsed); const cli = cliInvocation(); if (!plansDirExists(dir)) { return renderOutput2([ renderObject({ plans: `no plans/ directory in ${collapseHome2(dir)}` }), renderHelp([ "This repo has no plans/ yet \u2014 add one to track work-in-flight (see references/plans-protocol.md)", `Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command` ]) ]); } const a = analyzePlans(dir); if (a.plans.size === 0) { return renderOutput2([ renderObject({ plans: `0 plan files in ${collapseHome2(dir)}` }), renderHelp([ "Add a plan file under plans/ to begin (see references/plans-protocol.md)", `Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command` ]) ]); } const blocks = [renderObject({ summary: summaryLine(a) })]; if (a.inProgress.length) { blocks.push( renderList( "in_progress", a.inProgress.map((r) => ({ slug: r.plan.slug, unblocks: a.downstream.get(r.plan.slug) || 0 })) ) ); } if (a.ready.length) { blocks.push( renderList( "ready", a.ready.map((r) => ({ slug: r.plan.slug, unblocks: a.downstream.get(r.plan.slug) || 0 })) ) ); } const blocked = [...a.awaiting, ...a.blockedByDeps, ...a.blockedByStatus]; if (blocked.length) { blocks.push( renderList( "blocked", blocked.map((c) => ({ slug: c.plan.slug, why: whyBlocked(c) })) ) ); } const recent = recentlyDone( dir, a.done.map((p) => p.slug), RECENT_DONE_LIMIT ); if (recent.length) { const prBySlug = new Map(a.done.map((p) => [p.slug, p.pr])); blocks.push( renderList( "recently_done", recent.map((r) => ({ slug: r.slug, done: r.date, pr: prBySlug.get(r.slug) ?? null })) ) ); } if (a.warnings.length) { blocks.push(renderLines("warnings", a.warnings)); } blocks.push( renderHelp([ `Run \`${cli} next\` for the full readiness breakdown`, `Run \`${cli} dag --fence\` to visualize the dependency graph`, `Run \`${cli} --help\` to see the full command list, or \`${cli} <command> --help\` for usage on any command` ]) ); return renderOutput2(blocks); } function whyBlocked(c) { const more = (rest) => rest > 0 ? ` (+${rest} more)` : ""; if (c.plan.awaits.length) { return `awaits: ${c.plan.awaits[0]}${more(c.plan.awaits.length - 1)}`; } if (c.openDeps.length) { return `needs: ${c.openDeps[0]}${more(c.openDeps.length - 1)}`; } return `status: ${c.plan.status}`; } // src/cli/commands/next.ts var NEXT_HELP = `usage: specops next [plans-dir] [flags] Plans ordered by readiness. Sections (each emitted only when non-empty): ready deps all done AND awaits empty \u2014 sorted so the plan that unblocks the most downstream work appears first in_progress (only with --include-in-progress) someone's already on it awaiting non-empty awaits: \u2014 external blockers called out blocked_by_deps awaits empty but one or more deps still open blocked status: blocked (lifecycle explicitly blocked) done/cancelled plans are omitted; the summary line carries their counts. flags: --dir <path> plans directory (default ./plans; also positional) --include-in-progress also list in-progress plans --slugs-only print ready slugs only, one per line (scripting) --help examples: specops next specops next --include-in-progress specops next --slugs-only`; async function nextCommand(args) { const parsed = parseArgs(args, ["include-in-progress", "slugs-only"]); const dir = resolvePlansDir(parsed); const cli = cliInvocation(); if (!plansDirExists(dir)) { return renderOutput2([ renderObject({ plans: `no plans/ directory at ${collapseHome2(dir)}` }), renderHelp([ "Start one by adding a plan file under plans/ (see references/plans-protocol.md)", `Run \`${cli} --help\` for usage` ]) ]); } const a = analyzePlans(dir); if (a.plans.size === 0) { return renderOutput2([ renderObject({ plans: `0 plan files in ${collapseHome2(dir)} (looked for *.md, excluding README.md and _*.md)` }), renderHelp(["Add a plan file under plans/ to begin (see references/plans-protocol.md)"]) ]); } if (parsed.flags["slugs-only"]) { return a.ready.map((r) => r.plan.slug).join("\n"); } const unblocks = (slug) => a.downstream.get(slug) || 0; const blocks = [renderObject({ summary: summaryLine(a) })]; if (a.ready.length) { blocks.push( renderList( "ready", a.ready.map((r) => ({ slug: r.plan.slug, unblocks: unblocks(r.plan.slug) })) ) ); } if (parsed.flags["include-in-progress"] && a.inProgress.length) { blocks.push( renderList( "in_progress", a.inProgress.map((r) => ({ slug: r.plan.slug, unblocks: unblocks(r.plan.slug), awaits: r.plan.awaits.length ? joinAwaits(r) : "none" })) ) ); } if (a.awaiting.length) { blocks.push( renderList( "awaiting", a.awaiting.map((r) => ({ slug: r.plan.slug, awaits: joinAwaits(r), deps: joinDeps(r) })) ) ); } if (a.blockedByDeps.length) { blocks.push( renderList( "blocked_by_deps", a.blockedByDeps.map((r) => ({ slug: r.plan.slug, deps: joinDeps(r) })) ) ); } if (a.blockedByStatus.length) { blocks.push( renderList( "blocked", a.blockedByStatus.map((r) => ({ slug: r.plan.slug, awaits: r.plan.awaits.length ? joinAwaits(r) : "none", deps: joinDeps(r) })) ) ); } if (a.warnings.length) { blocks.push(renderLines("warnings", a.warnings)); } const help = []; if (a.ready.length) { help.push("Read the top ready plan's file, then mark it in-progress to start"); } if (!parsed.flags["include-in-progress"] && a.inProgress.length) { help.push(`Run \`${cli} next --include-in-progress\` to also see in-flight plans`); } help.push(`Run \`${cli} dag --fence\` to visualize the dependency graph`); blocks.push(renderHelp(help)); return renderOutput2(blocks); } // src/cli/commands/dag.ts var DAG_HELP = `usage: specops dag [plans-dir] [flags] Emit a Mermaid graph of the plans DAG, nodes styled by status: planned light gray \xB7 in-progress amber \xB7 done green (PR # appended) \xB7 blocked red \xB7 cancelled dashed gray (hidden unless --include-cancelled). Plans with non-empty awaits: get a dashed border. Edges follow depends:. flags: --dir <path> plans directory (default ./plans; also positional) --direction TB|LR|BT|RL graph direction (default TB) --fence wrap output in a \`\`\`mermaid fence --include-cancelled render cancelled nodes --help examples: specops dag --fence specops dag --direction LR`; var DIRECTIONS = ["TB", "LR", "BT", "RL"]; function nodeId(slug) { return slug.replace(/[^A-Za-z0-9_]/g, "_"); } function nodeShape(plan) { const id = nodeId(plan.slug); let label = plan.slug; if (plan.status === "done" && plan.pr) label += `<br/>PR #${plan.pr}`; label = label.replace(/"/g, "&quot;"); if (plan.status === "blocked") return `${id}(["${label}"])`; if (plan.status === "cancelled") return `${id}["${label}"]`; return `${id}("${label}")`; } function statusClass(status2) { switch (status2) { case "planned": return "planned"; case "in-progress": return "inProgress"; case "done": return "done"; case "blocked": return "blocked"; case "cancelled": return "cancelled"; default: return "unknown"; } } function renderMermaid(plans, direction, includeCancelled) { const lines = [`graph ${direction}`]; const shown = /* @__PURE__ */ new Map(); for (const plan of plans.values()) { if (plan.status === "cancelled" && !includeCancelled) continue; shown.set(plan.slug, plan); lines.push(` ${nodeShape(plan)}`); } for (const plan of shown.values()) { for (const dep of plan.depends) { if (!shown.has(dep)) continue; lines.push(` ${nodeId(dep)} --> ${nodeId(plan.slug)}`); } } lines.push(""); lines.push(" classDef planned fill:#f3f4f6,stroke:#9ca3af,color:#111827"); lines.push(" classDef inProgress fill:#fef3c7,stroke:#d97706,color:#78350f"); lines.push(" classDef done fill:#d1fae5,stroke:#059669,color:#064e3b"); lines.push(" classDef blocked fill:#fee2e2,stroke:#dc2626,color:#7f1d1d"); lines.push(" classDef cancelled fill:#e5e7eb,stroke:#9ca3af,color:#6b7280,stroke-dasharray:5 5"); lines.push(" classDef awaits stroke-dasharray:3 3,stroke-width:2px"); for (const plan of shown.values()) { lines.push(` class ${nodeId(plan.slug)} ${statusClass(plan.status)}`); if (plan.awaits.length > 0) { lines.push(` class ${nodeId(plan.slug)} awaits`); } } return lines.join("\n") + "\n"; } async function dagCommand(args) { const parsed = parseArgs(args, ["fence", "include-cancelled"]); const direction = typeof parsed.flags.direction === "string" ? parsed.flags.direction : "TB"; if (!DIRECTIONS.includes(direction)) { throw new AxiError(`--direction must be one of ${DIRECTIONS.join(", ")} (got ${direction})`, "VALIDATION_ERROR", [ "specops dag --direction LR" ]); } const dir = resolvePlansDir(parsed); const cli = cliInvocation(); if (!plansDirExists(dir)) { return renderOutput2([ renderObject({ plans: `no plans/ directory at ${collapseHome2(dir)}` }), renderHelp([`Run \`${cli} --help\` for usage`]) ]); } const { plans, warnings } = loadPlans(dir); if (plans.size === 0) { return renderObject({ plans: `0 plan files in ${collapseHome2(dir)} \u2014 nothing to graph` }); } const body = renderMermaid(plans, direction, parsed.flags["include-cancelled"] === true); const diagram = parsed.flags.fence ? "```mermaid\n" + body + "```" : body.replace(/\n$/, ""); return renderOutput2([diagram, renderLines("warnings", warnings)]); } // src/cli/commands/hook.ts import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as existsSync2 } from "node:fs"; import { homedir as homedir4 } from "node:os"; import { dirname, join as join2, resolve as resolve2 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { execSync } from "node:child_process"; var HOOK_HELP = `usage: specops hook <install|uninstall> [--scope project|global] [--dir <path>] specops hook status Manage the SessionStart hook that loads this repo's plans dashboard at the start of every agent session \u2014 so an agent sees what's ready / blocked from turn 1. Default scope is project: the hook is written to <repo>/.claude/settings.json, so it only fires for sessions in that repo and reads its plans/ (sessions start at the repo root). --dir sets the repo root (default: the git repo root of the current directory). Use --scope global to install it in ~/.claude/settings.json for every session instead. hook install project hook for the current repo (default) hook install --dir ../other-repo project hook for another repo hook install --scope global hook for every session, any repo hook uninstall [--scope project|global] remove it (default scope project) hook status report where the hook is installed`; var MARKER = "specops"; var TIMEOUT_SECONDS = 10; function gitRoot() { try { const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); return root || void 0; } catch { return void 0; } } function projectBase(flags) { const dir = flags.dir; if (typeof dir === "string" && dir) return resolve2(dir); const root = gitRoot(); if (root) return root; throw new AxiError( "Couldn't determine the project directory (current directory is not a git repository)", "VALIDATION_ERROR", ["Pass --dir <project-path>, or run from inside the target repo"] ); } function resolveScope(flags) { const scope = flags.scope; if (scope === void 0) return "project"; if (scope !== "project" && scope !== "global") { throw new AxiError("--scope must be project or global", "VALIDATION_ERROR", [ "specops hook install (project \u2014 the default)", "specops hook install --scope global" ]); } return scope; } function settingsPath(scope, flags) { const base = scope === "global" ? homedir4() : projectBase(flags); return join2(base, ".claude", "settings.json"); } function shimPath() { return join2(dirname(fileURLToPath2(import.meta.url)), "specops"); } var PROJECT_HOOK_COMMAND = '"${CLAUDE_PROJECT_DIR}/.claude/skills/specops/scripts/specops"'; function hookCommand(scope) { return scope === "global" ? JSON.stringify(shimPath()) : PROJECT_HOOK_COMMAND; } function readSettings(path) { if (!existsSync2(path)) return {}; try { const parsed = JSON.parse(readFileSync2(path, "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { throw new AxiError(`Could not parse ${path}`, "CONFIG_INVALID", ["Fix or remove the malformed JSON file"]); } } function writeSettings(path, settings) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(settings, null, 2)} `, "utf8"); } function managedCommand(settings) { const groups = settings.hooks?.SessionStart; if (!Array.isArray(groups)) return void 0; for (const group of groups) { for (const hook of group.hooks ?? []) { if (typeof hook.command === "string" && hook.command.includes(MARKER)) return hook.command; } } return void 0; } async function hookCommand_(args) { const verb = args[0]; const rest = args.slice(1); switch (verb) { case "install": return install(rest); case "uninstall": return uninstall(rest); case "status": return status(); default: throw new AxiError(`Unknown hook subcommand: ${verb ?? "(none)"}`, "VAL