diff --git a/dev/plans/README.md b/dev/plans/README.md index f340a67..baf96ac 100644 --- a/dev/plans/README.md +++ b/dev/plans/README.md @@ -80,4 +80,9 @@ Completed plans are **removed from the tree** after land. Use merged PRs and `gi --- -**New plans:** `YYMMDD-short-slug.md` in this directory — reconcile here before adding. Todo-like backlog spikes live in Linear; this directory is for approved implementation plans and handoffs. **When done:** remove the plan file and update this README shipped table; do not keep an `archive/` copy. +**New plans:** manual planning-workflow plans use `YYMMDD-short-slug.md`; the +Linear Spec operation uses the exact issue key, such as `FER-273.md`. Reconcile +either kind here before adding it. Todo-like backlog spikes live in Linear; this +directory is for approved implementation plans and handoffs. **When done:** +remove the plan file and update this README shipped table; do not keep an +`archive/` copy. diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 2049a69..b7d2737 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -128,12 +128,14 @@ real consumers expose the same stable contract. | ------------------------ | --------------------------------------------------------------------------------------------------------------- | | `bin/` | CLI entrypoint, generated help, and the persistent Linear worker command | | `lib/agent/` | Provider-neutral Agent contract, invocation support, structured output, streams, sessions, and workspace guards | +| `lib/work-item/` | Source-neutral normalized work-item, evidence, and portable-path schemas shared by proven operations | | `providers/` | Cursor and Codex invocation, auth, streaming, sessions, sandboxing, and provider result translation | | `lib/config/` | `harness.json` composition, workspace resolution, local initialization, and review option resolution | | `lib/review/` | Review runtime, prompt context, aggregation, events, schemas, artifacts, handoffs, and run management | | `workflows/` | Callable provider-neutral change-review and plan-review definitions | | `lib/linear/` | Standalone, JSON-safe Linear read, write, pagination, and webhook primitives without domain or delivery policy | | `lib/triage/` | Triage prompt, structured decision schema, and provider-independent operation | +| `lib/spec/` | Spec prompt, structured result schema, issue-key artifact validation, and provider-independent operation | | `lib/linear-automation/` | Linear readiness policy, application event contracts, Inngest functions, worker config, and process hosting | | `lib/skills/` | Packaged-skill installation support | | `skills/` | Packaged skills installed into target repositories | @@ -161,8 +163,10 @@ durable summaries, metadata, stream records, and incomplete-run cleanup. Domain operations accept plain serializable input and return validated structured output. They own policy and prompt rendering, but they know nothing -about Inngest scheduling. The triage operation follows this boundary and can be -tested with a fake agent without starting a worker. +about Inngest scheduling. Triage is read-only. Spec receives an isolated +writable workspace, writes one issue-keyed plan, and validates the claimed +artifact without publishing it. Both operations can be tested with a fake agent +without starting a worker. ### Service boundary @@ -200,6 +204,7 @@ check the runtime schema, exported schema, prompt, and consumer together. | ------------------------------------------------ | ------------------------------------------------------------------------------------- | | `.harness/runs/reviews//` | Review context, prompts, structured results, streams, summaries, metadata, and events | | `.harness/bin/harness` | Ignored target-repo shim pointing to the Harness checkout that initialized it | +| `dev/plans/.md` | Tracked Spec artifact written in an isolated workspace; publication remains separate | | Self-hosted Inngest SQLite volume | Local delivery history, retry state, function metadata, and traces | | Protected worker environment file outside a repo | Linear, Inngest, and optional Codex credentials for one deployment | | Dedicated worker Codex credential volume | Optional unattended ChatGPT-backed Codex login, separate from the host account | diff --git a/lib/spec/prompt.test.ts b/lib/spec/prompt.test.ts new file mode 100644 index 0000000..17a37c5 --- /dev/null +++ b/lib/spec/prompt.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { renderSpecPrompt, SPEC_POLICY_VERSION } from "./prompt.ts"; +import type { SpecWorkItemContext } from "./schema.ts"; + +describe("Spec prompt", () => { + it("renders deterministic complete context and the exact issue-key artifact path", () => { + const workItem = validContext(); + const artifactPath = "dev/plans/FER-273.md"; + const prompt = renderSpecPrompt({ workItem, artifactPath }); + + expect(SPEC_POLICY_VERSION).toBe("2"); + expect(prompt).toContain(JSON.stringify(workItem, null, 2)); + expect(prompt).toContain(`Write the complete Spec at exactly ${artifactPath}`); + expect(prompt).toContain(`artifactPath must be exactly "${artifactPath}"`); + expect(prompt).toContain("Do not use a date or title slug."); + expect(renderSpecPrompt({ workItem, artifactPath })).toBe(prompt); + }); + + it("grounds and reconciles the Spec in authority order before structuring it", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("Read repository guidance such as AGENTS.md and README.md"); + expect(prompt).toContain("authoritative project intent or vision source"); + expect(prompt).toContain( + "repository invariants and current project intent; explicit requirements and accepted decisions; verified codebase facts", + ); + expect(prompt).toContain("Separate current behavior from requested behavior"); + expect(prompt).toContain("Research first"); + expect(prompt).toContain("Prefer one coherent recommended direction"); + expect(prompt).toContain("Mention a verified skill or aid beside a concrete change only"); + expect(prompt).toContain("A ready Spec requires repository evidence"); + }); + + it("separates reviewer decisions from prerequisite human input", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("Reserve Needs Input for a true prerequisite"); + expect(prompt).toContain("missing decision materially changes scope or architecture"); + expect(prompt).toContain("A later approval or human-authority product choice"); + expect(prompt).toContain("do not leave raw alternatives or unresolved implementation choices"); + expect(prompt).toContain("at least two unique options with tradeoffs"); + expect(prompt).toContain("recommendation that exactly matches an option"); + expect(prompt).toContain("must not be asked to resolve a reviewer decision"); + expect(prompt).toContain("reviewerDecisions must be []"); + }); + + it("keeps the artifact decision-focused, right-sized, and portable", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("Right-size the artifact"); + expect(prompt).toContain("Capture decisions, not code"); + expect(prompt).toContain("Do not pre-write implementation code or shell-command choreography"); + expect(prompt).toContain("label it as directional rather than an implementation specification"); + expect(prompt).toContain( + "Do not embed provider-specific or tool-specific executor instructions", + ); + expect(prompt).toContain("Explicitly defer ordinary execution-time discovery"); + }); + + it("shapes multi-unit work vertically and explains necessary horizontal units", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("Prefer vertical slices"); + expect(prompt).toContain("separate agents can own with limited overlap"); + expect(prompt).toContain("Do not divide work mechanically by repository layer"); + expect(prompt).toContain("vertical delivery is impractical or unsafe"); + }); + + it("selects the highest stable proof seam", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("highest existing stable test seam that proves acceptance"); + expect(prompt).toContain("distinct invariant or failure mode"); + expect(prompt).toContain("repository's canonical gate"); + }); + + it("keeps the agent inside the operation boundary", () => { + const prompt = renderSpecPrompt({ + workItem: validContext(), + artifactPath: "dev/plans/FER-273.md", + }); + + expect(prompt).toContain("Do not edit product code."); + expect(prompt).toContain("Do not create branches, commits, or pull requests."); + expect(prompt).toContain( + "Reconcile dev/plans/README.md only when repository guidance explicitly requires it.", + ); + expect(prompt).toContain("Return only the final JSON object matching the supplied schema."); + }); + + it("rejects incomplete work-item context before rendering", () => { + const context = validContext(); + const invalid = { ...context, completeness: { ...context.completeness } } as Record< + string, + unknown + >; + delete (invalid.completeness as Record).relationsTruncated; + + expect(() => + renderSpecPrompt({ + workItem: invalid as SpecWorkItemContext, + artifactPath: "dev/plans/FER-273.md", + }), + ).toThrow(/relationsTruncated/); + }); +}); + +function validContext(): SpecWorkItemContext { + return { + id: "issue-273", + reference: "FER-273", + title: "Build a provider-neutral Spec operation", + description: "Write one code-grounded implementation Spec.", + url: "https://linear.app/issue/FER-273", + state: "Open", + labels: ["Spec"], + comments: [ + { + author: "Felipe", + body: "Use the Linear identifier as the filename.", + createdAt: "2026-07-22T20:00:00.000Z", + }, + ], + parent: { + id: "issue-211", + reference: "FER-211", + title: "Build modular Linear automation", + url: "https://linear.app/issue/FER-211", + state: "In Progress", + }, + children: [], + duplicateOf: null, + blockedBy: [], + related: [ + { + id: "issue-280", + reference: "FER-280", + title: "Decompose oversized modules", + url: "https://linear.app/issue/FER-280", + state: "Done", + }, + ], + links: [{ title: "Design reference", url: "https://example.com/design" }], + createdAt: "2026-07-21T22:12:57.641Z", + updatedAt: "2026-07-22T20:00:00.000Z", + completeness: { + commentsTruncated: true, + labelsTruncated: false, + relationsTruncated: false, + linksTruncated: false, + childrenTruncated: false, + }, + }; +} diff --git a/lib/spec/prompt.ts b/lib/spec/prompt.ts new file mode 100644 index 0000000..6e53a93 --- /dev/null +++ b/lib/spec/prompt.ts @@ -0,0 +1,84 @@ +import { SpecWorkItemContextSchema, type SpecWorkItemContext } from "./schema.ts"; + +export const SPEC_POLICY_VERSION = "2"; + +export function renderSpecPrompt(input: { + workItem: SpecWorkItemContext; + artifactPath: string; +}): string { + const workItem = SpecWorkItemContextSchema.parse(input.workItem); + + return `# Implementation Spec + +You are the Spec agent for one bounded work item in the current repository. Research the repository and either produce one minimum-sufficient implementation Spec for human review or identify a prerequisite human answer that blocks every useful Spec. + +The work item has already been routed to Spec. Do not re-triage its Linear status, labels, or lifecycle. + +Apply this policy in order: + +1. Ground the work in current authority. + - Read repository guidance such as AGENTS.md and README.md, then follow links to the authoritative project intent or vision source. + - Use this authority order: repository invariants and current project intent; explicit requirements and accepted decisions; verified codebase facts. + - Treat old plans, archived roadmaps, and unmarked proposals as context only. When accepted work intentionally changes current intent, name that direction change and its review boundary instead of silently overriding either source. + - Inspect only the relevant code, callers, contracts, tests, docs, active plans, work-item discussion, and institutional guidance before choosing a direction. Verify external contracts or guidance when they materially affect the plan. + +2. Reconcile requirements with reality before structuring the Spec. + - Separate current behavior from requested behavior. Resolve stale claims, already-implemented baseline, conflicts, real gaps, acceptance criteria, and the smallest credible solution. + - Repository inspection, diagnosis, technical research, option discovery, and migration design are agent work. Research first; do not organize an issue summary into steps before verifying it. + - Reject speculative hardening, future frameworks, unrelated cleanup, and compatibility that no current authority requires. + - Inspect available executor-aid descriptions and repository guidance. Mention a verified skill or aid beside a concrete change only when it adds non-obvious execution guidance; do not add a generic skills inventory. + +3. Resolve planning decisions. Reserve Needs Input for a true prerequisite. + - Resolve planning-time implementation choices with repository and research evidence. Prefer one coherent recommended direction; do not leave raw alternatives or unresolved implementation choices for the executor. + - Explicitly defer ordinary execution-time discovery that does not change scope or architecture, such as locating nearby details within a named owner. Do not turn inspectable repository basics into plan steps. + - Use outcome "needs-input" before writing when a missing decision materially changes scope or architecture and no coherent useful Spec can be produced until a human supplies missing or contradictory intent, credentials, inaccessible required evidence, or an external fact. + - A later approval or human-authority product choice is not prerequisite input when research can produce concrete options, tradeoffs, and a recommendation without invalidating the rest of the Spec. Record that as a reviewer decision instead. + - Ask only the smallest concrete questions that unblock useful Spec work. + +4. Design the smallest coherent change. + - Choose the smallest change that satisfies the acceptance criteria. Right-size the artifact: simple work gets a compact Spec; larger or riskier work gets only the extra structure its decisions require. + - Capture decisions, not code: state the approach, boundaries, exact files or symbols, ownership, dependencies, material risks, and test scenarios. + - Do not pre-write implementation code or shell-command choreography. Use a short pseudo-code sketch or grammar only when it helps review the direction, and label it as directional rather than an implementation specification. + - Keep the Spec portable as a living plan, review artifact, or issue body. Do not embed provider-specific or tool-specific executor instructions. + - When replacing, redirecting, splitting, or removing behavior, name its post-change owner, removals, cutover order, and required compatibility where those decisions matter. + +5. Shape multi-unit work as independently useful delivery. + - Prefer vertical slices where each unit completes one coherent observable behavior across the boundaries it needs and can be verified when it lands. + - Prefer units that separate agents can own with limited overlap and that can be reviewed, landed, rolled back, or continued independently. Keep shared setup to the minimum required by the first slice so later units can proceed in parallel where practical. + - Do not divide work mechanically by repository layer or component type. + - Keep an indivisible migration, cross-cutting safety fix, or minimum shared prerequisite horizontal only when vertical delivery is impractical or unsafe. State the reason and keep that unit no broader than necessary. + +6. Choose focused proof. + - Prefer the highest existing stable test seam that proves acceptance. Add a lower seam only for a distinct invariant or failure mode that the higher seam cannot observe. + - Keep verification to focused behavioral checks and the repository's canonical gate. Do not duplicate covered commands or prescribe unverified command names. + +7. Write the artifact when ready for review. + - Write the complete Spec at exactly ${input.artifactPath}. + - Do not choose another filename. Do not use a date or title slug. + - Use the repository's required plan shape. Otherwise use concise Goal, Changes, Verify, and optional Boundaries sections. + - The Spec must establish the intended outcome and acceptance criteria, relevant intent constraints, verified current-state facts, resolved decisions, named ownership, material boundaries, executor aids where useful, and focused verification. + - Do not edit product code. Do not create branches, commits, or pull requests. + - Reconcile dev/plans/README.md only when repository guidance explicitly requires it. Do not edit any other file. + +8. Make reviewer decisions useful. + - Reviewer decisions are allowed in a ready Spec when human authority is genuinely required after research. + - Each decision must contain one concrete question, at least two unique options with tradeoffs, one recommendation that exactly matches an option, and evidence-backed rationale. + - Keep reviewerDecisions empty when repository authority and accepted requirements already determine the answer. The executor must not be asked to resolve a reviewer decision during implementation. + +Structured-result rules: + +- Return only the final JSON object matching the supplied schema. Do not include the Spec markdown in JSON. +- For "ready-for-review": artifactPath must be exactly "${input.artifactPath}", questions must be [], and the claimed artifact must already exist. +- For "needs-input": artifactPath must be null, reviewerDecisions must be [], and questions must contain the prerequisite questions. +- summary is a concise description of the artifact for ready-for-review, or the evidence-backed blocking rationale for needs-input. +- Include at least one evidence item. A ready Spec requires repository evidence, not only tracker evidence. +- Tracker evidence uses path null. Code, docs, and test evidence uses a portable repository-relative path. Repo-state evidence may use path null. +- Do not invent facts hidden by truncated context or inaccessible systems. + +Work-item context: + +\`\`\`json +${JSON.stringify(workItem, null, 2)} +\`\`\` +`; +} diff --git a/lib/spec/schema.test.ts b/lib/spec/schema.test.ts new file mode 100644 index 0000000..4340e24 --- /dev/null +++ b/lib/spec/schema.test.ts @@ -0,0 +1,242 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { assertCodexStrictSchema, loadSchema, schemaAccepts } from "../agent/json-schema.ts"; +import { + SPEC_RESULT_SCHEMA_VERSION, + SpecDecisionSchema, + SpecReviewerDecisionSchema, + SpecWorkItemContextSchema, +} from "./schema.ts"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const JSON_SCHEMA_PATH = join(REPO_ROOT, "schemas/spec-result.schema.json"); +const JSON_SCHEMA = loadSchema({ schemaPath: JSON_SCHEMA_PATH })!; + +const REPOSITORY_EVIDENCE = { + kind: "code", + path: "lib/agent/contract.ts", + summary: "The shared Agent contract owns provider-neutral execution.", +}; + +const REVIEWER_DECISION = { + question: "Which compatibility boundary should the migration preserve?", + options: [ + { option: "One-release shim", tradeoffs: "Safer rollout with temporary maintenance." }, + { option: "Atomic cutover", tradeoffs: "Smaller code change with a coordinated release." }, + ], + recommendation: "Atomic cutover", + rationale: "The repository has no external compatibility promise.", +}; + +const READY_FOR_REVIEW = { + outcome: "ready-for-review", + artifactPath: "dev/plans/FER-273.md", + summary: "The Spec recommends a small provider-neutral operation.", + evidence: [REPOSITORY_EVIDENCE], + reviewerDecisions: [], + questions: [], +}; + +const NEEDS_INPUT = { + outcome: "needs-input", + artifactPath: null, + summary: "Two authoritative intent documents conflict on the required outcome.", + evidence: [ + { kind: "docs", path: "docs/project-intent.md", summary: "The intent requires one owner." }, + ], + reviewerDecisions: [], + questions: ["Which intent source supersedes the other?"], +}; + +describe("Spec decision schema", () => { + it.each([ + ["ready for review", READY_FOR_REVIEW], + [ + "ready with a reviewer decision", + { ...READY_FOR_REVIEW, reviewerDecisions: [REVIEWER_DECISION] }, + ], + ["needs input", NEEDS_INPUT], + ])("accepts a valid %s result", (_name, payload) => { + expect(SpecDecisionSchema.safeParse(payload).success).toBe(true); + }); + + it.each([ + ["null artifact", { ...READY_FOR_REVIEW, artifactPath: null }], + ["questions", { ...READY_FOR_REVIEW, questions: ["Choose an option?"] }], + [ + "tracker-only evidence", + { + ...READY_FOR_REVIEW, + evidence: [{ kind: "tracker", path: null, summary: "The issue requests a Spec." }], + }, + ], + ])("rejects ready-for-review with %s", (_name, payload) => { + expect(SpecDecisionSchema.safeParse(payload).success).toBe(false); + }); + + it.each([ + ["an artifact", { ...NEEDS_INPUT, artifactPath: "dev/plans/FER-273.md" }], + ["no questions", { ...NEEDS_INPUT, questions: [] }], + ["reviewer decisions", { ...NEEDS_INPUT, reviewerDecisions: [REVIEWER_DECISION] }], + ])("rejects needs-input with %s", (_name, payload) => { + expect(SpecDecisionSchema.safeParse(payload).success).toBe(false); + }); + + it.each([ + ["fewer than two options", { ...REVIEWER_DECISION, options: [REVIEWER_DECISION.options[0]] }], + [ + "duplicate options", + { + ...REVIEWER_DECISION, + options: [REVIEWER_DECISION.options[0], REVIEWER_DECISION.options[0]], + }, + ], + ["an unmatched recommendation", { ...REVIEWER_DECISION, recommendation: "Another option" }], + ])("rejects a reviewer decision with %s", (_name, payload) => { + expect(SpecReviewerDecisionSchema.safeParse(payload).success).toBe(false); + }); + + it.each([ + [ + "code without a path", + { ...READY_FOR_REVIEW, evidence: [{ ...REPOSITORY_EVIDENCE, path: null }] }, + ], + [ + "tracker evidence with a path", + { + ...READY_FOR_REVIEW, + evidence: [{ kind: "tracker", path: "FER-273", summary: "Issue evidence." }], + }, + ], + [ + "a parent traversal", + { ...READY_FOR_REVIEW, evidence: [{ ...REPOSITORY_EVIDENCE, path: "../outside.ts" }] }, + ], + ])("rejects invalid evidence: %s", (_name, payload) => { + expect(SpecDecisionSchema.safeParse(payload).success).toBe(false); + }); +}); + +describe("Spec work-item context schema", () => { + it("accepts complete normalized issue context", () => { + expect(SpecWorkItemContextSchema.safeParse(validContext()).success).toBe(true); + }); + + it("requires an uppercase issue reference", () => { + expect( + SpecWorkItemContextSchema.safeParse({ ...validContext(), reference: "fer-273" }).success, + ).toBe(false); + }); + + it("rejects incomplete or SDK-shaped context", () => { + const context = validContext(); + const { linksTruncated: _, ...incomplete } = context.completeness; + + expect( + SpecWorkItemContextSchema.safeParse({ ...context, completeness: incomplete }).success, + ).toBe(false); + expect( + SpecWorkItemContextSchema.safeParse({ ...context, team: { id: "team-fer" } }).success, + ).toBe(false); + }); +}); + +describe("exported Spec result JSON schema", () => { + it("is strict and defines every required provider field", () => { + expect(SPEC_RESULT_SCHEMA_VERSION).toBe("1"); + expect(() => assertCodexStrictSchema(JSON_SCHEMA)).not.toThrow(); + expect(JSON_SCHEMA.additionalProperties).toBe(false); + expect(JSON_SCHEMA.required).toEqual([ + "outcome", + "artifactPath", + "summary", + "evidence", + "reviewerDecisions", + "questions", + ]); + }); + + it.each([ + ["ready for review", READY_FOR_REVIEW], + [ + "ready with reviewer decisions", + { ...READY_FOR_REVIEW, reviewerDecisions: [REVIEWER_DECISION] }, + ], + ["needs input", NEEDS_INPUT], + ])("stays structurally aligned with Zod for %s", (_name, payload) => { + expect(schemaAccepts(JSON_SCHEMA, payload)).toBe(true); + expect(SpecDecisionSchema.safeParse(payload).success).toBe(true); + }); + + it.each([ + ["an invalid outcome", { ...READY_FOR_REVIEW, outcome: "draft-ready" }], + ["empty evidence", { ...READY_FOR_REVIEW, evidence: [] }], + ["an extra property", { ...READY_FOR_REVIEW, confidence: "high" }], + ["an omitted nullable field", omit(NEEDS_INPUT, "artifactPath")], + [ + "an incomplete reviewer option", + { + ...READY_FOR_REVIEW, + reviewerDecisions: [ + { + ...REVIEWER_DECISION, + options: [ + omit(REVIEWER_DECISION.options[0], "tradeoffs"), + REVIEWER_DECISION.options[1], + ], + }, + ], + }, + ], + ])("rejects %s in both schemas", (_name, payload) => { + expect(schemaAccepts(JSON_SCHEMA, payload)).toBe(false); + expect(SpecDecisionSchema.safeParse(payload).success).toBe(false); + }); + + it("leaves cross-field policy validation to Zod", () => { + const payload = { ...READY_FOR_REVIEW, artifactPath: null }; + + expect(schemaAccepts(JSON_SCHEMA, payload)).toBe(true); + expect(SpecDecisionSchema.safeParse(payload).success).toBe(false); + }); +}); + +function validContext() { + return { + id: "issue-273", + reference: "FER-273", + title: "Build a provider-neutral Spec operation", + description: "Write one code-grounded implementation Spec.", + url: "https://linear.app/issue/FER-273", + state: "In Progress", + labels: ["Spec"], + comments: [ + { + author: "Felipe", + body: "Use dev/plans/FER-273.md.", + createdAt: "2026-07-22T20:00:00.000Z", + }, + ], + parent: null, + children: [], + duplicateOf: null, + blockedBy: [], + related: [], + links: [{ title: "Architecture", url: "https://example.com/architecture" }], + createdAt: "2026-07-21T22:12:57.641Z", + updatedAt: "2026-07-22T20:00:00.000Z", + completeness: { + commentsTruncated: false, + labelsTruncated: false, + relationsTruncated: false, + linksTruncated: false, + childrenTruncated: false, + }, + }; +} + +function omit(value: T, key: K): Omit { + const { [key]: _, ...rest } = value; + return rest; +} diff --git a/lib/spec/schema.ts b/lib/spec/schema.ts new file mode 100644 index 0000000..3aab641 --- /dev/null +++ b/lib/spec/schema.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { + createWorkItemContextSchemas, + PortableRelativePathSchema, + WorkItemCommentSchema, + WorkItemEvidenceSchema, + WorkItemLinkSchema, +} from "../work-item/schema.ts"; + +export const SPEC_RESULT_SCHEMA_VERSION = "1"; + +const NonEmptyStringSchema = z.string().min(1); + +export const SpecIssueReferenceSchema = NonEmptyStringSchema.regex( + /^[A-Z][A-Z0-9]*-\d+$/, + "must be an uppercase issue reference such as FER-273", +); + +const SpecWorkItemSchemas = createWorkItemContextSchemas(SpecIssueReferenceSchema); + +export const SpecWorkItemReferenceSchema = SpecWorkItemSchemas.reference; +export const SpecCommentSchema = WorkItemCommentSchema; +export const SpecLinkSchema = WorkItemLinkSchema; +export const SpecWorkItemContextSchema = SpecWorkItemSchemas.context; +export const SpecEvidenceSchema = WorkItemEvidenceSchema; + +export const SpecReviewerOptionSchema = z + .object({ + option: NonEmptyStringSchema, + tradeoffs: NonEmptyStringSchema, + }) + .strict(); + +export const SpecReviewerDecisionSchema = z + .object({ + question: NonEmptyStringSchema, + options: z.array(SpecReviewerOptionSchema).min(2), + recommendation: NonEmptyStringSchema, + rationale: NonEmptyStringSchema, + }) + .strict() + .superRefine((decision, ctx) => { + const optionNames = decision.options.map((option) => option.option); + if (new Set(optionNames).size !== optionNames.length) { + ctx.addIssue({ + code: "custom", + path: ["options"], + message: "reviewer decision options must be unique", + }); + } + if (!optionNames.includes(decision.recommendation)) { + ctx.addIssue({ + code: "custom", + path: ["recommendation"], + message: "recommendation must exactly match one option", + }); + } + }); + +const SpecDecisionSharedShape = { + summary: NonEmptyStringSchema, + evidence: z.array(SpecEvidenceSchema).min(1), +}; + +const ReadyForReviewDecisionSchema = z + .object({ + outcome: z.literal("ready-for-review"), + artifactPath: PortableRelativePathSchema, + ...SpecDecisionSharedShape, + reviewerDecisions: z.array(SpecReviewerDecisionSchema), + questions: z.array(NonEmptyStringSchema).length(0), + }) + .strict() + .superRefine((decision, ctx) => { + if (decision.evidence.every((evidence) => evidence.kind === "tracker")) { + ctx.addIssue({ + code: "custom", + path: ["evidence"], + message: "ready-for-review requires repository evidence", + }); + } + }); + +const NeedsInputDecisionSchema = z + .object({ + outcome: z.literal("needs-input"), + artifactPath: z.null(), + ...SpecDecisionSharedShape, + reviewerDecisions: z.array(SpecReviewerDecisionSchema).length(0), + questions: z.array(NonEmptyStringSchema).min(1), + }) + .strict(); + +export const SpecDecisionSchema = z.discriminatedUnion("outcome", [ + ReadyForReviewDecisionSchema, + NeedsInputDecisionSchema, +]); + +export type SpecWorkItemReference = z.infer; +export type SpecComment = z.infer; +export type SpecLink = z.infer; +export type SpecWorkItemContext = z.infer; +export type SpecEvidence = z.infer; +export type SpecReviewerOption = z.infer; +export type SpecReviewerDecision = z.infer; +export type SpecDecision = z.infer; diff --git a/lib/spec/spec.test.ts b/lib/spec/spec.test.ts new file mode 100644 index 0000000..e47e6f4 --- /dev/null +++ b/lib/spec/spec.test.ts @@ -0,0 +1,417 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Agent, AgentProviderName, AgentRunInput, AgentRunResult } from "../agent/contract.ts"; +import { renderSpecPrompt, SPEC_POLICY_VERSION } from "./prompt.ts"; +import { + SPEC_RESULT_SCHEMA_VERSION, + type SpecDecision, + type SpecWorkItemContext, +} from "./schema.ts"; +import { + SPEC_RESULT_SCHEMA_PATH, + specArtifactPath, + specIssue, + type SpecIssueFailureKind, +} from "./spec.ts"; + +const READY_FOR_REVIEW = { + outcome: "ready-for-review", + artifactPath: "dev/plans/FER-273.md", + summary: "The Spec defines the provider-neutral operation.", + evidence: [ + { + kind: "code", + path: "lib/agent/contract.ts", + summary: "The Agent interface is the provider boundary.", + }, + ], + reviewerDecisions: [], + questions: [], +} satisfies SpecDecision; + +const NEEDS_INPUT = { + outcome: "needs-input", + artifactPath: null, + summary: "Two authoritative intent sources conflict.", + evidence: [ + { + kind: "docs", + path: "docs/project-intent.md", + summary: "The current source assigns one owner.", + }, + ], + reviewerDecisions: [], + questions: ["Which intent source supersedes the other?"], +} satisfies SpecDecision; + +const temporaryPaths: string[] = []; + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("specIssue", () => { + it("runs the exact prompt through the shared writable Agent boundary", async () => { + const workspace = createWorkspace(); + const signal = new AbortController().signal; + const fake = fakeAgent( + { + ok: true, + structuredOutput: READY_FOR_REVIEW, + raw: {}, + session: { + provider: "codex", + id: "thread-273", + raw: { adapterField: "must-not-leak" }, + }, + }, + "codex", + () => writeArtifact(workspace, "# FER-273 Spec\n"), + ); + + const result = await specIssue({ + workItem: validWorkItem(), + agent: fake.agent, + workspace, + execution: { + model: "gpt-5.6-sol", + modelReasoningEffort: "xhigh", + maxRuntimeMs: 120_000, + logPath: "/logs/spec.jsonl", + signal, + }, + }); + + expect(fake.inputs).toHaveLength(1); + expect(fake.inputs[0]).toEqual({ + workspace, + prompt: renderSpecPrompt({ + workItem: validWorkItem(), + artifactPath: "dev/plans/FER-273.md", + }), + schemaPath: SPEC_RESULT_SCHEMA_PATH, + model: "gpt-5.6-sol", + modelReasoningEffort: "xhigh", + sandboxMode: "workspace-write", + approvalPolicy: "never", + workspaceGuard: "record", + maxRuntimeMs: 120_000, + logPath: "/logs/spec.jsonl", + signal, + }); + expect(result).toMatchObject({ + ok: true, + decision: READY_FOR_REVIEW, + provenance: { + provider: "codex", + model: "gpt-5.6-sol", + modelReasoningEffort: "xhigh", + policyVersion: SPEC_POLICY_VERSION, + resultSchemaVersion: SPEC_RESULT_SCHEMA_VERSION, + session: { provider: "codex", id: "thread-273" }, + }, + }); + expect(result.provenance.session).toEqual({ provider: "codex", id: "thread-273" }); + }); + + it.each(["codex", "cursor"] satisfies AgentProviderName[])( + "keeps %s execution behind the shared Agent interface", + async (provider) => { + const workspace = createWorkspace(); + const fake = fakeAgent( + { + ok: true, + structuredOutput: READY_FOR_REVIEW, + raw: {}, + session: { provider, id: `${provider}-session` }, + }, + provider, + () => writeArtifact(workspace, "# Spec\n"), + ); + + const result = await run(fake.agent, workspace); + + expect(result).toMatchObject({ + ok: true, + provenance: { provider, session: { provider, id: `${provider}-session` } }, + }); + }, + ); + + it("returns Needs Input without requiring an artifact", async () => { + const workspace = createWorkspace(); + const result = await run( + fakeAgent({ ok: true, structuredOutput: NEEDS_INPUT, raw: {} }).agent, + workspace, + ); + + expect(result).toEqual({ + ok: true, + decision: NEEDS_INPUT, + provenance: expect.objectContaining({ provider: "codex", session: null }), + }); + }); + + it("records deterministic prompt and schema hashes", async () => { + const workspace = createWorkspace(); + const fake = fakeAgent({ ok: true, structuredOutput: READY_FOR_REVIEW, raw: {} }, "codex", () => + writeArtifact(workspace, "# Spec\n"), + ); + + const result = await run(fake.agent, workspace); + + expect(result.provenance.promptSha256).toBe( + createHash("sha256") + .update( + renderSpecPrompt({ + workItem: validWorkItem(), + artifactPath: "dev/plans/FER-273.md", + }), + ) + .digest("hex"), + ); + expect(result.provenance.schemaSha256).toBe( + createHash("sha256").update(readFileSync(SPEC_RESULT_SCHEMA_PATH)).digest("hex"), + ); + }); + + it("returns invalid-output when provider output violates the result contract", async () => { + const workspace = createWorkspace(); + const fake = fakeAgent({ + ok: true, + structuredOutput: { ...READY_FOR_REVIEW, questions: ["Choose one?"] }, + raw: {}, + }); + + const result = await run(fake.agent, workspace); + + expect(result).toMatchObject({ + ok: false, + failureKind: "invalid-output", + error: expect.stringContaining("questions"), + }); + }); + + it.each([ + ["a missing file", () => undefined, READY_FOR_REVIEW, "no such file"], + [ + "an empty file", + (workspace: string) => writeArtifact(workspace, "\n"), + READY_FOR_REVIEW, + "is empty", + ], + [ + "a wrong claimed path", + (workspace: string) => writeArtifact(workspace, "# Spec\n"), + { ...READY_FOR_REVIEW, artifactPath: "dev/plans/other.md" }, + "expected dev/plans/FER-273.md", + ], + [ + "an out-of-workspace claim", + (workspace: string) => writeArtifact(workspace, "# Spec\n"), + { ...READY_FOR_REVIEW, artifactPath: "../FER-273.md" }, + "Invalid Spec structured output", + ], + ])("rejects %s", async (_name, prepare, decision, error) => { + const workspace = createWorkspace(); + prepare(workspace); + const result = await run( + fakeAgent({ ok: true, structuredOutput: decision, raw: {} }).agent, + workspace, + ); + + expect(result).toMatchObject({ ok: false, error: expect.stringContaining(error) }); + }); + + it("rejects a directory in place of the claimed artifact", async () => { + const workspace = createWorkspace(); + mkdirSync(join(workspace, "dev/plans/FER-273.md"), { recursive: true }); + + const result = await run( + fakeAgent({ ok: true, structuredOutput: READY_FOR_REVIEW, raw: {} }).agent, + workspace, + ); + + expect(result).toMatchObject({ + ok: false, + failureKind: "invalid-artifact", + error: expect.stringContaining("must be a regular file"), + }); + }); + + it("rejects a symlinked artifact", async () => { + const workspace = createWorkspace(); + const outside = createWorkspace(); + const target = join(outside, "outside.md"); + writeFileSync(target, "# Outside\n", "utf8"); + mkdirSync(join(workspace, "dev/plans"), { recursive: true }); + symlinkSync(target, join(workspace, "dev/plans/FER-273.md")); + + const result = await run( + fakeAgent({ ok: true, structuredOutput: READY_FOR_REVIEW, raw: {} }).agent, + workspace, + ); + + expect(result).toMatchObject({ + ok: false, + failureKind: "invalid-artifact", + error: expect.stringContaining("must be a regular file"), + }); + }); + + it("rejects an artifact reached through a symlinked parent directory", async () => { + const workspace = createWorkspace(); + const outside = createWorkspace(); + writeFileSync(join(outside, "FER-273.md"), "# Outside\n", "utf8"); + mkdirSync(join(workspace, "dev"), { recursive: true }); + symlinkSync(outside, join(workspace, "dev/plans")); + + const result = await run( + fakeAgent({ ok: true, structuredOutput: READY_FOR_REVIEW, raw: {} }).agent, + workspace, + ); + + expect(result).toMatchObject({ + ok: false, + failureKind: "invalid-artifact", + error: expect.stringContaining("resolves outside the supplied workspace"), + }); + }); + + it.each([ + ["provider", { ok: false, error: "Codex failed", exitCode: 1 } satisfies AgentRunResult], + ["timeout", { ok: false, error: "Agent timed out", exitCode: 124 } satisfies AgentRunResult], + [ + "cancelled", + { + ok: false, + error: "Agent was aborted", + exitCode: 130, + aborted: true, + } satisfies AgentRunResult, + ], + [ + "workspace-guard", + { + ok: false, + error: "Workspace could not be inspected", + exitCode: 1, + failureKind: "workspace-guard", + } satisfies AgentRunResult, + ], + ] satisfies ReadonlyArray<[SpecIssueFailureKind, AgentRunResult]>)( + "returns a typed %s failure", + async (failureKind, agentResult) => { + const result = await run(fakeAgent(agentResult).agent, createWorkspace()); + + expect(result).toMatchObject({ + ok: false, + failureKind, + error: agentResult.ok ? undefined : agentResult.error, + }); + }, + ); + + it("converts a thrown provider error into a typed provider failure", async () => { + const agent: Agent = { + name: "codex", + run: async () => { + throw new Error("transport unavailable"); + }, + }; + + await expect(run(agent, createWorkspace())).resolves.toMatchObject({ + ok: false, + failureKind: "provider", + error: "Spec agent failed: transport unavailable", + }); + }); +}); + +describe("specArtifactPath", () => { + it("uses the exact normalized issue reference", () => { + expect(specArtifactPath("FER-273")).toBe("dev/plans/FER-273.md"); + }); + + it.each(["fer-273", "FER/273", "../FER-273", "FER-ABC"])( + "rejects invalid reference %s", + (reference) => { + expect(() => specArtifactPath(reference)).toThrow(/uppercase issue reference/); + }, + ); +}); + +function fakeAgent( + result: AgentRunResult, + provider: AgentProviderName = "codex", + onRun?: (input: AgentRunInput) => void, +): { agent: Agent; inputs: AgentRunInput[] } { + const inputs: AgentRunInput[] = []; + return { + inputs, + agent: { + name: provider, + async run(input) { + inputs.push(input); + onRun?.(input); + return result; + }, + }, + }; +} + +function run(agent: Agent, workspace: string) { + return specIssue({ + workItem: validWorkItem(), + agent, + workspace, + execution: { + model: "gpt-5.6-sol", + modelReasoningEffort: "high", + maxRuntimeMs: 120_000, + }, + }); +} + +function createWorkspace(): string { + const workspace = mkdtempSync(join(tmpdir(), "harness-spec-")); + temporaryPaths.push(workspace); + return workspace; +} + +function writeArtifact(workspace: string, contents: string): void { + const path = join(workspace, "dev/plans/FER-273.md"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, "utf8"); +} + +function validWorkItem(): SpecWorkItemContext { + return { + id: "issue-273", + reference: "FER-273", + title: "Build a provider-neutral Spec operation", + description: "Write one code-grounded implementation Spec.", + url: "https://linear.app/issue/FER-273", + state: "Open", + labels: ["Spec"], + comments: [], + parent: null, + children: [], + duplicateOf: null, + blockedBy: [], + related: [], + links: [], + createdAt: "2026-07-21T22:12:57.641Z", + updatedAt: "2026-07-22T20:00:00.000Z", + completeness: { + commentsTruncated: false, + labelsTruncated: false, + relationsTruncated: false, + linksTruncated: false, + childrenTruncated: false, + }, + }; +} diff --git a/lib/spec/spec.ts b/lib/spec/spec.ts new file mode 100644 index 0000000..722abed --- /dev/null +++ b/lib/spec/spec.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + Agent, + AgentProviderName, + AgentReasoningEffort, + AgentRunResult, +} from "../agent/contract.ts"; +import { errorMessage } from "../agent/invocation.ts"; +import { renderSpecPrompt, SPEC_POLICY_VERSION } from "./prompt.ts"; +import { + SPEC_RESULT_SCHEMA_VERSION, + SpecDecisionSchema, + SpecIssueReferenceSchema, + type SpecDecision, + type SpecWorkItemContext, +} from "./schema.ts"; + +const MODULE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const HARNESS_ROOT = basename(MODULE_ROOT) === "dist" ? resolve(MODULE_ROOT, "..") : MODULE_ROOT; + +export const SPEC_RESULT_SCHEMA_PATH = join(HARNESS_ROOT, "schemas/spec-result.schema.json"); + +export type SpecExecution = Readonly<{ + model: string; + modelReasoningEffort: AgentReasoningEffort; + maxRuntimeMs: number; + logPath?: string; + signal?: AbortSignal; +}>; + +export type SpecSessionReference = Readonly<{ + provider: AgentProviderName; + id: string; +}>; + +export type SpecProvenance = Readonly<{ + provider: AgentProviderName; + model: string; + modelReasoningEffort: AgentReasoningEffort; + policyVersion: string; + resultSchemaVersion: string; + promptSha256: string; + schemaSha256: string; + session: SpecSessionReference | null; +}>; + +export type SpecIssueFailureKind = + | "provider" + | "timeout" + | "cancelled" + | "invalid-output" + | "invalid-artifact" + | "workspace-guard"; + +export type SpecIssueResult = + | Readonly<{ + ok: true; + decision: SpecDecision; + provenance: SpecProvenance; + }> + | Readonly<{ + ok: false; + failureKind: SpecIssueFailureKind; + error: string; + provenance: SpecProvenance; + }>; + +export async function specIssue(input: { + workItem: SpecWorkItemContext; + agent: Agent; + workspace: string; + execution: SpecExecution; +}): Promise { + const artifactPath = specArtifactPath(input.workItem.reference); + const prompt = renderSpecPrompt({ workItem: input.workItem, artifactPath }); + const provenance = baseProvenance( + input.agent.name, + input.execution.model, + input.execution.modelReasoningEffort, + prompt, + ); + + let result: AgentRunResult; + try { + result = await input.agent.run({ + workspace: input.workspace, + prompt, + schemaPath: SPEC_RESULT_SCHEMA_PATH, + model: input.execution.model, + modelReasoningEffort: input.execution.modelReasoningEffort, + sandboxMode: "workspace-write", + approvalPolicy: "never", + workspaceGuard: "record", + maxRuntimeMs: input.execution.maxRuntimeMs, + logPath: input.execution.logPath, + signal: input.execution.signal, + }); + } catch (error) { + return { + ok: false, + failureKind: "provider", + error: `Spec agent failed: ${errorMessage(error)}`, + provenance, + }; + } + + const resultProvenance = { + ...provenance, + session: result.ok && result.session ? normalizedSession(result.session) : null, + }; + + if (!result.ok) { + return { + ok: false, + failureKind: failureKind(result), + error: result.error, + provenance: resultProvenance, + }; + } + + const decision = SpecDecisionSchema.safeParse(result.structuredOutput); + if (!decision.success) { + return { + ok: false, + failureKind: "invalid-output", + error: `Invalid Spec structured output: ${formatZodError(decision.error.issues)}`, + provenance: resultProvenance, + }; + } + + if (decision.data.outcome === "ready-for-review") { + const artifactError = validateSpecArtifact( + input.workspace, + artifactPath, + decision.data.artifactPath, + ); + if (artifactError) { + return { + ok: false, + failureKind: "invalid-artifact", + error: artifactError, + provenance: resultProvenance, + }; + } + } + + return { + ok: true, + decision: decision.data, + provenance: resultProvenance, + }; +} + +export function specArtifactPath(reference: string): string { + return `dev/plans/${SpecIssueReferenceSchema.parse(reference)}.md`; +} + +function validateSpecArtifact( + workspace: string, + expectedPath: string, + claimedPath: string, +): string | null { + if (claimedPath !== expectedPath) { + return `Invalid Spec artifact: expected ${expectedPath}, received ${claimedPath}.`; + } + + try { + const workspaceRoot = realpathSync(workspace); + const candidate = resolve(workspaceRoot, claimedPath); + const candidateRelative = relative(workspaceRoot, candidate); + if ( + candidateRelative === ".." || + candidateRelative.startsWith(`..${sep}`) || + isAbsolute(candidateRelative) + ) { + return `Invalid Spec artifact: ${claimedPath} resolves outside the supplied workspace.`; + } + + const stat = lstatSync(candidate); + if (stat.isSymbolicLink() || !stat.isFile()) { + return `Invalid Spec artifact: ${claimedPath} must be a regular file.`; + } + + // A regular final file can still escape through a symlinked parent directory. + const realCandidate = realpathSync(candidate); + const realRelative = relative(workspaceRoot, realCandidate); + if (realRelative === ".." || realRelative.startsWith(`..${sep}`) || isAbsolute(realRelative)) { + return `Invalid Spec artifact: ${claimedPath} resolves outside the supplied workspace.`; + } + + if (readFileSync(realCandidate, "utf8").trim() === "") { + return `Invalid Spec artifact: ${claimedPath} is empty.`; + } + } catch (error) { + return `Invalid Spec artifact ${claimedPath}: ${errorMessage(error)}`; + } + + return null; +} + +function baseProvenance( + provider: AgentProviderName, + model: string, + modelReasoningEffort: AgentReasoningEffort, + prompt: string, +): SpecProvenance { + return { + provider, + model, + modelReasoningEffort, + policyVersion: SPEC_POLICY_VERSION, + resultSchemaVersion: SPEC_RESULT_SCHEMA_VERSION, + promptSha256: sha256(prompt), + schemaSha256: sha256(readFileSync(SPEC_RESULT_SCHEMA_PATH)), + session: null, + }; +} + +function failureKind(result: Extract): SpecIssueFailureKind { + if (result.aborted) return "cancelled"; + if (result.exitCode === 124) return "timeout"; + if (result.failureKind === "workspace-guard") return "workspace-guard"; + return "provider"; +} + +function normalizedSession(session: { + provider: AgentProviderName; + id: string; +}): SpecSessionReference { + return { + provider: session.provider, + id: session.id, + }; +} + +function sha256(value: string | NodeJS.ArrayBufferView): string { + return createHash("sha256").update(value).digest("hex"); +} + +function formatZodError(issues: ReadonlyArray<{ path: PropertyKey[]; message: string }>): string { + return issues.map((issue) => `${issue.path.join(".") || "$"}: ${issue.message}`).join("; "); +} diff --git a/lib/triage/schema.ts b/lib/triage/schema.ts index 610423c..f9a01ae 100644 --- a/lib/triage/schema.ts +++ b/lib/triage/schema.ts @@ -1,104 +1,22 @@ import { z } from "zod"; +import { + createWorkItemContextSchemas, + WorkItemCommentSchema, + WorkItemEvidenceSchema, + WorkItemLinkSchema, +} from "../work-item/schema.ts"; export const TRIAGE_DECISION_SCHEMA_VERSION = "3"; const NonEmptyStringSchema = z.string().min(1); -const PortableRelativePathSchema = NonEmptyStringSchema.superRefine((value, ctx) => { - const segments = value.split("/"); - if ( - value.includes("\\") || - value.startsWith("/") || - /^[A-Za-z]:/.test(value) || - value.startsWith("//") || - segments.some((segment) => segment === "" || segment === "." || segment === "..") - ) { - ctx.addIssue({ code: "custom", message: "must be a portable repository-relative path" }); - } -}); +const TriageWorkItemSchemas = createWorkItemContextSchemas(NonEmptyStringSchema); -export const TriageWorkItemReferenceSchema = z - .object({ - id: NonEmptyStringSchema, - reference: NonEmptyStringSchema, - title: NonEmptyStringSchema, - url: z.url().nullable(), - state: NonEmptyStringSchema, - }) - .strict(); - -export const TriageCommentSchema = z - .object({ - author: NonEmptyStringSchema.nullable(), - body: NonEmptyStringSchema, - createdAt: z.iso.datetime(), - }) - .strict(); - -export const TriageLinkSchema = z - .object({ - title: NonEmptyStringSchema, - url: z.url(), - }) - .strict(); - -export const TriageWorkItemContextSchema = z - .object({ - id: NonEmptyStringSchema, - reference: NonEmptyStringSchema, - title: NonEmptyStringSchema, - description: NonEmptyStringSchema.nullable(), - url: z.url().nullable(), - state: NonEmptyStringSchema, - labels: z.array(NonEmptyStringSchema), - comments: z.array(TriageCommentSchema), - parent: TriageWorkItemReferenceSchema.nullable(), - children: z.array(TriageWorkItemReferenceSchema), - duplicateOf: TriageWorkItemReferenceSchema.nullable(), - blockedBy: z.array(TriageWorkItemReferenceSchema), - related: z.array(TriageWorkItemReferenceSchema), - links: z.array(TriageLinkSchema), - createdAt: z.iso.datetime(), - updatedAt: z.iso.datetime(), - completeness: z - .object({ - commentsTruncated: z.boolean(), - labelsTruncated: z.boolean(), - relationsTruncated: z.boolean(), - linksTruncated: z.boolean(), - childrenTruncated: z.boolean(), - }) - .strict(), - }) - .strict(); - -export const TriageEvidenceSchema = z - .object({ - kind: z.enum(["tracker", "code", "docs", "test", "repo-state"]), - path: PortableRelativePathSchema.nullable(), - summary: NonEmptyStringSchema, - }) - .strict() - .superRefine((evidence, ctx) => { - if (evidence.kind === "tracker" && evidence.path !== null) { - ctx.addIssue({ - code: "custom", - path: ["path"], - message: "tracker evidence must use path: null", - }); - } - - if ( - (evidence.kind === "code" || evidence.kind === "docs" || evidence.kind === "test") && - evidence.path === null - ) { - ctx.addIssue({ - code: "custom", - path: ["path"], - message: `${evidence.kind} evidence requires a repository-relative path`, - }); - } - }); +export const TriageWorkItemReferenceSchema = TriageWorkItemSchemas.reference; +export const TriageCommentSchema = WorkItemCommentSchema; +export const TriageLinkSchema = WorkItemLinkSchema; +export const TriageWorkItemContextSchema = TriageWorkItemSchemas.context; +export const TriageEvidenceSchema = WorkItemEvidenceSchema; export const TriageDecisionSchema = z .object({ diff --git a/lib/work-item/schema.ts b/lib/work-item/schema.ts new file mode 100644 index 0000000..525e421 --- /dev/null +++ b/lib/work-item/schema.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +const NonEmptyStringSchema = z.string().min(1); + +export const PortableRelativePathSchema = NonEmptyStringSchema.superRefine((value, ctx) => { + const segments = value.split("/"); + if ( + value.includes("\\") || + value.startsWith("/") || + /^[A-Za-z]:/.test(value) || + value.startsWith("//") || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + ctx.addIssue({ code: "custom", message: "must be a portable repository-relative path" }); + } +}); + +export const WorkItemCommentSchema = z + .object({ + author: NonEmptyStringSchema.nullable(), + body: NonEmptyStringSchema, + createdAt: z.iso.datetime(), + }) + .strict(); + +export const WorkItemLinkSchema = z + .object({ + title: NonEmptyStringSchema, + url: z.url(), + }) + .strict(); + +export const WorkItemEvidenceSchema = z + .object({ + kind: z.enum(["tracker", "code", "docs", "test", "repo-state"]), + path: PortableRelativePathSchema.nullable(), + summary: NonEmptyStringSchema, + }) + .strict() + .superRefine((evidence, ctx) => { + if (evidence.kind === "tracker" && evidence.path !== null) { + ctx.addIssue({ + code: "custom", + path: ["path"], + message: "tracker evidence must use path: null", + }); + } + + if ( + (evidence.kind === "code" || evidence.kind === "docs" || evidence.kind === "test") && + evidence.path === null + ) { + ctx.addIssue({ + code: "custom", + path: ["path"], + message: `${evidence.kind} evidence requires a repository-relative path`, + }); + } + }); + +export function createWorkItemContextSchemas(referenceSchema: z.ZodType) { + const reference = z + .object({ + id: NonEmptyStringSchema, + reference: referenceSchema, + title: NonEmptyStringSchema, + url: z.url().nullable(), + state: NonEmptyStringSchema, + }) + .strict(); + + const context = z + .object({ + id: NonEmptyStringSchema, + reference: referenceSchema, + title: NonEmptyStringSchema, + description: NonEmptyStringSchema.nullable(), + url: z.url().nullable(), + state: NonEmptyStringSchema, + labels: z.array(NonEmptyStringSchema), + comments: z.array(WorkItemCommentSchema), + parent: reference.nullable(), + children: z.array(reference), + duplicateOf: reference.nullable(), + blockedBy: z.array(reference), + related: z.array(reference), + links: z.array(WorkItemLinkSchema), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + completeness: z + .object({ + commentsTruncated: z.boolean(), + labelsTruncated: z.boolean(), + relationsTruncated: z.boolean(), + linksTruncated: z.boolean(), + childrenTruncated: z.boolean(), + }) + .strict(), + }) + .strict(); + + return { reference, context } as const; +} diff --git a/schemas/spec-result.schema.json b/schemas/spec-result.schema.json new file mode 100644 index 0000000..67feeb3 --- /dev/null +++ b/schemas/spec-result.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SpecResult", + "type": "object", + "additionalProperties": false, + "required": ["outcome", "artifactPath", "summary", "evidence", "reviewerDecisions", "questions"], + "properties": { + "outcome": { + "type": "string", + "enum": ["ready-for-review", "needs-input"] + }, + "artifactPath": { + "type": ["string", "null"], + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path", "summary"], + "properties": { + "kind": { + "type": "string", + "enum": ["tracker", "code", "docs", "test", "repo-state"] + }, + "path": { + "type": ["string", "null"], + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + } + } + } + }, + "reviewerDecisions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["question", "options", "recommendation", "rationale"], + "properties": { + "question": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["option", "tradeoffs"], + "properties": { + "option": { + "type": "string", + "minLength": 1 + }, + "tradeoffs": { + "type": "string", + "minLength": 1 + } + } + } + }, + "recommendation": { + "type": "string", + "minLength": 1 + }, + "rationale": { + "type": "string", + "minLength": 1 + } + } + } + }, + "questions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } +}