feat: add decision center document contract
This commit is contained in:
@@ -7,12 +7,16 @@ type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "i
|
||||
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
|
||||
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
|
||||
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
|
||||
type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
|
||||
type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
|
||||
|
||||
const serviceId = "decision-center";
|
||||
const typeValues = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const requirementTypeValues = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const levelValues = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
|
||||
const statusValues = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
|
||||
const documentTypeValues = new Set<DecisionDocumentType>(["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]);
|
||||
const documentPriorityValues = new Set<DecisionDocumentPriority>(["P0", "P1", "P2", "P3"]);
|
||||
|
||||
function optionValue(args: string[], names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
@@ -74,6 +78,67 @@ function parseStatus(raw: string | undefined, fallback: DecisionRecordStatus): D
|
||||
return value as DecisionRecordStatus;
|
||||
}
|
||||
|
||||
function parseDocumentNo(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = raw.trim().toUpperCase();
|
||||
if (!/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-\d{4}-\d{1,9}$/u.test(value)) {
|
||||
throw new Error("--doc-no must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>, for example DC-GOAL-P0-2026-001");
|
||||
}
|
||||
const [prefix, docType, docPriority, docYear, docSeq] = value.split("-");
|
||||
return `${prefix}-${docType}-${docPriority}-${docYear}-${String(Number(docSeq)).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function parseDocumentType(raw: string | undefined): DecisionDocumentType | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = raw.toUpperCase();
|
||||
if (!documentTypeValues.has(value as DecisionDocumentType)) throw new Error(`--doc-type must be one of: ${Array.from(documentTypeValues).join(", ")}`);
|
||||
return value as DecisionDocumentType;
|
||||
}
|
||||
|
||||
function parseDocumentPriority(raw: string | undefined): DecisionDocumentPriority | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = raw.toUpperCase();
|
||||
if (!documentPriorityValues.has(value as DecisionDocumentPriority)) throw new Error(`--doc-priority must be one of: ${Array.from(documentPriorityValues).join(", ")}`);
|
||||
return value as DecisionDocumentPriority;
|
||||
}
|
||||
|
||||
function parseDocumentYear(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(value) || value < 1970 || value > 2100) throw new Error("--doc-year must be a four-digit year between 1970 and 2100");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDocumentSeq(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!/^\d+$/u.test(raw) || !Number.isInteger(value) || value <= 0) throw new Error("--doc-seq must be a positive integer");
|
||||
return value;
|
||||
}
|
||||
|
||||
function addDocumentPayloadFields(payload: Record<string, unknown>, args: string[]): void {
|
||||
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
|
||||
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
|
||||
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
|
||||
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
|
||||
const docSeq = parseDocumentSeq(optionValue(args, ["--doc-seq", "--docSeq"]));
|
||||
const signer = optionValue(args, ["--signer"]);
|
||||
const issuedAt = optionValue(args, ["--issued-at", "--issuedAt"]);
|
||||
const effectiveScope = optionValue(args, ["--effective-scope", "--effectiveScope"]);
|
||||
const supersedes = splitList(optionValues(args, ["--supersedes"]));
|
||||
const supersededBy = splitList(optionValues(args, ["--superseded-by", "--supersededBy"]));
|
||||
if (docNo !== undefined) payload.docNo = docNo;
|
||||
if (docType !== undefined) payload.docType = docType;
|
||||
if (docPriority !== undefined) payload.docPriority = docPriority;
|
||||
if (docYear !== undefined) payload.docYear = docYear;
|
||||
if (docSeq !== undefined) payload.docSeq = docSeq;
|
||||
if (signer !== undefined) payload.signer = signer;
|
||||
if (issuedAt !== undefined) payload.issuedAt = issuedAt;
|
||||
if (effectiveScope !== undefined) payload.effectiveScope = effectiveScope;
|
||||
if (supersedes.length > 0) payload.supersedes = supersedes;
|
||||
if (supersededBy.length > 0) payload.supersededBy = supersededBy;
|
||||
}
|
||||
|
||||
function splitList(values: string[]): string[] {
|
||||
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
@@ -140,6 +205,7 @@ function uploadMeeting(args: string[]): unknown {
|
||||
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
||||
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
||||
};
|
||||
addDocumentPayloadFields(payload, args);
|
||||
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
|
||||
const body = type === "meeting" ? payload : { ...payload, body: markdown };
|
||||
return { file: absolutePath, result: unwrapProxyResponse(decisionProxy(endpoint, { method: "POST", body })) };
|
||||
@@ -165,6 +231,7 @@ async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?:
|
||||
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
||||
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
||||
};
|
||||
addDocumentPayloadFields(payload, args);
|
||||
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
|
||||
const body = type === "meeting" ? payload : { ...payload, body: markdown };
|
||||
return { file: absolutePath, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, endpoint, { method: "POST", body })) };
|
||||
@@ -249,6 +316,14 @@ function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}
|
||||
const tag = optionValue(args, ["--tag", "--tags"]);
|
||||
const queryText = optionValue(args, ["--query", "--q"]);
|
||||
const limit = optionValue(args, ["--limit"]);
|
||||
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
|
||||
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
|
||||
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
|
||||
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
|
||||
if (docNo !== undefined) params.set("docNo", docNo);
|
||||
if (docType !== undefined) params.set("docType", docType);
|
||||
if (docPriority !== undefined) params.set("docPriority", docPriority);
|
||||
if (docYear !== undefined) params.set("docYear", String(docYear));
|
||||
if (type !== undefined) params.set("type", options.requirementOnly === true ? parseRequirementType(type, "goal") : parseType(type, "meeting"));
|
||||
if (status !== undefined) params.set("status", parseStatus(status, "active"));
|
||||
if (level !== undefined) params.set("level", parseLevel(level, "none"));
|
||||
@@ -419,6 +494,7 @@ function requirementPayload(args: string[], command: string, options: { partial?
|
||||
if (issueId !== undefined) payload.issueId = issueId;
|
||||
if (taskId !== undefined) payload.taskId = taskId;
|
||||
if (commitId !== undefined) payload.commitId = commitId;
|
||||
addDocumentPayloadFields(payload, args);
|
||||
if (Object.keys(payload).length === 0) throw new Error(`${command} requires at least one field to write`);
|
||||
if (options.partial !== true && title === undefined && body === undefined) throw new Error(`${command} requires --title or --body-file/--body`);
|
||||
return payload;
|
||||
@@ -441,22 +517,22 @@ async function upsertRequirementAsync(args: string[], fetcher: (path: string, in
|
||||
}
|
||||
|
||||
function showRequirement(id: string | undefined): unknown {
|
||||
if (!id) throw new Error("decision requirement show requires record id");
|
||||
if (!id) throw new Error("decision requirement show requires record id or docNo");
|
||||
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
async function showRequirementAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
if (!id) throw new Error("decision requirement show requires record id");
|
||||
if (!id) throw new Error("decision requirement show requires record id or docNo");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
function updateRequirement(id: string | undefined, args: string[]): unknown {
|
||||
if (!id) throw new Error("decision requirement update requires record id");
|
||||
if (!id) throw new Error("decision requirement update requires record id or docNo");
|
||||
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
||||
}
|
||||
|
||||
async function updateRequirementAsync(id: string | undefined, args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
if (!id) throw new Error("decision requirement update requires record id");
|
||||
if (!id) throw new Error("decision requirement update requires record id or docNo");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
||||
}
|
||||
|
||||
@@ -489,7 +565,7 @@ export async function runDecisionCenterCommand(_config: UniDeskConfig, args: str
|
||||
if (action === "list") return listRecords(args.slice(1));
|
||||
if (action === "show") return showRecord(id);
|
||||
if (action === "health") return unwrapProxyResponse(coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
|
||||
throw new Error("decision command must be one of: upload, list, show, health");
|
||||
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary");
|
||||
}
|
||||
|
||||
export async function runDecisionCenterCommandAsync(
|
||||
@@ -525,5 +601,5 @@ export async function runDecisionCenterCommandAsync(
|
||||
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
|
||||
if (action === "show") return showRecordAsync(id, fetcher);
|
||||
if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
|
||||
throw new Error("decision command must be one of: upload, list, show, health");
|
||||
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary");
|
||||
}
|
||||
|
||||
+8
-8
@@ -29,7 +29,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
|
||||
{ command: "microservice diagnostics <id>", description: "Split k3sctl-managed proxy health into provider-gateway, HTTP tunnel, adapter, Kubernetes API service proxy, and target Service checks." },
|
||||
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
|
||||
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--source text] [--issue id]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
|
||||
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--doc-no DC-...] [--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
|
||||
{ command: "decision diary import <markdown-file> [--source-file path] [--tag tag] [--include-entries]", description: "Import a dated work log Markdown into PostgreSQL diary entries split as YYYY-MM/YYYY-MM-DD.md." },
|
||||
{ command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." },
|
||||
{ command: "decision diary history [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "Read diary history through the productized history API alias." },
|
||||
@@ -37,9 +37,9 @@ export function rootHelp(): unknown {
|
||||
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
|
||||
{ command: "decision diary show <YYYY-MM-DD|id> [--source-file path]", description: "Show one daily diary Markdown entry; source-file disambiguates same-day entries from multiple imports." },
|
||||
{ command: "decision diary edit|upsert <YYYY-MM-DD|id> --body-file path [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy." },
|
||||
{ command: "decision list [--type ...] [--status ...] [--level|--priority ...] [--source text] [--issue id] [--linked-goal-id id] [--limit N] [--include-body]", description: "List Decision Center records through the user-service proxy; bodies are omitted unless --include-body is set." },
|
||||
{ command: "decision requirement list|create|show|update|upsert [id] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--source text] [--issue id]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
|
||||
{ command: "decision show <id>", description: "Show one Decision Center record." },
|
||||
{ command: "decision list [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--year YYYY] [--type ...] [--status ...] [--level|--priority ...] [--limit N] [--include-body]", description: "List Decision Center records through the user-service proxy; bodies are omitted unless --include-body is set." },
|
||||
{ command: "decision requirement list|create|show|update|upsert [id|docNo] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
|
||||
{ command: "decision show <id|docNo>", description: "Show one Decision Center record." },
|
||||
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
|
||||
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
|
||||
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
|
||||
@@ -162,12 +162,12 @@ function decisionHelp(): unknown {
|
||||
command: "decision upload|list|show|health|diary|requirement",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision]",
|
||||
"bun scripts/cli.ts decision list [--type ...] [--status ...] [--level ...] [--limit N]",
|
||||
"bun scripts/cli.ts decision show <id>",
|
||||
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision] [--doc-no DC-...]",
|
||||
"bun scripts/cli.ts decision list [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--year YYYY] [--limit N]",
|
||||
"bun scripts/cli.ts decision show <id|docNo>",
|
||||
"bun scripts/cli.ts decision health",
|
||||
"bun scripts/cli.ts decision diary import|list|history|months|today|show|edit|upsert ...",
|
||||
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ...",
|
||||
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ... [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--signer text] [--issued-at ISO]",
|
||||
],
|
||||
description: "Operate Decision Center through the registered user-service proxy.",
|
||||
};
|
||||
|
||||
@@ -758,11 +758,13 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
: {
|
||||
method: init.method,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
};
|
||||
};
|
||||
return frontendJson(session, path, requestInit, 30_000);
|
||||
};
|
||||
emitRemoteJson(name, await runDecisionCenterCommandAsync(config, args.slice(1), fetcher));
|
||||
return 0;
|
||||
const result = await runDecisionCenterCommandAsync(config, args.slice(1), fetcher);
|
||||
const ok = typeof result !== "object" || result === null || !("ok" in result) || (result as { ok?: unknown }).ok !== false;
|
||||
emitRemoteJson(name, result, ok);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
if (top === "codex") {
|
||||
emitRemoteJson(name, await remoteCodeQueue(session, args));
|
||||
|
||||
Reference in New Issue
Block a user