Files
2026-07-13 12:20:01 +08:00

384 lines
21 KiB
TypeScript

import { readFileSync } from "fs"
import path from "path"
import { describe, expect, test } from "bun:test"
const SKILL_PATH = path.join(
process.cwd(),
"skills/ce-plan/SKILL.md",
)
const SKILL_BODY = readFileSync(SKILL_PATH, "utf8")
const HANDOFF_PATH = path.join(
process.cwd(),
"skills/ce-plan/references/plan-handoff.md",
)
const HANDOFF_BODY = readFileSync(HANDOFF_PATH, "utf8")
const ISSUE_CREATION_START = HANDOFF_BODY.indexOf("## Issue Creation")
const ISSUE_CREATION_SECTION =
ISSUE_CREATION_START > -1 ? HANDOFF_BODY.slice(ISSUE_CREATION_START) : ""
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/714.
//
// ce-plan Phase 5.4 presents a 4-option post-generation menu. Because SKILL.md
// content caches at session start while reference files load on demand, the
// per-option routing (what action fires when the user picks an option) MUST
// live in SKILL.md itself — not solely in references/plan-handoff.md. The
// reference may still hold elaborate sub-flows (Issue Creation tracker
// detection); only the bare per-option action must be inline.
//
// Symptom when this regresses: the agent renders the menu, the user picks
// "Start `/ce-work` (Recommended)", and the agent stops in prose without
// invoking the ce-work skill.
describe("ce-plan post-generation menu routing", () => {
test("SKILL.md contains inline routing for all four menu options", () => {
// Anchor on the Phase 5.4 region so a stray match elsewhere in the file
// doesn't satisfy these assertions.
const phaseStart = SKILL_BODY.indexOf("##### 5.3.8")
expect(
phaseStart,
"ce-plan SKILL.md no longer contains the '##### 5.3.8' phase heading — the test anchor needs updating, or Phase 5.4 was removed.",
).toBeGreaterThan(-1)
const phaseRegion = SKILL_BODY.slice(phaseStart)
// Each menu option must have a routing bullet in the phase region that
// pairs the label with an action statement. The routing bullet shape is
// `- **<label-or-label-fragment>** — <action sentence>`. We accept "—",
// "->", or "-" as the separator so legitimate phrasing tweaks don't break
// the test, but require:
// - the line starts with `- **` (a bullet, not the numbered menu list)
// - the bold span contains a fragment unique to the option label
// - the bold span is followed by a separator and at least one action verb
// Testing for a label fragment (not the full label) tolerates label
// phrasing tweaks without the assertion becoming brittle.
const optionFragments: { name: string; fragment: string }[] = [
{ name: "Start /ce-work", fragment: "Start `/ce-work`" },
{ name: "Run it as a /goal", fragment: "Run it as a `/goal`" },
{ name: "Create Issue", fragment: "Create Issue" },
{ name: "Publish to Proof", fragment: "Publish to Proof" },
// "Open in browser" is the HTML-mode replacement for Publish to Proof.
// Both routings must be present inline: the menu shows one or the other
// depending on OUTPUT_FORMAT, so the agent needs both bullets to route
// correctly without loading the reference.
{ name: "Open in browser", fragment: "Open in browser" },
]
for (const { name, fragment } of optionFragments) {
const escaped = fragment.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&")
// Bullet form: `- **...<fragment>...**` followed by separator + action,
// ALL on the same line. Use `[ \t]*` for the inter-token gaps instead of
// `\s*` so a bullet with no action text cannot match by spilling into the
// next bullet's leading `-` (Codex P2 catch on PR #715: `\s*` consumed
// newlines, letting an empty-action bullet pass the regex). The trailing
// `[^\n]+` requires at least one non-newline character of action text.
const inlineRoutingPattern = new RegExp(
`^- \\*\\*[^\\n]*${escaped}[^\\n]*\\*\\*[ \\t]*(?:[—\\-]+>?|->)[ \\t]*[^\\n]+`,
"m",
)
const found = inlineRoutingPattern.test(phaseRegion)
expect(
found,
`ce-plan SKILL.md Phase 5.4 is missing inline routing for menu option "${name}". The bare per-option action MUST live in SKILL.md (not solely in references/plan-handoff.md) so an agent that doesn't load the reference still routes correctly. See https://github.com/EveryInc/compound-engineering-plugin/issues/714 and docs/solutions/skill-design/post-menu-routing-belongs-inline-2026-04-28.md.`,
).toBe(true)
}
})
test("Start /ce-work routing names skill-invocation primitive and plan path", () => {
const phaseStart = SKILL_BODY.indexOf("##### 5.3.8")
const phaseRegion = SKILL_BODY.slice(phaseStart)
// The Start /ce-work routing line must be platform-explicit. We require
// that the routing BULLET (not the menu list entry) names both
// (a) the skill-invocation primitive (Skill tool / skill-invocation /
// skill primitive) and (b) the plan path being passed as the argument.
// This is what makes the difference between "tell the user to type
// /ce-work" and "fire the Skill tool now."
//
// Anchor on the bullet form `- **Start \`/ce-work\`**` to avoid matching
// the numbered menu list entry `1. **Start \`/ce-work\`** (recommended) -`,
// which legitimately doesn't carry the routing language.
const ceWorkRoutingMatch = phaseRegion.match(
/^- \*\*Start `\/ce-work`\*\*[\s\S]{0,500}/m,
)
expect(
ceWorkRoutingMatch,
"ce-plan SKILL.md Phase 5.4 is missing the inline '- **Start `/ce-work`** ...' routing bullet (distinct from the numbered menu list entry).",
).not.toBeNull()
const block = ceWorkRoutingMatch![0]
expect(
/skill[\s-]?invocation|Skill tool|skill primitive/i.test(block),
"ce-plan SKILL.md 'Start /ce-work' routing must name the skill-invocation primitive (e.g., 'Skill tool in Claude Code', 'platform's skill-invocation primitive') so the agent fires the invocation rather than announcing a handoff in prose. See issue #714.",
).toBe(true)
expect(
/plan path|plan file path|plan as the (?:skill )?argument|passing the plan/i.test(block),
"ce-plan SKILL.md 'Start /ce-work' routing must name the plan path as the argument so the agent passes it correctly to ce-work. See issue #714.",
).toBe(true)
})
test("plan-handoff.md routing for Start /ce-work matches the inline platform-explicit phrasing", () => {
// Both surfaces must converge: the reference file's routing line should
// also use platform-explicit invocation language so that an agent which
// does load the reference sees compatible (not contradictory) guidance.
const ceWorkLine = HANDOFF_BODY.match(
/\*\*Start `\/ce-work`\*\*[^\n]*->[^\n]+/,
)
expect(
ceWorkLine,
"references/plan-handoff.md is missing the routing line for 'Start /ce-work'.",
).not.toBeNull()
expect(
/skill[\s-]?invocation|Skill tool|skill primitive/i.test(ceWorkLine![0]),
`references/plan-handoff.md 'Start /ce-work' routing must use platform-explicit invocation language matching SKILL.md (e.g., 'invoke the ce-work skill via the platform's skill-invocation primitive'). The bare 'Call /ce-work with the plan path' phrasing was the regression. Found: ${JSON.stringify(ceWorkLine![0])}`,
).toBe(true)
})
test("Codex goal handoff is capability-based and menu-cap aware", () => {
for (const [label, body] of [
["SKILL.md", SKILL_BODY],
["plan-handoff.md", HANDOFF_BODY],
] as const) {
expect(
body.includes("top-level `/goal` command"),
`${label} must not gate Codex goal handoff on a literal top-level /goal command; Codex exposes goal mode through create_goal.`,
).toBe(false)
expect(
body.includes("hosts with a `/goal` command"),
`${label} must not describe goal availability as slash-command-only; use goal capability and Codex create_goal instead.`,
).toBe(false)
expect(
/(?:Codex [`"]?request_user_input[`"]?[\s\S]{0,120}no option cap|no option cap[\s\S]{0,120}Codex [`"]?request_user_input[`"]?)/i.test(body),
`${label} must not claim Codex request_user_input has no option cap; current Codex question tools only allow 2-3 explicit options.`,
).toBe(false)
expect(
body.includes("create_goal") && /goal capability/i.test(body),
`${label} must explicitly treat Codex create_goal as goal capability so the /goal option renders in Codex app runs.`,
).toBe(true)
expect(
/request_user_input[\s\S]{0,120}2-3 explicit options/i.test(body),
`${label} must document the Codex request_user_input 2-3 option cap so larger handoff menus use numbered chat instead of trimming choices.`,
).toBe(true)
}
})
test("completion contract is visible before the workflow and guarded at the end", () => {
const contractStart = SKILL_BODY.indexOf("## Mandatory Completion Contract")
const interactionStart = SKILL_BODY.indexOf("## Interaction Method")
expect(
contractStart,
"ce-plan SKILL.md must keep the Mandatory Completion Contract near the top so agents see the handoff boundary before entering the workflow.",
).toBeGreaterThan(-1)
expect(
interactionStart,
"ce-plan SKILL.md no longer contains the Interaction Method heading — update this test anchor if the top section was restructured.",
).toBeGreaterThan(-1)
expect(
contractStart,
"Mandatory Completion Contract must appear before Interaction Method, not only after Phase 5.4, so 'create the plan and stop' does not look like completion.",
).toBeLessThan(interactionStart)
const topContract = SKILL_BODY.slice(contractStart, interactionStart)
expect(
/Every normal interactive `ce-plan` branch that produces a plan artifact or checkpoint is incomplete until its owning handoff question is presented/i.test(topContract),
"Top completion contract must state that artifact/checkpoint branches are incomplete until their owning handoff question is presented.",
).toBe(true)
expect(
/software implementation-plan runs[\s\S]{0,120}Phase 5\.4[\s\S]{0,120}handoff menu/i.test(topContract),
"Top completion contract must state that software implementation-plan runs are incomplete until the Phase 5.4 handoff menu is presented.",
).toBe(true)
expect(
/Non-software plan-seeking and approach-altitude branches[\s\S]{0,160}do not force those branches through Phase 5\.4/i.test(topContract),
"Top completion contract must preserve branch-owned handoffs for non-software plan-seeking and approach-altitude branches.",
).toBe(true)
expect(
/Answer-seeking is the exception:[\s\S]{0,140}may end after delivering the answer unless[\s\S]{0,100}offer save\/share/i.test(topContract),
"Top completion contract must allow answer-seeking to end after the answer unless universal-planning says to offer save/share.",
).toBe(true)
expect(
/intermediate milestones, not completion/i.test(topContract),
"Top completion contract must say writing/reviewing the plan are intermediate milestones, not completion.",
).toBe(true)
expect(
/only ["“]create a plan["”][\s\S]{0,160}run [`']?ce-doc-review[`']?/i.test(topContract),
"Top completion contract must make 'user only asked to create a plan / run ce-doc-review' non-exempt.",
).toBe(true)
expect(
/Plan ready at `<absolute path to plan>`\. What would you like to do next\?/i.test(topContract),
"Top completion contract must include the literal Phase 5.4 handoff question.",
).toBe(true)
expect(
/headless review state or documented skip state is summarized/i.test(topContract),
"Top completion contract must allow documented skip-state summaries, not only headless review summaries.",
).toBe(true)
const checklistStart = SKILL_BODY.indexOf("**Final pre-response checklist:**")
const completionStart = SKILL_BODY.indexOf("**Completion check:**")
expect(
checklistStart,
"ce-plan SKILL.md must include a final pre-response checklist before the completion check.",
).toBeGreaterThan(-1)
expect(
completionStart,
"ce-plan SKILL.md must keep the existing Completion check anchor.",
).toBeGreaterThan(-1)
expect(
checklistStart,
"Final pre-response checklist should appear immediately before the terminal completion check.",
).toBeLessThan(completionStart)
const finalGuard = SKILL_BODY.slice(checklistStart, SKILL_BODY.indexOf("**Pipeline mode exception:**"))
for (const expected of [
"Plan file exists on disk",
"Headless review state or documented skip state was summarized above the menu",
"Phase 5.4 menu was presented for software implementation-plan runs, even if the user only asked to create the plan or run doc review, unless pipeline mode returned control to the caller",
"If the user selected an action, the selected routing was executed",
'Incorrect final response: "Created the plan and ran doc review."',
'Correct terminal handoff: "Created the plan and ran doc review. Plan ready at `<absolute path to plan>`. What would you like to do next?"',
]) {
expect(
finalGuard.includes(expected),
`Final completion guard is missing expected text: ${expected}`,
).toBe(true)
}
})
test("inline-routing regex rejects empty-action bullets even when followed by another bullet", () => {
// Regression guard for Codex P2 finding on PR #715: the previous
// `\s*(?:...)\s*` shape allowed newline consumption, so a bullet with no
// action text on its own line could still match by spilling into the next
// bullet's leading `-`. The first test in this file would silently pass
// on a real regression. This test recreates the failure mode and asserts
// the regex now refuses it.
//
// Construct the same regex shape used above and exercise it directly
// against a hand-rolled fixture — no live SKILL.md needed.
const fragment = "Start `/ce-work`"
const escaped = fragment.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&")
const fixedRegex = new RegExp(
`^- \\*\\*[^\\n]*${escaped}[^\\n]*\\*\\*[ \\t]*(?:[—\\-]+>?|->)[ \\t]*[^\\n]+`,
"m",
)
const broken = [
"- **Start `/ce-work`**",
"- **Done for now** — End the turn.",
].join("\n")
expect(
fixedRegex.test(broken),
"Routing regex must NOT match a bullet with no action text on its own line, even when the next bullet's `-` could be misread as the separator. If this assertion fires, the regex regressed back to consuming newlines (Codex P2 on PR #715).",
).toBe(false)
// And confirm the regex still matches the legitimate same-line shape so
// the negative case isn't masking a positive-case breakage.
const valid = "- **Start `/ce-work`** — Invoke the ce-work skill, passing the plan path."
expect(fixedRegex.test(valid)).toBe(true)
})
// Regression guard for PR #961's underlying problem: Issue Creation hardcoded
// a `linear issue create` CLI and instructed the agent to "Read AGENTS.md /
// CLAUDE.md" to detect the tracker. Both are wrong — Linear has no guaranteed
// first-party CLI (false-negative probes caused silent local-doc fallbacks),
// and the project's instruction files are already in context, so naming them
// for a re-read is redundant, harness-brittle, and an injection smell. These
// assertions are behavior-focused (capability language present, bad patterns
// absent) rather than locking exact prose.
describe("Issue Creation is capability-based, not CLI/filename-coupled", () => {
// Shared anchor guard: a renamed/removed "## Issue Creation" heading would
// leave ISSUE_CREATION_SECTION empty, making the absence assertions below
// vacuously pass. Fail loudly here instead so the cause is obvious.
test("plan-handoff.md still has an '## Issue Creation' section", () => {
expect(
ISSUE_CREATION_START,
"plan-handoff.md is missing the '## Issue Creation' section — the other assertions in this block anchor on it.",
).toBeGreaterThan(-1)
})
test("does not prescribe a `linear issue create` CLI", () => {
expect(
HANDOFF_BODY.includes("linear issue create"),
"references/plan-handoff.md must not prescribe `linear issue create`; Linear has no guaranteed first-party CLI. Route through whatever interface Linear exposes (connector/MCP, documented API/GraphQL, or a documented CLI).",
).toBe(false)
})
test("names the accepted Linear access surfaces and guards the false-negative probe", () => {
expect(
/connector|MCP/i.test(ISSUE_CREATION_SECTION) && /API|GraphQL/i.test(ISSUE_CREATION_SECTION),
"Issue Creation must name capability-based Linear access surfaces (connector/MCP and documented API/GraphQL), not a single hardcoded CLI.",
).toBe(true)
expect(
/do not (?:assume|treat|infer)[\s\S]{0,200}(?:missing|no )[\s\S]{0,120}(?:binary|MCP|env|unavailable)/i.test(ISSUE_CREATION_SECTION),
"Issue Creation must guard against the false-negative probe: a missing binary / env var / MCP server is not proof the tracker is unavailable.",
).toBe(true)
})
test("tracker detection reads from context, not by re-opening named instruction files", () => {
// The detection step must not instruct re-reading a named root instruction
// file (the old "Read `AGENTS.md` (or `CLAUDE.md` ...)" probe). It should
// reference the project instructions already in context instead. Naming
// AGENTS.md is still allowed on the WRITE path (persisting project_tracker).
expect(
/Read `?AGENTS\.md`?\s*\(or\s*`?CLAUDE\.md`?/i.test(ISSUE_CREATION_SECTION),
'Issue Creation tracker detection must not instruct "Read AGENTS.md (or CLAUDE.md ...)"; reference the project instructions already in context. Naming a file is reserved for the write-back path.',
).toBe(false)
expect(
/already in your context|active instructions/i.test(ISSUE_CREATION_SECTION),
"Issue Creation tracker detection must point at the project instructions already in the agent's context rather than a file to open.",
).toBe(true)
})
// Codex review of PR #971 (P1): the prose named the tracker category, but
// the visible menu still said "(GitHub or Linear)" and the no-config prompt
// offered no path for any other tracker — keeping the closed set exactly
// when recovering from missing config. Guard both surfaces.
test("menu labels name the tracker category, not a closed GitHub/Linear set", () => {
expect(
/configured issue tracker \(GitHub or Linear\)/i.test(SKILL_BODY),
'ce-plan SKILL.md "Create Issue" menu label must name the category (e.g., GitHub Issues, Linear, Jira), not the closed "(GitHub or Linear)" set.',
).toBe(false)
expect(
/configured issue tracker \(GitHub or Linear\)/i.test(HANDOFF_BODY),
'plan-handoff.md "Create Issue" menu label must name the category, not the closed "(GitHub or Linear)" set.',
).toBe(false)
})
test("no-config prompt routes other trackers via free-form, not a 4th explicit option", () => {
// Non-GitHub/Linear trackers must not be locked out, but the path is the
// blocking tool's built-in free-form/Other input — not an explicit fourth
// `Other` option, which is redundant where the tool already offers free-form
// and exceeds the 2-3 explicit-option cap on Codex `request_user_input`.
expect(
/free-form|different tracker|another tracker|other-tracker/i.test(ISSUE_CREATION_SECTION),
"Issue Creation no-config prompt must let the user reach a non-GitHub/Linear tracker via the tool's free-form path.",
).toBe(true)
expect(
/Options:[^\n]*`Other`|`Linear`,\s*`Other`/.test(ISSUE_CREATION_SECTION),
"Issue Creation no-config prompt must NOT add an explicit fourth `Other` option (redundant + exceeds Codex's explicit-option cap); rely on the tool's built-in free-form input.",
).toBe(false)
})
// Codex review of PR #971 (P3): the inline SKILL.md routing caches at session
// start while the reference loads on demand, so the capability-based Linear
// guidance must also live inline — not only in plan-handoff.md.
test("inline SKILL.md Create Issue routing is capability-based for Linear", () => {
const phaseStart = SKILL_BODY.indexOf("##### 5.3.8")
const phaseRegion = SKILL_BODY.slice(phaseStart)
const createIssueRouting = phaseRegion.match(
/^- \*\*Create Issue\*\*[^\n]+/m,
)
expect(
createIssueRouting,
"ce-plan SKILL.md is missing the inline '- **Create Issue** ...' routing bullet.",
).not.toBeNull()
const bullet = createIssueRouting![0]
expect(
/connector|MCP|API|GraphQL/i.test(bullet) && /no guaranteed `linear` CLI|not.*proof|do not treat/i.test(bullet),
"ce-plan SKILL.md inline Create Issue routing must carry the capability-based Linear guidance (named access surfaces + 'missing binary is not proof unavailable'), since inline routing is what an agent sees when the reference isn't loaded.",
).toBe(true)
})
})
})