/** * `intake` — capture a raw idea durably before it becomes anything (#298). * * The store is the point of the verb: an idea captured at 02:00 is still there * after a restart, and `status` counts what is still pending so a backlog * cannot quietly empty itself. This surface records, lists, dismisses and — * once the orchestrator has groomed an idea into an issue (#300) — marks the * provenance of that grooming via `resolveIntake`'s `groomed` path. */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig } from "../config.ts"; import { dbPath, openStore } from "../store.ts"; /** Flags the intake surface understands, and whether each takes a value. * `--project` is consumed by {@link CommandContext.projectFlag}; the value * token stays in argv, so the arg scan has to step over it. `--json` is a * toggle and must NOT swallow the token after it. */ const INTAKE_FLAGS: Record = { "--project": "value", "--issue": "value", "--json": "toggle", }; /** Every remaining token after the subverb must be one of the known flags * (or its value), otherwise the operator typo'd something that would be * silently ignored — the same exit-2 scan `stats` runs (#462). */ function assertKnownArgs(ctx: CommandContext, from: number): void { for (let i = from; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; const kind = INTAKE_FLAGS[token]; if (kind === "value") { i += 1; continue; } if (kind === "toggle") continue; if (token.startsWith("--project=")) continue; process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`); process.exit(2); } } export async function intakeCommand(ctx: CommandContext): Promise { // `--help`/`-h` never arrives here: the CLI dispatch gate answers a real // help request from the manifest before this handler runs (#863), which is // why the long-form prose lives on the manifest's `details` field rather // than in a usage string this module would print itself. const sub = ctx.argv[1]; const project = findProject(loadConfig(), ctx.projectFlag); const store = openStore(dbPath()); try { if (sub === "list") { assertKnownArgs(ctx, 2); const pending = store.pendingIntake(project.name); const now = Date.now(); // Age at capture granularity: minutes for a fresh idea, hours once it // has been waiting a while — the unit the operator actually cares about. const items = pending.map((item) => { const minutes = Math.max(0, Math.round((now - item.createdAt) / 60_000)); return { id: item.id, age: minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`, // null, not omitted, so a consumer can tell "operator's own idea" // from "this build predates provenance" — the key is always there. source: item.source ?? null, text: item.text, }; }); if (ctx.argv.includes("--json")) { process.stdout.write(`${JSON.stringify({ project: project.name, items }, null, 2)}\n`); return; } if (items.length === 0) { process.stdout.write("no pending ideas\n"); return; } for (const item of items) { // The source column exists so an operator reading this list can tell a // machine-filed signal from something they thought of themselves — // they are answerable for one and merely informed of the other. A dash // marks their own idea; anything else names the signal that filed it. process.stdout.write( `${item.id} ${item.age.padStart(5)} ${item.source ?? "-"} ${item.text}\n`, ); } return; } if (sub === "dismiss") { assertKnownArgs(ctx, 3); const id = ctx.argv[2]; if (id === undefined || id.startsWith("--")) { process.stderr.write("omp-conductor: intake dismiss needs the idea id\n"); process.exit(2); } if (!store.resolveIntake(id, "dismissed")) { process.stderr.write( `omp-conductor: no intake item ${id} for ${project.name} — it was already resolved, or the id is wrong\n`, ); process.exit(1); } process.stdout.write(`intake ${id} dismissed\n`); return; } if (sub === "groomed") { assertKnownArgs(ctx, 3); const id = ctx.argv[2]; const url = ctx.flag("issue")?.trim(); if (id === undefined || id.startsWith("--") || url === undefined || url.length === 0) { process.stderr.write("omp-conductor: intake groomed needs the idea id and --issue \n"); process.exit(2); } if (!store.resolveIntake(id, "groomed", url)) { // Ticks retry: an id that is already groomed (or unknown) is a no-op // with a message, not an error, so a re-filed tick never fails here. process.stdout.write(`intake ${id} already resolved or unknown — nothing to do\n`); return; } process.stdout.write(`intake ${id} groomed → ${url}\n`); return; } if (sub !== undefined && sub.startsWith("--") && INTAKE_FLAGS[sub] === undefined) { process.stderr.write(`omp-conductor: intake: unexpected argument "${sub}"\n`); process.exit(2); } // Capture: the text is the first non-flag token, wherever it sits. Flags // may come before or after it; anything beyond one text token is a typo. let text: string | undefined; for (let i = 1; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; // Only value flags step over their value here. `--json` is a list-only // toggle, so on the capture path it falls through to the rejection below // rather than being silently ignored on a verb that cannot honour it. if (INTAKE_FLAGS[token] === "value") { i += 1; continue; } if (token.startsWith("--project=")) continue; if (token.startsWith("--")) { process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`); process.exit(2); } if (text !== undefined) { process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`); process.exit(2); } text = token; } const trimmed = text?.trim(); if (trimmed === undefined || trimmed.length === 0) { process.stderr.write( "omp-conductor: intake needs a non-empty idea text — e.g. omp-conductor intake \"ship the intake command\"\n", ); process.exit(2); } const item = store.recordIntake({ project: project.name, text: trimmed, at: Date.now(), }); process.stdout.write(`intake ${item.id} recorded — see it with: omp-conductor intake list\n`); } finally { store.close(); } }