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
46 changes: 26 additions & 20 deletions claude-plugins/kampus-pipeline/skills/plan-epic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,30 +468,36 @@ query reads for an existing issue before filing. When in doubt on the epic's own
reconcile (skip/Amend) over a second create; when in doubt on a backlog issue, reference it in the
rationale and let `review-plan` weigh in.

Create each child via REST, assembling its body from a temp file so multi-line
markdown and backticks survive the shell. Allocate the body file with `mktemp`
(every other temp this skill uses is already epic-scoped — `/tmp/plan-epic-<EPIC>-*`),
not a fixed `/tmp/plan-epic-child.md`: concurrent `plan-epic` runs on sibling epics
share `/tmp`, so a fixed path lets one run's child body clobber another's between the
write and this `cat`, filing a child under the right title but with a **sibling epic's
`### What to build` + acceptance criteria** — a cross-epic body bleed the structural
floor can't see (it checks markers, never body fidelity), caught only by `review-plan`'s
Create each child via REST. **Compose its format-2 body with
`pipeline-cli intake-compose sub-issue`** — the one tested composer for the intake-formats
prose contract §2 — rather than hand-re-deriving the format here (the #3254 cite-the-verb
rule). Hand it the child's fields as a spec JSON and it emits the body **by value** on
stdout, so multi-line markdown and backticks survive the shell without a `<<EOF` heredoc; it
enforces the format-2 invariants (the ≥ 1-acceptance-criterion hard floor) and owns the
leak-safe handoff — a stdout-only verb has no scratchpad file to `@`-reference, so the
`gh api -f body=@<path>` machine-local-path leak (#2002 / #754 / PR #1567) is unreachable.
Allocate the **spec** file with `mktemp` (every temp this skill uses is already epic-scoped —
`/tmp/plan-epic-<EPIC>-*`), not a fixed `/tmp/plan-epic-child.json`: concurrent `plan-epic`
runs on sibling epics share `/tmp`, so a fixed path lets one run's spec clobber another's
before it is composed, filing a child under the right title but with a **sibling epic's
`### What to build` + acceptance criteria** — a cross-epic body bleed the structural floor
can't see (it checks markers, never body fidelity), caught only by `review-plan`'s
non-blocking advisor (#754, same `/tmp` collision class as `report`'s per-run `mktemp`):

```bash
# write this child's body into a per-run temp file, never a shared fixed path (#754)
CHILD_BODY_FILE="$(mktemp /tmp/plan-epic-<EPIC>-child.XXXXXX)"
cat > "$CHILD_BODY_FILE" <<'EOF'
**Stories:** <bare numbers, e.g. `1` or `1, 3` — no `S` prefix, no parenthetical digits>
**TDD:** yes | no

### What to build
<…>

### Acceptance criteria
- [ ] <…>
# write this child's spec into a per-run temp file, never a shared fixed path (#754)
CHILD_SPEC_FILE="$(mktemp /tmp/plan-epic-<EPIC>-child.XXXXXX)"
cat > "$CHILD_SPEC_FILE" <<'EOF'
{
"stories": "<bare numbers, e.g. `1` or `1, 3` — no `S` prefix; or `none (pure infra — see What to build)`>",
"tdd": "yes",
"whatToBuild": "<the prose spec>",
"acceptanceCriteria": ["<observable, checkable criterion>"]
}
EOF
BODY="$(cat "$CHILD_BODY_FILE")"
# The verb composes the format-2 body per the contract and emits it BY VALUE to stdout — no
# hand-re-derived `### What to build` / `### Acceptance criteria`, no `-f body=@file` leak.
BODY="$(pipeline-cli intake-compose sub-issue --spec "$CHILD_SPEC_FILE")"
# ATOMIC create — body AND its type/priority/status:planned labels in ONE REST write. `POST /issues`
# accepts `labels` inline, so an interrupted run can never leave a label-less child: the create
# either lands the issue WITH its labels or creates nothing. (Values chosen per the paragraph below.)
Expand Down
6 changes: 6 additions & 0 deletions packages/pipeline-cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {fanoutGuardCommand} from "./tools/fanout-guard/command.ts";
import {ghPhoenixCommand} from "./tools/gh-phoenix/command.ts";
import {glossaryDriftCommand} from "./tools/glossary-drift/command.ts";
import {guardContentProbeCommand} from "./tools/guard-content-probe/command.ts";
import {intakeComposeCommand} from "./tools/intake-compose/command.ts";
import {intakeDedupCommand} from "./tools/intake-dedup/command.ts";
import {leakGuardCommand} from "./tools/leak-guard/command.ts";
import {mainSyncCommand} from "./tools/main-sync/command.ts";
Expand Down Expand Up @@ -151,6 +152,11 @@ export const registeredTools: ReadonlyArray<RegisteredTool> = [
changeDetectGuardCommand,
patchGuardCommand,
intakeDedupCommand,
// The #3688 intake-body composer (epic #3258): one tested verb that emits the
// format-2 sub-issue body of the gh-issue-intake-formats.md prose contract, so a
// filer cites it instead of re-deriving the format — and owns the by-value handoff
// that keeps the `-f body=@file` leak class (#2002 / #754) unreachable.
intakeComposeCommand,
splitGuardCommand,
redactLeaksCommand,
commandsCommand,
Expand Down
18 changes: 18 additions & 0 deletions packages/pipeline-cli/src/tools/adoption-lint/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,24 @@ export const DECISIONS: ReadonlyArray<OwnedDecision> = [
reason:
"re-derives the epic-body splice (anchor guards + first-time-append / re-plan-in-place) that `pipeline-cli epic-splice apply` owns, instead of citing the verb (#3689 / #261 / #3254)",
},
{
// `intake-compose sub-issue` owns the format-2 sub-issue body of the intake-formats prose
// contract (§2, #3688). The fingerprint is the co-occurrence of the two format-2 section
// headings AND a bare `POST /issues` create (the ` \`/` -f` tell right after `/issues`,
// never `/issues/<N>`) — i.e. a file that actually FILES a composed format-2 body, not one
// that merely documents or reads the format. That create discriminator keeps the contract
// file (defines the format, no create) and write-code (reads format-2, creates PRs not
// issues) out of scope. A file that cites `pipeline-cli intake-compose` is compliant.
verb: "intake-compose sub-issue",
signature: [
/### What to build/, // the format-2 spec heading
/### Acceptance criteria/, // the format-2 acceptance-criteria heading
/gh api repos\/\$REPO\/issues(?: \\|\s+-f)/, // a bare POST /issues create (not /issues/<N>)
],
citation: /pipeline-cli\s+intake-compose\b/,
reason:
"re-derives the format-2 sub-issue body that `pipeline-cli intake-compose sub-issue` owns (the gh-issue-intake-formats.md prose contract §2), instead of citing the verb (#3688 / #3254)",
},
];

/**
Expand Down
86 changes: 86 additions & 0 deletions packages/pipeline-cli/src/tools/intake-compose/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {spawnSync} from "node:child_process";
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {fileURLToPath} from "node:url";
import {afterAll, assert, beforeAll, describe, it} from "@effect/vitest";

// The exit + stdout contract of `pipeline-cli intake-compose sub-issue` over the shared bin.
const BIN = fileURLToPath(new URL("../../bin.ts", import.meta.url));

interface RunResult {
readonly code: number;
readonly stdout: string;
readonly stderr: string;
}

const run = (args: ReadonlyArray<string>, input?: string): RunResult => {
const r = spawnSync("node", [BIN, "intake-compose", ...args], {
encoding: "utf8",
input: input ?? "",
});
return {code: r.status ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? ""};
};

const validSpec = {
stories: "4, 9",
tdd: "yes",
containment: "exempt (internal pipeline tooling — no user-facing surface)",
whatToBuild: "Add a pipeline-cli intake composer verb.",
acceptanceCriteria: ["A verb emits a format-2 body.", "Consumers cite the verb."],
};

describe("intake-compose sub-issue — leak-safe stdout handoff (AC3)", () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "intake-compose-"));
});
afterAll(() => {
rmSync(dir, {recursive: true, force: true});
});

it("emits the composed format-2 body to stdout and exits 0 (spec from a file)", () => {
const f = join(dir, "spec.json");
writeFileSync(f, JSON.stringify(validSpec), "utf8");
const {code, stdout} = run(["sub-issue", "--spec", f]);
assert.strictEqual(code, 0);
assert.include(stdout, "**Stories:** 4, 9");
assert.include(stdout, "**TDD:** yes");
assert.include(stdout, "### What to build");
assert.include(stdout, "### Acceptance criteria");
assert.include(stdout, "- [ ] A verb emits a format-2 body.");
}, 30_000);

it("reads the spec from stdin when --spec is omitted", () => {
const {code, stdout} = run(["sub-issue"], JSON.stringify(validSpec));
assert.strictEqual(code, 0);
assert.include(stdout, "### Acceptance criteria");
}, 30_000);

// The whole point: the body is emitted BY VALUE to stdout, so a caller passes it
// as `-f body="$BODY"` — no file path is ever handed back to `@`-reference.
it("the emitted body carries no machine-local filesystem path (no @file leak surface)", () => {
const f = join(dir, "spec2.json");
writeFileSync(f, JSON.stringify(validSpec), "utf8");
const {stdout} = run(["sub-issue", "--spec", f]);
assert.notMatch(stdout, /(^|\s)@[/~]/);
// The spec file path itself must not appear in the emitted body.
assert.notInclude(stdout, f);
}, 30_000);

it("exits 2 on zero acceptance criteria (the format-2 hard floor)", () => {
const f = join(dir, "no-ac.json");
writeFileSync(f, JSON.stringify({...validSpec, acceptanceCriteria: []}), "utf8");
const {code, stderr} = run(["sub-issue", "--spec", f]);
assert.strictEqual(code, 2);
assert.include(stderr, "acceptance criterion");
}, 30_000);

it("exits 2 on malformed JSON", () => {
const f = join(dir, "bad.json");
writeFileSync(f, "{not json", "utf8");
const {code, stderr} = run(["sub-issue", "--spec", f]);
assert.strictEqual(code, 2);
assert.include(stderr, "valid JSON");
}, 30_000);
});
134 changes: 134 additions & 0 deletions packages/pipeline-cli/src/tools/intake-compose/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* The `intake-compose` tool — `pipeline-cli intake-compose sub-issue`.
*
* The one composer for the format-2 sub-issue body of the intake-formats prose
* contract (`gh-issue-intake-formats.md` §2), so the skills that file a sub-issue
* cite this verb instead of re-deriving the format by hand (#3254 / epic #3258):
*
* pipeline-cli intake-compose sub-issue [--spec <file>] # spec JSON via file or stdin
*
* Reads a structured spec JSON (stories / tdd / containment / whatToBuild /
* acceptanceCriteria) from `--spec` or stdin, decodes it at this trust boundary
* with Schema (per `.patterns/effect-schema-validation.md`), enforces the format-2
* invariants (the ≥1-acceptance-criterion hard floor), and emits the composed body
* to **stdout only**.
*
* Stdout-only is the leak-safe handoff (AC3): the caller captures the body by value
* — `BODY="$(pipeline-cli intake-compose sub-issue --spec s.json)"` then
* `gh api … -f body="$BODY"` — so there is no scratchpad file to `@`-reference and
* the `gh api -f body=@<path>` machine-local-path leak (contract §"Posting a comment
* body"; #2002 / #754 / PR #1567) is structurally unreachable. There is deliberately
* no `--out <file>` flag — a file output would reopen exactly that `@file` path.
*
* exit 0 — a well-formed body emitted to stdout
* exit 2 — the spec is unreadable, not valid JSON, or violates a format-2 invariant
*
* The exit mapping is caught inside this handler (not at the shared bin's run
* boundary, which provides only NodeServices and no per-tool catch), mirroring
* `adoption-lint` / `changelog-derive`.
*/
import {readFileSync} from "node:fs";
import {Console, Effect} from "effect";
import * as Schema from "effect/Schema";
import {Command, Flag} from "effect/unstable/cli";
import {composeSubIssueBody, type SubIssueSpec, validateSubIssueSpec} from "./compose.ts";

const SPEC_ERROR_EXIT_CODE = 2;

class SpecError extends Schema.TaggedErrorClass<SpecError>()("SpecError", {
message: Schema.String,
}) {}

/** The untrusted spec JSON, decoded at this boundary into the total `SubIssueSpec`. */
const SpecSchema = Schema.Struct({
stories: Schema.String,
tdd: Schema.Literals(["yes", "no"]),
containment: Schema.optional(Schema.String),
whatToBuild: Schema.String,
acceptanceCriteria: Schema.Array(Schema.String),
});

const decodeSpec = Schema.decodeUnknownEffect(SpecSchema);

const specFlag = Flag.string("spec").pipe(
Flag.optional,
Flag.withDescription("path to the spec JSON; defaults to reading the spec from stdin"),
);

/** Read the raw spec text from `--spec <file>` or stdin (fd 0), any IO failure typed. */
const readSpec = (
spec: {readonly _tag: "Some"; readonly value: string} | {readonly _tag: "None"},
) =>
Effect.try({
try: () => readFileSync(spec._tag === "Some" ? spec.value : 0, "utf8"),
catch: (cause) =>
new SpecError({
message: `cannot read spec from ${spec._tag === "Some" ? spec.value : "stdin"}: ${String(cause)}`,
}),
});

const loadSpec = (
specFlagValue: {readonly _tag: "Some"; readonly value: string} | {readonly _tag: "None"},
): Effect.Effect<SubIssueSpec, SpecError> =>
readSpec(specFlagValue).pipe(
Effect.flatMap((raw) =>
Effect.try({
try: () => JSON.parse(raw) as unknown,
catch: (cause) => new SpecError({message: `spec is not valid JSON: ${String(cause)}`}),
}),
),
Effect.flatMap((json) =>
decodeSpec(json).pipe(
Effect.mapError(
(cause) =>
new SpecError({message: `spec does not match the format-2 shape: ${String(cause)}`}),
),
),
),
Effect.map(
(decoded): SubIssueSpec => ({
stories: decoded.stories,
tdd: decoded.tdd,
...(decoded.containment !== undefined ? {containment: decoded.containment} : {}),
whatToBuild: decoded.whatToBuild,
acceptanceCriteria: decoded.acceptanceCriteria,
}),
),
);

const onSpecError = (e: SpecError) =>
Console.error(`intake-compose: ${e.message}`).pipe(
Effect.flatMap(() => Effect.sync(() => process.exit(SPEC_ERROR_EXIT_CODE))),
);

const subIssue = Command.make(
"sub-issue",
{spec: specFlag},
Effect.fn(function* ({spec}) {
const run = Effect.gen(function* () {
const parsed = yield* loadSpec(spec);
const violations = validateSubIssueSpec(parsed);
if (violations.length > 0) {
return yield* Effect.fail(
new SpecError({
message: `spec violates the format-2 contract:\n${violations.map((v) => ` - ${v}`).join("\n")}`,
}),
);
}
// The body goes to STDOUT by value — the leak-safe handoff (AC3). No `--out` file.
yield* Console.log(composeSubIssueBody(parsed));
});
yield* run.pipe(Effect.catchTag("SpecError", onSpecError));
}),
).pipe(
Command.withDescription(
"Compose a format-2 sub-issue body from a spec JSON and emit it to stdout (the leak-safe by-value handoff)",
),
);

export const intakeComposeCommand = Command.make("intake-compose").pipe(
Command.withSubcommands([subIssue]),
Command.withDescription(
"Compose an intake body per the gh-issue-intake-formats.md prose contract — one composer, not N re-derivations (#3254 / epic #3258)",
),
);
Loading
Loading