Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions dist/index.cjs

Large diffs are not rendered by default.

41 changes: 20 additions & 21 deletions src/stages/triage/decision.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import { z } from "zod";

const SuppliedArtifact = z
.object({
source: z.enum(["body", "path"]),
path: z.string().optional(),
content: z.string().optional(),
})
.refine(
(v) =>
v.source === "path"
? typeof v.path === "string" && v.path.length > 0
: true,
{ message: "path is required when source='path'" },
)
.refine(
(v) =>
v.source === "body"
? typeof v.content === "string" && v.content.length > 0
: true,
{ message: "content is required when source='body'" },
);
const SuppliedArtifactObject = z.object({
source: z.enum(["body", "path"]),
path: z.string().optional(),
content: z.string().optional(),
});

// An artifact is only usable when it carries the field its source requires: a
// `path` source needs a non-empty `path`, a `body` source needs non-empty
// `content`. The agent occasionally emits a `source` without the matching
// field. Rather than fail the entire triage decision (which forces a manual
// label-removal retry), degrade the unusable artifact to `null` so triage
// completes and the stage runs normally. The downstream apply step already
// no-ops on a missing `path`/`content`, so nothing is lost.
const SuppliedArtifact = SuppliedArtifactObject.transform((v) => {
if (v.source === "path") {
return typeof v.path === "string" && v.path.length > 0 ? v : null;
}
return typeof v.content === "string" && v.content.length > 0 ? v : null;
});

export const TriageDecision = z.object({
status: z.enum(["classified", "needs_clarification"]),
Expand All @@ -30,4 +29,4 @@ export const TriageDecision = z.object({
supplied_plan: SuppliedArtifact.nullable().default(null),
});
export type TriageDecision = z.infer<typeof TriageDecision>;
export type SuppliedArtifact = z.infer<typeof SuppliedArtifact>;
export type SuppliedArtifact = z.infer<typeof SuppliedArtifactObject>;
4 changes: 2 additions & 2 deletions src/stages/triage/prompt.system.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Schema:
- `complexity`: "quick" | "medium" | "large" (your best guess even when needs_clarification)
- `rationale`: 1-3 sentences explaining the classification and what the next stage should focus on
- `clarifying_questions`: array of single-question strings (empty when classified)
- `supplied_spec`: { source: "body" | "path", path?, content? } | null
- `supplied_spec`: { source: "body" | "path", path?, content? } | null. When `source` is `"body"`, `content` is REQUIRED and must hold the full extracted artifact text; when `source` is `"path"`, `path` is REQUIRED.
- `supplied_plan`: same shape | null

Rules:
Expand All @@ -122,5 +122,5 @@ Rules:
- `status: "needs_clarification"` requires a non-empty `clarifying_questions` array.
- Every string in `clarifying_questions` must be a single, specific, answerable question. No multi-part questions.
- If the issue is bug-shaped per `<root_cause_analysis>` and `status` is `classified` and no `supplied_spec` or `supplied_plan` is detected, the `rationale` string MUST end with a `### Suspected root cause` subsection. Otherwise the `rationale` MUST NOT contain such a subsection.
- `supplied_spec` and `supplied_plan` default to `null`. Set them only when you detect a supplied artifact per `<artifact_detection>`. When `source` is `path`, omit `content`; when `source` is `body`, omit `path`.
- `supplied_spec` and `supplied_plan` default to `null`. Set them only when you detect a supplied artifact per `<artifact_detection>`. When `source` is `path`, set `path` and omit `content`; when `source` is `body`, put the full extracted artifact text in `content` and omit `path`. An artifact missing its required field (a `body` artifact with no `content`, or a `path` artifact with no `path`) is unusable and will be discarded as if no artifact was detected, so always populate the required field.
</output>
45 changes: 45 additions & 0 deletions test/stages/triage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,51 @@ describe("applyTriage", () => {
);
});

it("degrades a body-source supplied_spec with no content to null instead of failing triage", async () => {
const ctx = makeCtx({
decision: {
status: "classified",
complexity: "large",
rationale: "the body reads like a spec but no content was extracted",
supplied_spec: { source: "body" },
},
issueTitle: "feat: do the thing",
});
const d = await runTriage(ctx, { issueComments: "" });
expect(d.supplied_spec).toBeNull();
await applyTriage(ctx, { decision: d, baseBranch: "main" });
// Unusable artifact discarded: no spec PR seeded, routes through the
// normal large -> needs-spec path.
expect(ctx._mockGithub.openStagePr).not.toHaveBeenCalled();
expect(ctx._mockGithub.addLabel).toHaveBeenCalledWith(
7,
"shopfloor:needs-spec",
);
});

it("degrades a path-source supplied_spec with no path to null instead of failing triage", async () => {
const ctx = makeCtx({
decision: {
status: "classified",
complexity: "large",
rationale: "referenced a spec path but did not name it",
supplied_spec: { source: "path" },
},
issueTitle: "feat: do the thing",
});
const d = await runTriage(ctx, { issueComments: "" });
expect(d.supplied_spec).toBeNull();
await applyTriage(ctx, { decision: d, baseBranch: "main" });
expect(ctx._mockGithub.upsertIssueMetadata).toHaveBeenCalledWith(
7,
expect.not.objectContaining({ specPath: expect.anything() }),
);
expect(ctx._mockGithub.addLabel).toHaveBeenCalledWith(
7,
"shopfloor:needs-spec",
);
});

it("refuses to re-triage when an advanced state label is present", async () => {
const ctx = makeCtx({
decision: {
Expand Down
Loading