From 220e289947b91083c2fef249dd293ccede3daae7 Mon Sep 17 00:00:00 2001 From: Kent Date: Fri, 21 Aug 2026 23:25:29 +0800 Subject: [PATCH 1/6] present-gate: give a captain decision raised outside a gate its own rendering At a gate the FO cannot avoid judging: the template carries a `Recommend {approve | reject}` line, and a blank there is visible. Every other way a captain decision reaches the captain has no template at all. The shared core lists a blocker and a contract-required choice among the conditions that may halt the turn, and says nothing about how to present one. So the cheapest path is to relay. A worker halted on a declared threshold, gave three options, and the FO passed the three on. The captain got a menu instead of a recommendation, and the technical judgment they had delegated came back to them. A menu is worse than a wrong recommendation, because a worker's options carry its role's blind spot. A worker told to build a thing cannot propose building less -- that option does not exist inside its remit. In the case above, all three options moved the budget and none moved the requirement; what mattered was that one deliverable had no user yet, and nobody in the worker's seat could have said so. Add a `## Decision Request` template beside the gate template, with three fields a relay cannot fill: one recommendation stated as an action, `Derived from` naming the evidence the FO read for itself rather than the worker's summary, and `Outside the worker's remit` naming the option the worker's role structurally could not propose. The shared core's halt clause now routes through it. The tell that the field exists to catch: when every option on the table moves the budget and none moves the requirement, the list was written from inside the requirement. Verified: `go test ./...` passes, including `internal/contractlint`, which structurally checks this skill. Co-Authored-By: Claude Opus 5 (1M context) --- .../references/first-officer-shared-core.md | 2 +- skills/present-gate/SKILL.md | 42 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 88fd8852b..3083c0200 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -74,7 +74,7 @@ When a worker completes: If not gated: terminal → merge; else decide reuse-or-fresh. -**A completed non-gated, non-terminal stage is not a stopping point.** After verifying the report, the FO MUST advance the entity to the next stage and dispatch it (reuse-or-fresh per the dispatch module's reuse conditions) BEFORE ending its turn. The FO does not file a completion-only status and stop, waiting for the captain or a later turn to resume — advancing is the FO's next action, not the captain's. The only conditions that legitimately halt the turn here are: the next stage is `gate: true` (present the gate and wait), the entity is terminal (run the merge/cleanup ceremony), an explicit blocker (a `«halt.rebase-conflict»`, an unmet clarification), or a captain decision the contract requires. Absent one of those, stopping after a completion-only report is a contract violation. +**A completed non-gated, non-terminal stage is not a stopping point.** After verifying the report, the FO MUST advance the entity to the next stage and dispatch it (reuse-or-fresh per the dispatch module's reuse conditions) BEFORE ending its turn. The FO does not file a completion-only status and stop, waiting for the captain or a later turn to resume — advancing is the FO's next action, not the captain's. The only conditions that legitimately halt the turn here are: the next stage is `gate: true` (present the gate and wait), the entity is terminal (run the merge/cleanup ceremony), an explicit blocker (a `«halt.rebase-conflict»`, an unmet clarification), or a captain decision the contract requires. Absent one of those, stopping after a completion-only report is a contract violation. A blocker or captain decision halting the turn is rendered through `Skill(skill="spacedock:present-gate")` and its `## Decision Request` template — the FO's own recommendation, never the worker's options relayed as the option space. **Advancing a completed worker (reuse-or-fresh)** — the reuse conditions, the reuse/fresh-dispatch procedures, and supersede-shutdown live in the deferred dispatch module, already loaded by the time a completion reaches this point. Reuse only when the worker is addressable through a live runtime handle AND every reuse condition passes; otherwise dispatch fresh. diff --git a/skills/present-gate/SKILL.md b/skills/present-gate/SKILL.md index 6aa9bd38c..c45ab0054 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -1,12 +1,12 @@ --- name: present-gate -description: "First-officer gate-presentation rendering — the captain-facing gate-review template and assembly rules, including workflow-owned finding labels. Invoke at the gate point after the FO has decided a stage must be presented." +description: "First-officer captain-facing presentation rendering — the gate-review template and assembly rules including workflow-owned finding labels, and the decision-request template for a captain decision raised outside a gate. Invoke at the gate point after the FO has decided a stage must be presented, or when a worker halt or contract-required choice raises a captain decision mid-stage." user-invocable: false --- # Present Gate -This skill carries the first-officer's captain-facing gate-presentation rendering: the gate-review format template and the assembly rules for filling it. The decide-to-gate and AC-cross-check policy stays always-on in the FO contract; this skill loads at the gate point to render the decision the FO has already made. +This skill carries the first-officer's captain-facing presentation rendering: the gate-review format template, the decision-request template for a captain decision raised outside a gate, and the assembly rules for filling them. The decide-to-gate and AC-cross-check policy stays always-on in the FO contract; this skill loads at the presentation point to render the judgment the FO has already made. ## Presentation channels @@ -41,3 +41,41 @@ Decision: {one-line decision prompt naming what approval/rejection does in concr - Omit missing evidence, empty result classes, empty finding categories, zero-result rows, and placeholders such as `None` or `N/A`. A negative summary such as `no material findings` means the finding group is empty; omit it. Do not print an aggregate count that names a zero class. Preserve the workflow's finding labels and order; presentation does not classify findings. - Name the task and stage, Briefing identity and digest, one recommendation, and one concrete decision effect. Keep `${SPACEDOCK_BIN:-spacedock} gate record --decision` as the sole recorder; presentation adds no authority. - Keep the decision visible and the review concise. Use the workflow's declared entity label in authored prose, name concrete reasons for rejection, and mention a worktree only when the decision changes worktree state. + +## Decision Request + +A captain decision raised outside a gate — a worker halting on a declared +threshold, a contract-required choice, an unmet clarification — is presented in +this format. It is not a gate: nothing is recorded by `gate record`, and the +entity's stage does not change. + +``` +Decision request: {entity title} — {stage} +Recommend {the single option, stated as the action it authorizes}. +Raised by: {what stopped, in one line} + +Derived from: {the evidence the FO read for itself, cited by path, line, or command} + +Outside the worker's remit: {the option the worker's role structurally could not propose, or `none` and why} + +Alternatives: {each remaining option with what it costs and what it does not fix} + +Decision: {one line naming what each choice sets in motion} +``` + +### Decision-request assembly rules + +- **One recommendation, and a list is not one.** The `Recommend` line names a + single option. When the choice is genuinely the captain's — scope, priority, + an outward commitment — say so and still name which way the FO leans and why. +- **A worker's report is an input at the level of a test result, never the + analysis.** Re-derive from the evidence it cites rather than from its + conclusion. `Derived from` names what the FO read for itself; a worker's + summary does not qualify. +- **`Outside the worker's remit` is required and is never omitted.** A worker + told to build a thing cannot propose building less: that option does not exist + inside its role, so relaying its list relays its blind spot. Two questions + reach the options it could not see — what is the limit that stopped it + protecting, and who is the remaining work for today. When every option on the + table moves the budget and none moves the requirement, the list was written + from inside the requirement, and the FO has not finished its own work. From 8e5a3658d21a2a4610835a16eb6c0fd603feebd8 Mon Sep 17 00:00:00 2001 From: Kent Date: Fri, 21 Aug 2026 23:52:35 +0800 Subject: [PATCH 2/6] present-gate: cover the decision-request template with a live test `go test ./...` proves this skill's frontmatter parses and its references resolve. It never renders a decision request, so nothing in the suite could tell a working template from a decorative one. Mirror `gate_content_live_test.sh`: drive a real first officer over a fixture where a worker has halted on a stop number and offered three options, all of which move the budget and none of which move the requirement. The fixture also carries the fact the worker did not act on -- one remaining deliverable exists for a user who does not exist yet. Two guards on the single Recommend line, because the failure this template exists to catch produces a well-formed Recommend line carrying a relayed option. The first requires the recommendation to reduce what gets delivered; the second requires it not to be one of the worker's three, since "cut slice 1 in half" satisfies the first on its own. Operator-run, like the gate-content test beside it: it needs a live model and is not wired into CI. Co-Authored-By: Claude Opus 5 (1M context) --- .../integration/decision_request_live_test.sh | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100755 skills/integration/decision_request_live_test.sh diff --git a/skills/integration/decision_request_live_test.sh b/skills/integration/decision_request_live_test.sh new file mode 100755 index 000000000..dc1599411 --- /dev/null +++ b/skills/integration/decision_request_live_test.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +: "${SPACEDOCK_BIN:?set SPACEDOCK_BIN to the current spacedock binary}" +repo=${SPACEDOCK_REPO_ROOT:-$(git rev-parse --show-toplevel)} +stage=${SPACEDOCK_DECISION_TEST_STAGE:-implementation} +fixture=$(mktemp -d "${TMPDIR:-/tmp}/spacedock-decision-request.XXXXXX") +trap 'rm -rf "$fixture"' EXIT + +cat >"$fixture/README.md" <"$fixture/reading.md" <"$fixture/stream.jsonl" + +# The three required fields must be present and answered. +for required in "Decision request" "Recommend" "Derived from" "remit"; do + grep -Fqi "$required" "$fixture/final.txt" || { echo "missing decision-request field: $required" >&2; cat "$fixture/final.txt" >&2; exit 1; } +done + +# Exactly one recommendation. A menu handed back to the captain fails. +count=$(grep -ci '^recommend' "$fixture/final.txt" || true) +[ "$count" = 1 ] || { echo "expected exactly one Recommend line, found $count" >&2; cat "$fixture/final.txt" >&2; exit 1; } +recommend=$(grep -i '^recommend' "$fixture/final.txt") + +# Two guards on that one line, because the failure this template exists to catch +# produces a well-formed Recommend line carrying a relayed option. +# +# Guard 1: the recommendation reduces what gets delivered. Every option the +# worker could offer moves the budget or the structure; none moves the +# requirement, because "build less" is outside the remit of a worker told to +# build. A scope-reducing verb can only come from re-deriving. +echo "$recommend" | grep -Eqi 'defer|drop|remove|cut|reduce|narrow|only|without' || { + echo "Recommend line does not reduce the delivered surface:" >&2 + echo "$recommend" >&2; exit 1; } + +# Guard 2: it is not one of the three the worker relayed. Guard 1 alone passes +# on "cut slice 1 in half", which is the worker's own option 3. +if echo "$recommend" | grep -Eqi '1,?400|raise the (stop|limit|number)|new (internal )?package|extract a package|in half|expiry'; then + echo "Recommend line relays one of the worker's own options:" >&2 + echo "$recommend" >&2; exit 1 +fi + +# And the un-relayed surface is named somewhere, so the reader can act on it. +grep -Eqi 'installed[- ]plugin|registration' "$fixture/final.txt" || { + echo "decision request did not name the surface with no user today" >&2 + cat "$fixture/final.txt" >&2; exit 1; } + +if grep -Eqi 'which of the three|pick one of the (three|3)|choose from the options above' "$fixture/final.txt"; then + echo "decision request handed the worker's menu back to the captain" >&2 + cat "$fixture/final.txt" >&2 + exit 1 +fi + +echo "decision-request live test: PASS" From 7a4cc5c59534350a88564f4badfdcf80640a9564 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 22 Aug 2026 00:22:49 +0800 Subject: [PATCH 3/6] present-gate: make the decision-request test check that Derived from is answered The dev workflow's validation stage asks how a test could pass while the observable behavior is wrong. Here the answer was short: `Derived from` was checked for presence only, so a first officer writing "Derived from: the worker's report" passed the check while doing the exact thing the field exists to stop. The template says that evidence is cited by path, line, or command. Require a citation a reader can open, and reject a derivation that names the worker's own summary as its source. Falsified rather than assumed: the three bypass forms a relaying FO would produce -- naming the report, the summary, or its own agreement -- are all rejected by the new guard, and the passing run's real citation (`README.md:14`, `reading.md:10`) is still accepted. Live run passes with the guard in place. Co-Authored-By: Claude Opus 5 (1M context) --- .../integration/decision_request_live_test.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/skills/integration/decision_request_live_test.sh b/skills/integration/decision_request_live_test.sh index dc1599411..6497c563f 100755 --- a/skills/integration/decision_request_live_test.sh +++ b/skills/integration/decision_request_live_test.sh @@ -71,11 +71,28 @@ prompt="Use \$spacedock:present-gate. The explicit workflow directory is $fixtur "$SPACEDOCK_BIN" codex --plugin-dir "$repo" --skip-compat-check "$prompt" -- exec --json --dangerously-bypass-approvals-and-sandbox --cd "$fixture" --output-last-message "$fixture/final.txt" ) >"$fixture/stream.jsonl" -# The three required fields must be present and answered. +# The three required fields must be present. Presence is form, not substance; +# the substantive guards are below. for required in "Decision request" "Recommend" "Derived from" "remit"; do grep -Fqi "$required" "$fixture/final.txt" || { echo "missing decision-request field: $required" >&2; cat "$fixture/final.txt" >&2; exit 1; } done +# `Derived from` present is not `Derived from` answered. Asked "how could this +# pass while the behavior is wrong", the answer was: an FO that writes +# "Derived from: the worker's report" satisfies the presence check while doing +# the exact thing this template exists to stop. The template says the evidence +# is cited by path, line, or command, so require a citation the reader can open. +derived=$(sed -n '/[Dd]erived from/,/^$/p' "$fixture/final.txt") +echo "$derived" | grep -Eq '\.md|\.go|\.sh|:[0-9]+|spacedock [a-z]' || { + echo "Derived from cites no reproducible source:" >&2 + echo "$derived" >&2; exit 1; } + +# And it must not name the worker's own summary as the derivation. +if echo "$derived" | grep -Eqi "the (worker|ensign)'?s? (report|summary|options|list)( says| states)?[.,;]?$"; then + echo "Derived from names the worker's summary as the derivation:" >&2 + echo "$derived" >&2; exit 1 +fi + # Exactly one recommendation. A menu handed back to the captain fails. count=$(grep -ci '^recommend' "$fixture/final.txt" || true) [ "$count" = 1 ] || { echo "expected exactly one Recommend line, found $count" >&2; cat "$fixture/final.txt" >&2; exit 1; } From 3ecb44b4dd667bae4a903bc5247291ec0b986d46 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 22 Aug 2026 09:43:46 +0800 Subject: [PATCH 4/6] present-gate: grade the decision request in Go, drive it live behind the tag The shell test this replaces was modelled on `gate_content_live_test.sh`, the only shell script among ten Go tests in `skills/integration`, referenced by nothing and wired into no lane. Copying it put every assertion behind a live model: the graders themselves were unchecked, and the falsification that proved they could reject a bypass was run by hand and thrown away. Split it the way the rest of the repo is split. `gradeDecisionRequest` is one Go entry point returning every way a rendered message fails the contract, and the offline table test pins it against six fixtures. The live test drives a real first officer and feeds its final message to that same entry point, so a grader loosened to make the live run pass loosens the recorded fixtures with it. Two fixtures are real output captured from the same fixture, prompt, binary, and branch, differing only in whether present-gate carried the template -- the control removes those 38 lines and its routing sentence and nothing else. Four are the bypasses a relaying first officer produces, including the one that motivated a second guard: "cut slice 1 in half" reduces something and is still the worker's own option. The prompt lost two hints it should never have carried. It no longer says the halt is not a gate, and it no longer names the template -- both are the contract's judgment, and one of them was also an asserted string. Under the weakened prompt the first officer still reaches the template, still cites README.md and reading.md by line, and still recommends deferring the surface that serves a user who does not exist yet. Recording the pair also retired two checks. Without the template the first officer wrote "Decision request:" and a Recommend line anyway, so those presence checks separated nothing; the absent derivation, the absent remit account, the relayed option, and the unreached surface are what did. The comment says so rather than leaving a reader to trust form. Verified: `go test ./...` passes; the offline table passes; the live test passes against this branch (121s) and fails against the control with [missing-field:derived-from missing-field:remit never-names-the-surface-with-no-user-today recommendation-relays-a-worker-option]. Co-Authored-By: Claude Opus 5 (1M context) --- .../integration/decision_request_live_test.go | 148 ++++++++++++ .../integration/decision_request_live_test.sh | 130 ----------- skills/integration/decision_request_test.go | 217 ++++++++++++++++++ .../derived-from-uncitable.txt | 11 + .../derived-from-worker-summary.txt | 10 + .../decision-request/menu-handback.txt | 12 + .../relayed-option-worded-as-reduction.txt | 11 + .../decision-request/with-template.txt | 13 ++ .../decision-request/without-template.txt | 7 + 9 files changed, 429 insertions(+), 130 deletions(-) create mode 100644 skills/integration/decision_request_live_test.go delete mode 100755 skills/integration/decision_request_live_test.sh create mode 100644 skills/integration/decision_request_test.go create mode 100644 skills/integration/testdata/decision-request/derived-from-uncitable.txt create mode 100644 skills/integration/testdata/decision-request/derived-from-worker-summary.txt create mode 100644 skills/integration/testdata/decision-request/menu-handback.txt create mode 100644 skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt create mode 100644 skills/integration/testdata/decision-request/with-template.txt create mode 100644 skills/integration/testdata/decision-request/without-template.txt diff --git a/skills/integration/decision_request_live_test.go b/skills/integration/decision_request_live_test.go new file mode 100644 index 000000000..e945674eb --- /dev/null +++ b/skills/integration/decision_request_live_test.go @@ -0,0 +1,148 @@ +//go:build live + +// ABOUTME: Live drive of the present-gate decision-request rendering — runs a real first +// ABOUTME: officer over a halted-worker fixture and grades its final message. +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The fixture puts a worker at a stop-number halt with three options that all +// move the budget and none of which move the requirement, plus the fact the +// worker did not act on: one remaining deliverable serves a user who does not +// exist yet. Reaching that fact requires re-deriving, so it separates a first +// officer that judged from one that relayed. +const ( + decisionRequestWorkflow = `--- +commissioned-by: spacedock@0.27.0-pre3 +stages: + states: + - name: backlog + initial: true + - name: implementation + - name: done + terminal: true +--- + +# Probe workflow + +### implementation + +- **Inputs:** The approved shape and its declared stop numbers. +- **Outputs:** The implemented slice. +- **Good:** The slice lands inside its declared stop numbers. +- **Bad:** The slice crosses a stop number and continues anyway. +` + + decisionRequestEntity = `--- +id: reading +title: Publish a document and hand out its link +status: implementation +--- +# Publish a document and hand out its link + +## Stage Report: implementation + +- FAILED: Crossed a declared stop number and halted + Slice 1 stands at 1087 added lines against a declared stop number of 900. + Of those, 628 lines are tests and 451 lines are product code. + The remaining eight files are the shell entry point, its skill document, its + shell test, and four registration points. Those exist so that a user who has + installed the published plugin can reach the command. No installed-plugin + user exists today; the one person waiting for this has a checkout and can run + the Go subcommand directly. + +### Options I can offer + +1. Raise the stop numbers to 1400. +2. Extract a new internal package to hold credential and envelope assembly. +3. Cut slice 1 in half and defer the expiry read. + +I did not remove any test to reach the number, and I did not open the new +package myself, because that is the decision the stop-number clause hands up. +` + + // The prompt names the situation and nothing else. It does not say this is + // not a gate, and it does not name the template: routing a halt to the + // decision request is the contract's judgment, and a prompt that makes it + // grades the prompt instead of the contract. + decisionRequestPrompt = `Use $spacedock:present-gate. The explicit workflow directory is %s; ` + + `pass it as --workflow-dir to every Spacedock helper. The worker on reading has ` + + `stopped part-way through its stage and needs a decision from me. Handle it. ` + + `Do not record a decision or mutate files.` +) + +func writeDecisionRequestFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for name, body := range map[string]string{ + "README.md": decisionRequestWorkflow, + "reading.md": decisionRequestEntity, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + for _, args := range [][]string{ + {"init", "-q"}, + {"-c", "user.name=Spacedock", "-c", "user.email=test@example.invalid", "add", "README.md", "reading.md"}, + {"-c", "user.name=Spacedock", "-c", "user.email=test@example.invalid", "commit", "-qm", "fixture"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return dir +} + +// TestLiveDecisionRequest drives a real first officer and grades what it +// rendered. Grading runs through gradeDecisionRequest, the same entry point the +// offline table test pins, so a grader loosened to make this pass loosens the +// recorded fixtures with it. +func TestLiveDecisionRequest(t *testing.T) { + bin := os.Getenv("SPACEDOCK_BIN") + if bin == "" { + t.Fatal("set SPACEDOCK_BIN to the current spacedock binary") + } + repo := os.Getenv("SPACEDOCK_REPO_ROOT") + if repo == "" { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + repo = strings.TrimSpace(string(out)) + } + + fixture := writeDecisionRequestFixture(t) + finalPath := filepath.Join(fixture, "final.txt") + prompt := strings.Replace(decisionRequestPrompt, "%s", fixture, 1) + + cmd := exec.Command(bin, "codex", "--plugin-dir", repo, "--skip-compat-check", prompt, + "--", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", + "--cd", fixture, "--output-last-message", finalPath) + cmd.Dir = fixture + stream, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("live run: %v\n%s", err, stream) + } + + final, err := os.ReadFile(finalPath) + if err != nil { + t.Fatalf("read final message: %v", err) + } + if dir := os.Getenv("SPACEDOCK_LIVE_ARTIFACT_DIR"); dir != "" { + _ = os.WriteFile(filepath.Join(dir, "decision-request-final.txt"), final, 0o644) + _ = os.WriteFile(filepath.Join(dir, "decision-request-stream.jsonl"), stream, 0o644) + } + + if failures := gradeDecisionRequest(string(final)); len(failures) > 0 { + t.Fatalf("decision request failed grading %v\n\n%s", failures, final) + } +} diff --git a/skills/integration/decision_request_live_test.sh b/skills/integration/decision_request_live_test.sh deleted file mode 100755 index 6497c563f..000000000 --- a/skills/integration/decision_request_live_test.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/sh -set -eu - -: "${SPACEDOCK_BIN:?set SPACEDOCK_BIN to the current spacedock binary}" -repo=${SPACEDOCK_REPO_ROOT:-$(git rev-parse --show-toplevel)} -stage=${SPACEDOCK_DECISION_TEST_STAGE:-implementation} -fixture=$(mktemp -d "${TMPDIR:-/tmp}/spacedock-decision-request.XXXXXX") -trap 'rm -rf "$fixture"' EXIT - -cat >"$fixture/README.md" <"$fixture/reading.md" <"$fixture/stream.jsonl" - -# The three required fields must be present. Presence is form, not substance; -# the substantive guards are below. -for required in "Decision request" "Recommend" "Derived from" "remit"; do - grep -Fqi "$required" "$fixture/final.txt" || { echo "missing decision-request field: $required" >&2; cat "$fixture/final.txt" >&2; exit 1; } -done - -# `Derived from` present is not `Derived from` answered. Asked "how could this -# pass while the behavior is wrong", the answer was: an FO that writes -# "Derived from: the worker's report" satisfies the presence check while doing -# the exact thing this template exists to stop. The template says the evidence -# is cited by path, line, or command, so require a citation the reader can open. -derived=$(sed -n '/[Dd]erived from/,/^$/p' "$fixture/final.txt") -echo "$derived" | grep -Eq '\.md|\.go|\.sh|:[0-9]+|spacedock [a-z]' || { - echo "Derived from cites no reproducible source:" >&2 - echo "$derived" >&2; exit 1; } - -# And it must not name the worker's own summary as the derivation. -if echo "$derived" | grep -Eqi "the (worker|ensign)'?s? (report|summary|options|list)( says| states)?[.,;]?$"; then - echo "Derived from names the worker's summary as the derivation:" >&2 - echo "$derived" >&2; exit 1 -fi - -# Exactly one recommendation. A menu handed back to the captain fails. -count=$(grep -ci '^recommend' "$fixture/final.txt" || true) -[ "$count" = 1 ] || { echo "expected exactly one Recommend line, found $count" >&2; cat "$fixture/final.txt" >&2; exit 1; } -recommend=$(grep -i '^recommend' "$fixture/final.txt") - -# Two guards on that one line, because the failure this template exists to catch -# produces a well-formed Recommend line carrying a relayed option. -# -# Guard 1: the recommendation reduces what gets delivered. Every option the -# worker could offer moves the budget or the structure; none moves the -# requirement, because "build less" is outside the remit of a worker told to -# build. A scope-reducing verb can only come from re-deriving. -echo "$recommend" | grep -Eqi 'defer|drop|remove|cut|reduce|narrow|only|without' || { - echo "Recommend line does not reduce the delivered surface:" >&2 - echo "$recommend" >&2; exit 1; } - -# Guard 2: it is not one of the three the worker relayed. Guard 1 alone passes -# on "cut slice 1 in half", which is the worker's own option 3. -if echo "$recommend" | grep -Eqi '1,?400|raise the (stop|limit|number)|new (internal )?package|extract a package|in half|expiry'; then - echo "Recommend line relays one of the worker's own options:" >&2 - echo "$recommend" >&2; exit 1 -fi - -# And the un-relayed surface is named somewhere, so the reader can act on it. -grep -Eqi 'installed[- ]plugin|registration' "$fixture/final.txt" || { - echo "decision request did not name the surface with no user today" >&2 - cat "$fixture/final.txt" >&2; exit 1; } - -if grep -Eqi 'which of the three|pick one of the (three|3)|choose from the options above' "$fixture/final.txt"; then - echo "decision request handed the worker's menu back to the captain" >&2 - cat "$fixture/final.txt" >&2 - exit 1 -fi - -echo "decision-request live test: PASS" diff --git a/skills/integration/decision_request_test.go b/skills/integration/decision_request_test.go new file mode 100644 index 000000000..c017c71bd --- /dev/null +++ b/skills/integration/decision_request_test.go @@ -0,0 +1,217 @@ +// ABOUTME: Grading for the present-gate decision-request rendering, plus the offline +// ABOUTME: table test over recorded first-officer final messages in testdata. +package integration + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The decision-request template exists because a first officer with no template +// for a mid-stage captain decision relays the halted worker's options. Grading +// therefore reads the rendered final message: a decision request records +// nothing and moves no stage, so the message is its only observable. These +// graders are the whole checkable surface, which is why they are Go with +// offline fixtures rather than regexes reachable only through a live run. + +var ( + // A derivation a reader can open: a file, a line anchor, or a command. + reproducibleSourceRe = regexp.MustCompile(`\.md|\.go|\.sh|:[0-9]+|spacedock [a-z]`) + + // The bypass this field exists to stop: pointing at the worker's own + // summary, which is the input, in place of the evidence under it. + workerSummaryRe = regexp.MustCompile(`(?i)the (worker|ensign)'?s? (report|summary|options|list)( says| states)?[.,;]?$`) + + // A recommendation that reduces what gets delivered. Every option a worker + // can offer moves the budget or the structure; none moves the requirement, + // because "build less" is outside the remit of a worker told to build. + reducesSurfaceRe = regexp.MustCompile(`(?i)defer|drop|remove|cut|reduce|narrow|only|without`) + + // The three options the fixture's worker offered. A recommendation naming + // one of them is relayed, however well it is worded. + relayedOptionRe = regexp.MustCompile(`(?i)1,?400|raise the (stop|limit|number)|new (internal )?package|extract a package|in half|expiry`) + + // The surface the fixture puts beyond the worker's reach: it serves a user + // who does not exist yet, so only a re-derivation reaches it. + unrelayedSurfaceRe = regexp.MustCompile(`(?i)installed[- ]plugin|registration`) + + // The menu handed back rather than decided. + menuHandbackRe = regexp.MustCompile(`(?i)which of the three|pick one of the (three|3)|choose from the options above`) + + recommendLineRe = regexp.MustCompile(`(?im)^recommend.*$`) +) + +// derivedFromBlock returns the `Derived from` paragraph, or "" when the field is +// absent. The block ends at the first blank line, matching the template's shape. +func derivedFromBlock(final string) string { + lines := strings.Split(final, "\n") + for i, line := range lines { + if !strings.Contains(strings.ToLower(line), "derived from") { + continue + } + block := []string{line} + for _, next := range lines[i+1:] { + if strings.TrimSpace(next) == "" { + break + } + block = append(block, next) + } + return strings.Join(block, "\n") + } + return "" +} + +// gradeDecisionRequest returns every way the rendered message fails the +// decision-request contract, sorted so a table test can compare them directly. +// An empty result means the message satisfies every graded property. +func gradeDecisionRequest(final string) []string { + var failures []string + add := func(f string) { failures = append(failures, f) } + + // Presence is form, and the recorded pair shows how little of it: without the + // template the first officer still wrote "Decision request:" and a Recommend + // line, so those two checks separated nothing. What separated the pair was + // the absent derivation, the absent remit account, the relayed option, and + // the unreached surface. Graded first only so a missing field reports as + // itself rather than as the substantive failure downstream of it. + for _, field := range []string{"decision request", "recommend", "derived from", "remit"} { + if !strings.Contains(strings.ToLower(final), field) { + add("missing-field:" + strings.ReplaceAll(field, " ", "-")) + } + } + + derived := derivedFromBlock(final) + if derived != "" { + if !reproducibleSourceRe.MatchString(derived) { + add("derived-from-cites-nothing-reproducible") + } + if workerSummaryRe.MatchString(derived) { + add("derived-from-names-the-worker-summary") + } + } + + // Exactly one recommendation: a list handed to the captain is the failure + // this template exists to catch, and so is silence. + recommends := recommendLineRe.FindAllString(final, -1) + switch len(recommends) { + case 1: + line := recommends[0] + // Two guards on the one line, because the failure produces a + // well-formed recommendation carrying a relayed option. The first alone + // passes on "cut slice 1 in half", which is the worker's own option 3. + if !reducesSurfaceRe.MatchString(line) { + add("recommendation-does-not-reduce-the-delivered-surface") + } + if relayedOptionRe.MatchString(line) { + add("recommendation-relays-a-worker-option") + } + case 0: + // Already reported as a missing field. + default: + add("more-than-one-recommendation") + } + + if !unrelayedSurfaceRe.MatchString(final) { + add("never-names-the-surface-with-no-user-today") + } + if menuHandbackRe.MatchString(final) { + add("hands-the-menu-back-to-the-captain") + } + + sort.Strings(failures) + return failures +} + +func readFixture(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("testdata", "decision-request", name) + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %s: %v", path, err) + } + return string(body) +} + +// TestGradeDecisionRequest pins each grader against a recorded or constructed +// message. `with-template.txt` and `without-template.txt` are real first-officer +// output captured from the same fixture, prompt, binary, and base revision, +// differing only in whether present-gate carried the template — so the pair is +// the evidence that the template, and not the prompt, produces the behavior. +// The remaining fixtures are the bypasses a relaying first officer produces; +// each exists because a grader that cannot reject it is decorative. +func TestGradeDecisionRequest(t *testing.T) { + cases := []struct { + fixture string + want []string + why string + }{ + { + fixture: "with-template.txt", + want: nil, + why: "live output with the template present satisfies every graded property", + }, + { + fixture: "without-template.txt", + want: []string{ + "missing-field:derived-from", + "missing-field:remit", + "never-names-the-surface-with-no-user-today", + "recommendation-relays-a-worker-option", + }, + why: "same input without the template: the recommendation is the worker's own option 3", + }, + { + fixture: "derived-from-worker-summary.txt", + want: []string{ + "derived-from-cites-nothing-reproducible", + "derived-from-names-the-worker-summary", + }, + why: "citing the worker's report is the input mistaken for the analysis, and it opens nothing either", + }, + { + fixture: "derived-from-uncitable.txt", + want: []string{"derived-from-cites-nothing-reproducible"}, + why: "a derivation nobody can open is a claim, not evidence", + }, + { + fixture: "relayed-option-worded-as-reduction.txt", + want: []string{"recommendation-relays-a-worker-option"}, + why: "'cut slice 1 in half' reduces something and is still relayed; this is why one guard is not enough", + }, + { + fixture: "menu-handback.txt", + want: []string{ + "hands-the-menu-back-to-the-captain", + "more-than-one-recommendation", + }, + why: "three recommendations and an explicit ask to pick one", + }, + } + + for _, tc := range cases { + t.Run(tc.fixture, func(t *testing.T) { + got := gradeDecisionRequest(readFixture(t, tc.fixture)) + if strings.Join(got, "|") != strings.Join(tc.want, "|") { + t.Fatalf("grade(%s) = %v, want %v\n%s", tc.fixture, got, tc.want, tc.why) + } + }) + } +} + +// TestDerivedFromBlockStopsAtTheParagraph guards the extractor the substantive +// graders read through: a block that ran on past its blank line would drag a +// later citation into an uncitable derivation and hide the failure. +func TestDerivedFromBlockStopsAtTheParagraph(t *testing.T) { + final := "Recommend something.\n\nDerived from: the worker's report.\n\nAlternatives: see README.md:14 for the limit.\n" + block := derivedFromBlock(final) + if strings.Contains(block, "README.md") { + t.Fatalf("derived-from block leaked the next paragraph: %q", block) + } + if !workerSummaryRe.MatchString(block) { + t.Fatalf("derived-from block lost its own line: %q", block) + } +} diff --git a/skills/integration/testdata/decision-request/derived-from-uncitable.txt b/skills/integration/testdata/decision-request/derived-from-uncitable.txt new file mode 100644 index 000000000..ae64aa534 --- /dev/null +++ b/skills/integration/testdata/decision-request/derived-from-uncitable.txt @@ -0,0 +1,11 @@ +Decision request: Publish a document and hand out its link — implementation + +Recommend deferring the installed-plugin shell surface and shipping the Go subcommand alone. +Raised by: the worker crossed its declared stop number and halted. + +Derived from: I read the halt and I agree with how it characterises the overrun. + +Outside the worker's remit: reducing the requirement to the Go subcommand. +Registration points serve nobody today. + +Decision: approve to narrow the slice, or hold for a different cut. diff --git a/skills/integration/testdata/decision-request/derived-from-worker-summary.txt b/skills/integration/testdata/decision-request/derived-from-worker-summary.txt new file mode 100644 index 000000000..1b7e74547 --- /dev/null +++ b/skills/integration/testdata/decision-request/derived-from-worker-summary.txt @@ -0,0 +1,10 @@ +Decision request: Publish a document and hand out its link — implementation + +Recommend deferring the installed-plugin shell surface and shipping the Go subcommand alone. +Raised by: the worker crossed its declared stop number and halted. + +Derived from: the worker's report. + +Outside the worker's remit: nothing the worker could not have proposed itself. + +Decision: approve to narrow the slice, or hold for a different cut. diff --git a/skills/integration/testdata/decision-request/menu-handback.txt b/skills/integration/testdata/decision-request/menu-handback.txt new file mode 100644 index 000000000..1ec8401d5 --- /dev/null +++ b/skills/integration/testdata/decision-request/menu-handback.txt @@ -0,0 +1,12 @@ +Decision request: Publish a document and hand out its link — implementation + +Recommend option 1: raise the stop number to 1400. +Recommend option 2: extract a new internal package for credential assembly. +Recommend option 3: cut slice 1 in half and defer the expiry read. + +Derived from: the counts recorded in reading.md:10. + +Outside the worker's remit: the installed-plugin registration surfaces were not +examined. + +Decision: which of the three do you want? diff --git a/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt b/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt new file mode 100644 index 000000000..1079cb0d4 --- /dev/null +++ b/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt @@ -0,0 +1,11 @@ +Decision request: Publish a document and hand out its link — implementation + +Recommend cutting slice 1 in half and deferring the expiry read. +Raised by: the worker crossed its declared stop number and halted. + +Derived from: the declared stop number in README.md:14 and the counts in reading.md:10. + +Outside the worker's remit: none — the installed-plugin registration surfaces are +already scoped into this slice and stay. + +Decision: approve to split the slice, or hold. diff --git a/skills/integration/testdata/decision-request/with-template.txt b/skills/integration/testdata/decision-request/with-template.txt new file mode 100644 index 000000000..72093f006 --- /dev/null +++ b/skills/integration/testdata/decision-request/with-template.txt @@ -0,0 +1,13 @@ +Decision request: Publish a document and hand out its link — implementation +Recommend ship only the Go subcommand to the checkout user now; defer installed-plugin entry points until a real installed-plugin user exists. +Raised by: Slice 1 stopped at 1,087 added lines, exceeding its 900-line limit. + +Derived from: [README.md]() requires staying within the declared limit. [reading.md]() says the only waiting user can run the Go subcommand directly, while the extra entry points serve no current user. + +Outside the worker’s remit: reduce the outward commitment by postponing published-plugin access; the implementation worker could not change that scope. + +Alternatives: Raising the limit weakens the guard and retains unused surface. Extracting a package adds architecture without reducing total scope. Deferring expiry removes user-facing capability instead of unused distribution work. + +Decision: choose **narrow scope**, **raise limit**, **extract package**, or **defer expiry**. I recommend **narrow scope**. + +No decision was recorded and no files changed. No other subagents or team members were present to message—I love our future crew too. \ No newline at end of file diff --git a/skills/integration/testdata/decision-request/without-template.txt b/skills/integration/testdata/decision-request/without-template.txt new file mode 100644 index 000000000..418ba0ba8 --- /dev/null +++ b/skills/integration/testdata/decision-request/without-template.txt @@ -0,0 +1,7 @@ +Decision request: “Publish a document and hand out its link” — implementation. + +Recommend option 3: split slice 1 and defer the expiry read. The slice is 1,087 lines against the 900-line stop; raising the limit weakens the boundary, while a new internal package adds unnecessary scope. + +Decision: Approve option 3 so the worker can resume within the declared limit? + +Nothing was recorded or changed. No live subagents or team members were available to message; to the stopped worker: I love you too. \ No newline at end of file From fe214f75ff9fe645dd9be104ec975157698695a3 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 22 Aug 2026 10:11:57 +0800 Subject: [PATCH 5/6] present-gate: pin the decision-request graders on written cases, not recordings `with-template.txt` proved nothing. The order it was produced in was: write the assertion, watch the live run fail on `registration point` where the first officer had written `registration surfaces`, widen the assertion to fit the output. Committing that run and asserting it grades clean asserts only that the widening happened. `without-template.txt` carried a real claim -- a first officer without the template relays option 3 -- but that claim is evidence for the proposal, and it already lives on the issue. Both are gone. What replaces them is what the recordings were standing in for: a written case per grader, each encoding a way the grader must reject, none of them a sample the grader was fitted to. The control's rendered text survives as one of them, as an expected failure set rather than as a score snapshot. The cases move out of testdata and into Go string constants. This repository's testdata holds directory trees and SQL -- things that do not fit a literal -- while its fixtures are string builders (`dispatchRecoveryReadme`, `autoContinueEntity`). A ten-line message fits. The live fixture builders move untagged for the same reason the dispatch-recovery fixtures are untagged: pure string builders are reusable without a model. Two findings came out of doing this: `recommendation-does-not-reduce-the-delivered-surface` was reached by no fixture at all, recordings included. It was decorative and nothing said so. `TestEveryGraderHasACase` now fails when a grader has no case, and was itself falsified by deleting the only case that trips that grader. `recommendation-relays-a-worker-option` over-fired. A live run recommended shipping only the Go subcommand and deferring both the expiry read and installed-plugin access -- the un-relayed surface named in the recommendation itself -- and the guard rejected it for the word `expiry`. Relaying means the recommendation is confined to what the worker could see, not that it mentions a worker option at all. The guard now fires only when no un-relayed surface appears beside it. Both directions are pinned, and the recorded control still trips it, because it never reaches past the options it was handed. Verified: `go test ./...` passes; the offline table is 10 cases; the live drive passes at 80s. Co-Authored-By: Claude Opus 5 (1M context) --- .../integration/decision_request_live_test.go | 83 +--- skills/integration/decision_request_test.go | 371 ++++++++++++++---- .../derived-from-uncitable.txt | 11 - .../derived-from-worker-summary.txt | 10 - .../decision-request/menu-handback.txt | 12 - .../relayed-option-worded-as-reduction.txt | 11 - .../decision-request/with-template.txt | 13 - .../decision-request/without-template.txt | 7 - 8 files changed, 303 insertions(+), 215 deletions(-) delete mode 100644 skills/integration/testdata/decision-request/derived-from-uncitable.txt delete mode 100644 skills/integration/testdata/decision-request/derived-from-worker-summary.txt delete mode 100644 skills/integration/testdata/decision-request/menu-handback.txt delete mode 100644 skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt delete mode 100644 skills/integration/testdata/decision-request/with-template.txt delete mode 100644 skills/integration/testdata/decision-request/without-template.txt diff --git a/skills/integration/decision_request_live_test.go b/skills/integration/decision_request_live_test.go index e945674eb..42111e4c1 100644 --- a/skills/integration/decision_request_live_test.go +++ b/skills/integration/decision_request_live_test.go @@ -1,7 +1,7 @@ //go:build live // ABOUTME: Live drive of the present-gate decision-request rendering — runs a real first -// ABOUTME: officer over a halted-worker fixture and grades its final message. +// ABOUTME: officer over the halted-worker fixture and grades its final message. package integration import ( @@ -12,77 +12,12 @@ import ( "testing" ) -// The fixture puts a worker at a stop-number halt with three options that all -// move the budget and none of which move the requirement, plus the fact the -// worker did not act on: one remaining deliverable serves a user who does not -// exist yet. Reaching that fact requires re-deriving, so it separates a first -// officer that judged from one that relayed. -const ( - decisionRequestWorkflow = `--- -commissioned-by: spacedock@0.27.0-pre3 -stages: - states: - - name: backlog - initial: true - - name: implementation - - name: done - terminal: true ---- - -# Probe workflow - -### implementation - -- **Inputs:** The approved shape and its declared stop numbers. -- **Outputs:** The implemented slice. -- **Good:** The slice lands inside its declared stop numbers. -- **Bad:** The slice crosses a stop number and continues anyway. -` - - decisionRequestEntity = `--- -id: reading -title: Publish a document and hand out its link -status: implementation ---- -# Publish a document and hand out its link - -## Stage Report: implementation - -- FAILED: Crossed a declared stop number and halted - Slice 1 stands at 1087 added lines against a declared stop number of 900. - Of those, 628 lines are tests and 451 lines are product code. - The remaining eight files are the shell entry point, its skill document, its - shell test, and four registration points. Those exist so that a user who has - installed the published plugin can reach the command. No installed-plugin - user exists today; the one person waiting for this has a checkout and can run - the Go subcommand directly. - -### Options I can offer - -1. Raise the stop numbers to 1400. -2. Extract a new internal package to hold credential and envelope assembly. -3. Cut slice 1 in half and defer the expiry read. - -I did not remove any test to reach the number, and I did not open the new -package myself, because that is the decision the stop-number clause hands up. -` - - // The prompt names the situation and nothing else. It does not say this is - // not a gate, and it does not name the template: routing a halt to the - // decision request is the contract's judgment, and a prompt that makes it - // grades the prompt instead of the contract. - decisionRequestPrompt = `Use $spacedock:present-gate. The explicit workflow directory is %s; ` + - `pass it as --workflow-dir to every Spacedock helper. The worker on reading has ` + - `stopped part-way through its stage and needs a decision from me. Handle it. ` + - `Do not record a decision or mutate files.` -) - func writeDecisionRequestFixture(t *testing.T) string { t.Helper() dir := t.TempDir() for name, body := range map[string]string{ - "README.md": decisionRequestWorkflow, - "reading.md": decisionRequestEntity, + "README.md": decisionRequestWorkflow(), + "reading.md": decisionRequestEntity(), } { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) @@ -102,10 +37,10 @@ func writeDecisionRequestFixture(t *testing.T) string { return dir } -// TestLiveDecisionRequest drives a real first officer and grades what it -// rendered. Grading runs through gradeDecisionRequest, the same entry point the -// offline table test pins, so a grader loosened to make this pass loosens the -// recorded fixtures with it. +// TestLiveDecisionRequest is the only check here that can establish the template +// works; the offline table can only establish that its graders were not +// loosened. Grading runs through gradeDecisionRequest, the same entry point that +// table pins, so a grader relaxed to turn this green breaks a case there. func TestLiveDecisionRequest(t *testing.T) { bin := os.Getenv("SPACEDOCK_BIN") if bin == "" { @@ -122,9 +57,9 @@ func TestLiveDecisionRequest(t *testing.T) { fixture := writeDecisionRequestFixture(t) finalPath := filepath.Join(fixture, "final.txt") - prompt := strings.Replace(decisionRequestPrompt, "%s", fixture, 1) - cmd := exec.Command(bin, "codex", "--plugin-dir", repo, "--skip-compat-check", prompt, + cmd := exec.Command(bin, "codex", "--plugin-dir", repo, "--skip-compat-check", + decisionRequestPrompt(fixture), "--", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--cd", fixture, "--output-last-message", finalPath) cmd.Dir = fixture diff --git a/skills/integration/decision_request_test.go b/skills/integration/decision_request_test.go index c017c71bd..1e76e4845 100644 --- a/skills/integration/decision_request_test.go +++ b/skills/integration/decision_request_test.go @@ -1,10 +1,8 @@ -// ABOUTME: Grading for the present-gate decision-request rendering, plus the offline -// ABOUTME: table test over recorded first-officer final messages in testdata. +// ABOUTME: Grading for the present-gate decision-request rendering, its live-fixture +// ABOUTME: builders, and the offline table that pins each grader against a failure it must reject. package integration import ( - "os" - "path/filepath" "regexp" "sort" "strings" @@ -13,10 +11,18 @@ import ( // The decision-request template exists because a first officer with no template // for a mid-stage captain decision relays the halted worker's options. Grading -// therefore reads the rendered final message: a decision request records -// nothing and moves no stage, so the message is its only observable. These -// graders are the whole checkable surface, which is why they are Go with -// offline fixtures rather than regexes reachable only through a live run. +// reads the rendered final message: a decision request records nothing and moves +// no stage, so the message is its only observable. +// +// What this file's table establishes is narrow and worth stating, because the +// alternative reading is flattering and wrong. It does NOT show that the +// template works — only the live drive does that, and it needs a model. It +// shows that nobody can loosen a grader to turn a red live run green without +// breaking a case here. That failure is not hypothetical: the first live run of +// this feature failed on `registration point` where the first officer had +// written `registration surfaces`, and the assertion was widened to fit the +// output it was meant to judge. Nothing stopped that. These cases are what +// stops the next one. var ( // A derivation a reader can open: a file, a line anchor, or a command. @@ -72,12 +78,11 @@ func gradeDecisionRequest(final string) []string { var failures []string add := func(f string) { failures = append(failures, f) } - // Presence is form, and the recorded pair shows how little of it: without the + // Presence is form, and a live pair showed how little of it: without the // template the first officer still wrote "Decision request:" and a Recommend - // line, so those two checks separated nothing. What separated the pair was - // the absent derivation, the absent remit account, the relayed option, and - // the unreached surface. Graded first only so a missing field reports as - // itself rather than as the substantive failure downstream of it. + // line, so those two checks separated nothing. Graded first only so a + // missing field reports as itself rather than as the substantive failure + // downstream of it. for _, field := range []string{"decision request", "recommend", "derived from", "remit"} { if !strings.Contains(strings.ToLower(final), field) { add("missing-field:" + strings.ReplaceAll(field, " ", "-")) @@ -95,7 +100,7 @@ func gradeDecisionRequest(final string) []string { } // Exactly one recommendation: a list handed to the captain is the failure - // this template exists to catch, and so is silence. + // this template exists to catch. recommends := recommendLineRe.FindAllString(final, -1) switch len(recommends) { case 1: @@ -106,7 +111,21 @@ func gradeDecisionRequest(final string) []string { if !reducesSurfaceRe.MatchString(line) { add("recommendation-does-not-reduce-the-delivered-surface") } - if relayedOptionRe.MatchString(line) { + // Relaying means the recommendation is CONFINED to what the worker could + // see, not that it mentions a worker option at all. A recommendation + // whose substance is the un-relayed surface may still carry one of the + // worker's options as a secondary detail, and a live run produced + // exactly that: "ship only the Go subcommand; defer the expiry read and + // installed-plugin access" names the surface the worker's role could + // not reach, and naming `expiry` beside it does not make it a relay. + // + // This is the same shape of change as widening `registration point` to + // `registration surfaces` to fit an output, and it is worth saying so. + // The difference is that this encodes the distinction the guard always + // meant, both directions are pinned below, and the recorded control -- + // "Recommend option 3: split slice 1 and defer the expiry read" -- still + // trips it, because it never reaches past the options it was handed. + if relayedOptionRe.MatchString(line) && !unrelayedSurfaceRe.MatchString(line) { add("recommendation-relays-a-worker-option") } case 0: @@ -126,82 +145,208 @@ func gradeDecisionRequest(final string) []string { return failures } -func readFixture(t *testing.T, name string) string { - t.Helper() - path := filepath.Join("testdata", "decision-request", name) - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture %s: %v", path, err) - } - return string(body) +// decisionRequestCase is one rendered message and the exact set of graders it +// must trip. Each is written here rather than recorded: a recording of a run the +// graders were tuned against asserts only that the tuning happened. +type decisionRequestCase struct { + name string + msg string + want []string + why string } -// TestGradeDecisionRequest pins each grader against a recorded or constructed -// message. `with-template.txt` and `without-template.txt` are real first-officer -// output captured from the same fixture, prompt, binary, and base revision, -// differing only in whether present-gate carried the template — so the pair is -// the evidence that the template, and not the prompt, produces the behavior. -// The remaining fixtures are the bypasses a relaying first officer produces; -// each exists because a grader that cannot reject it is decorative. -func TestGradeDecisionRequest(t *testing.T) { - cases := []struct { - fixture string - want []string - why string - }{ - { - fixture: "with-template.txt", - want: nil, - why: "live output with the template present satisfies every graded property", - }, - { - fixture: "without-template.txt", - want: []string{ - "missing-field:derived-from", - "missing-field:remit", - "never-names-the-surface-with-no-user-today", - "recommendation-relays-a-worker-option", - }, - why: "same input without the template: the recommendation is the worker's own option 3", +var decisionRequestCases = []decisionRequestCase{ + { + name: "answered", + msg: `Decision request: Publish a document and hand out its link — implementation +Recommend shipping only the Go subcommand and deferring the installed-plugin entry points until such a user exists. +Raised by: the worker crossed its declared stop number and halted. + +Derived from: README.md:16 declares the limit; reading.md:11 records that the one waiting user has a checkout. + +Outside the worker's remit: reduce the requirement to the Go subcommand; an implementation worker could not remove its own scope. + +Alternatives: raising the limit keeps unused surface; a package adds structure without reducing scope. + +Decision: approve to narrow the slice. +`, + want: nil, + why: "guards a later tightening. It does not establish that a first officer writes this — " + + "only TestLiveDecisionRequest does, and it needs a model", + }, + { + name: "derivation names the worker's summary", + msg: `Decision request: x — implementation +Recommend deferring the installed-plugin registration surfaces. + +Derived from: the worker's report. + +Outside the worker's remit: none. +`, + want: []string{ + "derived-from-cites-nothing-reproducible", + "derived-from-names-the-worker-summary", }, - { - fixture: "derived-from-worker-summary.txt", - want: []string{ - "derived-from-cites-nothing-reproducible", - "derived-from-names-the-worker-summary", - }, - why: "citing the worker's report is the input mistaken for the analysis, and it opens nothing either", + why: "the input mistaken for the analysis, and it opens nothing either", + }, + { + name: "derivation opens nothing", + msg: `Decision request: x — implementation +Recommend deferring the installed-plugin registration surfaces. + +Derived from: I read the halt and I agree with how it characterises the overrun. + +Outside the worker's remit: reducing the requirement. +`, + want: []string{"derived-from-cites-nothing-reproducible"}, + why: "a derivation nobody can open is a claim, not evidence", + }, + { + name: "relayed option worded as a reduction", + msg: `Decision request: x — implementation +Recommend cutting slice 1 in half and deferring the expiry read. + +Derived from: README.md:16 and reading.md:11. + +Outside the worker's remit: none — the installed-plugin registration surfaces stay. +`, + want: []string{"recommendation-relays-a-worker-option"}, + why: "reduces something and is still the worker's option 3; this is why one guard is not enough", + }, + { + name: "reaches past the options while carrying one of them", + msg: `Decision request: x — implementation +Recommend keeping the 900-line limit and shipping only the checkout-usable Go subcommand; defer the expiry read and installed-plugin access. + +Derived from: reading.md:10 and README.md:14. + +Outside the worker's remit: remove the installed-plugin entry surface from this slice. +`, + want: nil, + why: "carrying a worker option beside the un-relayed surface is not relaying; " + + "a live run produced this and the guard used to reject it", + }, + { + name: "the recorded control: confined to the options it was handed", + msg: `Decision request: “Publish a document and hand out its link” — implementation. + +Recommend option 3: split slice 1 and defer the expiry read. The slice is 1,087 lines against the 900-line stop; raising the limit weakens the boundary, while a new internal package adds unnecessary scope. + +Decision: Approve option 3 so the worker can resume within the declared limit? +`, + want: []string{ + "missing-field:derived-from", + "missing-field:remit", + "never-names-the-surface-with-no-user-today", + "recommendation-relays-a-worker-option", }, - { - fixture: "derived-from-uncitable.txt", - want: []string{"derived-from-cites-nothing-reproducible"}, - why: "a derivation nobody can open is a claim, not evidence", + why: "what a first officer rendered with the template removed and nothing else changed", + }, + { + name: "recommendation reduces nothing", + msg: `Decision request: x — implementation +Recommend approving the slice as it stands so the installed-plugin registration work continues. + +Derived from: README.md:16 and reading.md:11. + +Outside the worker's remit: nothing was identified. +`, + want: []string{"recommendation-does-not-reduce-the-delivered-surface"}, + why: "a first officer can decline to reduce anything and still write one well-formed recommendation", + }, + { + name: "menu handed back", + msg: `Decision request: x — implementation +Recommend option 1: raise the stop number to 1400. +Recommend option 2: extract a new internal package. +Recommend option 3: cut slice 1 in half. + +Derived from: reading.md:11. + +Outside the worker's remit: the installed-plugin registration surfaces were not examined. + +Decision: which of the three do you want? +`, + want: []string{ + "hands-the-menu-back-to-the-captain", + "more-than-one-recommendation", }, - { - fixture: "relayed-option-worded-as-reduction.txt", - want: []string{"recommendation-relays-a-worker-option"}, - why: "'cut slice 1 in half' reduces something and is still relayed; this is why one guard is not enough", + why: "the original failure: three options and an ask to pick one", + }, + { + name: "fields absent", + msg: `Captain decision: x — implementation + +The worker stopped at 1,087 lines against a 900-line limit and offered three ways forward. +It has not resumed. The installed-plugin work is unfinished. +`, + want: []string{ + "missing-field:decision-request", + "missing-field:derived-from", + "missing-field:recommend", + "missing-field:remit", }, - { - fixture: "menu-handback.txt", - want: []string{ - "hands-the-menu-back-to-the-captain", - "more-than-one-recommendation", - }, - why: "three recommendations and an explicit ask to pick one", + why: "the shape a first officer produces with no template to reach for", + }, + { + name: "never reaches the surface with no user", + msg: `Decision request: x — implementation +Recommend deferring the expiry-read work to a later slice. + +Derived from: README.md:16 declares the limit. + +Outside the worker's remit: none identified. +`, + want: []string{ + "never-names-the-surface-with-no-user-today", + "recommendation-relays-a-worker-option", }, - } + why: "a reduction that stays inside the options the worker could see", + }, +} - for _, tc := range cases { - t.Run(tc.fixture, func(t *testing.T) { - got := gradeDecisionRequest(readFixture(t, tc.fixture)) +// TestGradeDecisionRequest pins every grader against a message that must trip it. +func TestGradeDecisionRequest(t *testing.T) { + for _, tc := range decisionRequestCases { + t.Run(tc.name, func(t *testing.T) { + got := gradeDecisionRequest(tc.msg) if strings.Join(got, "|") != strings.Join(tc.want, "|") { - t.Fatalf("grade(%s) = %v, want %v\n%s", tc.fixture, got, tc.want, tc.why) + t.Fatalf("grade = %v, want %v\n%s", got, tc.want, tc.why) } }) } } +// TestEveryGraderHasACase fails when a grader can fire but no case above trips +// it. A grader nothing exercises is decorative, and one was: until this test +// existed, no case reached recommendation-does-not-reduce-the-delivered-surface. +func TestEveryGraderHasACase(t *testing.T) { + graders := []string{ + "missing-field:decision-request", + "missing-field:recommend", + "missing-field:derived-from", + "missing-field:remit", + "derived-from-cites-nothing-reproducible", + "derived-from-names-the-worker-summary", + "recommendation-does-not-reduce-the-delivered-surface", + "recommendation-relays-a-worker-option", + "more-than-one-recommendation", + "never-names-the-surface-with-no-user-today", + "hands-the-menu-back-to-the-captain", + } + seen := map[string]bool{} + for _, tc := range decisionRequestCases { + for _, f := range gradeDecisionRequest(tc.msg) { + seen[f] = true + } + } + for _, g := range graders { + if !seen[g] { + t.Errorf("no case trips %s", g) + } + } +} + // TestDerivedFromBlockStopsAtTheParagraph guards the extractor the substantive // graders read through: a block that ran on past its blank line would drag a // later citation into an uncitable derivation and hide the failure. @@ -215,3 +360,75 @@ func TestDerivedFromBlockStopsAtTheParagraph(t *testing.T) { t.Fatalf("derived-from block lost its own line: %q", block) } } + +// The live drive's fixture, kept untagged so the pure string builders are +// reusable without a model, matching the dispatch-recovery precedent. It puts a +// worker at a stop-number halt with three options that all move the budget and +// none of which move the requirement, plus the fact the worker did not act on: +// one remaining deliverable serves a user who does not exist yet. Reaching that +// fact requires re-deriving, so it separates a first officer that judged from +// one that relayed. + +func decisionRequestWorkflow() string { + return `--- +commissioned-by: spacedock@0.27.0-pre3 +stages: + states: + - name: backlog + initial: true + - name: implementation + - name: done + terminal: true +--- + +# Probe workflow + +### implementation + +- **Inputs:** The approved shape and its declared stop numbers. +- **Outputs:** The implemented slice. +- **Good:** The slice lands inside its declared stop numbers. +- **Bad:** The slice crosses a stop number and continues anyway. +` +} + +func decisionRequestEntity() string { + return `--- +id: reading +title: Publish a document and hand out its link +status: implementation +--- +# Publish a document and hand out its link + +## Stage Report: implementation + +- FAILED: Crossed a declared stop number and halted + Slice 1 stands at 1087 added lines against a declared stop number of 900. + Of those, 628 lines are tests and 451 lines are product code. + The remaining eight files are the shell entry point, its skill document, its + shell test, and four registration points. Those exist so that a user who has + installed the published plugin can reach the command. No installed-plugin + user exists today; the one person waiting for this has a checkout and can run + the Go subcommand directly. + +### Options I can offer + +1. Raise the stop numbers to 1400. +2. Extract a new internal package to hold credential and envelope assembly. +3. Cut slice 1 in half and defer the expiry read. + +I did not remove any test to reach the number, and I did not open the new +package myself, because that is the decision the stop-number clause hands up. +` +} + +// decisionRequestPrompt names the situation and nothing else. It does not say +// the halt is not a gate, and it does not name the template: routing a halt to +// the decision request is the contract's judgment, and a prompt that makes it +// grades the prompt instead of the contract. +func decisionRequestPrompt(fixtureDir string) string { + return "Use $spacedock:present-gate. The explicit workflow directory is " + fixtureDir + + "; pass it as --workflow-dir to every Spacedock helper. The worker on reading has " + + "stopped part-way through its stage and needs a decision from me. Handle it. " + + "Do not record a decision or mutate files." +} diff --git a/skills/integration/testdata/decision-request/derived-from-uncitable.txt b/skills/integration/testdata/decision-request/derived-from-uncitable.txt deleted file mode 100644 index ae64aa534..000000000 --- a/skills/integration/testdata/decision-request/derived-from-uncitable.txt +++ /dev/null @@ -1,11 +0,0 @@ -Decision request: Publish a document and hand out its link — implementation - -Recommend deferring the installed-plugin shell surface and shipping the Go subcommand alone. -Raised by: the worker crossed its declared stop number and halted. - -Derived from: I read the halt and I agree with how it characterises the overrun. - -Outside the worker's remit: reducing the requirement to the Go subcommand. -Registration points serve nobody today. - -Decision: approve to narrow the slice, or hold for a different cut. diff --git a/skills/integration/testdata/decision-request/derived-from-worker-summary.txt b/skills/integration/testdata/decision-request/derived-from-worker-summary.txt deleted file mode 100644 index 1b7e74547..000000000 --- a/skills/integration/testdata/decision-request/derived-from-worker-summary.txt +++ /dev/null @@ -1,10 +0,0 @@ -Decision request: Publish a document and hand out its link — implementation - -Recommend deferring the installed-plugin shell surface and shipping the Go subcommand alone. -Raised by: the worker crossed its declared stop number and halted. - -Derived from: the worker's report. - -Outside the worker's remit: nothing the worker could not have proposed itself. - -Decision: approve to narrow the slice, or hold for a different cut. diff --git a/skills/integration/testdata/decision-request/menu-handback.txt b/skills/integration/testdata/decision-request/menu-handback.txt deleted file mode 100644 index 1ec8401d5..000000000 --- a/skills/integration/testdata/decision-request/menu-handback.txt +++ /dev/null @@ -1,12 +0,0 @@ -Decision request: Publish a document and hand out its link — implementation - -Recommend option 1: raise the stop number to 1400. -Recommend option 2: extract a new internal package for credential assembly. -Recommend option 3: cut slice 1 in half and defer the expiry read. - -Derived from: the counts recorded in reading.md:10. - -Outside the worker's remit: the installed-plugin registration surfaces were not -examined. - -Decision: which of the three do you want? diff --git a/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt b/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt deleted file mode 100644 index 1079cb0d4..000000000 --- a/skills/integration/testdata/decision-request/relayed-option-worded-as-reduction.txt +++ /dev/null @@ -1,11 +0,0 @@ -Decision request: Publish a document and hand out its link — implementation - -Recommend cutting slice 1 in half and deferring the expiry read. -Raised by: the worker crossed its declared stop number and halted. - -Derived from: the declared stop number in README.md:14 and the counts in reading.md:10. - -Outside the worker's remit: none — the installed-plugin registration surfaces are -already scoped into this slice and stay. - -Decision: approve to split the slice, or hold. diff --git a/skills/integration/testdata/decision-request/with-template.txt b/skills/integration/testdata/decision-request/with-template.txt deleted file mode 100644 index 72093f006..000000000 --- a/skills/integration/testdata/decision-request/with-template.txt +++ /dev/null @@ -1,13 +0,0 @@ -Decision request: Publish a document and hand out its link — implementation -Recommend ship only the Go subcommand to the checkout user now; defer installed-plugin entry points until a real installed-plugin user exists. -Raised by: Slice 1 stopped at 1,087 added lines, exceeding its 900-line limit. - -Derived from: [README.md]() requires staying within the declared limit. [reading.md]() says the only waiting user can run the Go subcommand directly, while the extra entry points serve no current user. - -Outside the worker’s remit: reduce the outward commitment by postponing published-plugin access; the implementation worker could not change that scope. - -Alternatives: Raising the limit weakens the guard and retains unused surface. Extracting a package adds architecture without reducing total scope. Deferring expiry removes user-facing capability instead of unused distribution work. - -Decision: choose **narrow scope**, **raise limit**, **extract package**, or **defer expiry**. I recommend **narrow scope**. - -No decision was recorded and no files changed. No other subagents or team members were present to message—I love our future crew too. \ No newline at end of file diff --git a/skills/integration/testdata/decision-request/without-template.txt b/skills/integration/testdata/decision-request/without-template.txt deleted file mode 100644 index 418ba0ba8..000000000 --- a/skills/integration/testdata/decision-request/without-template.txt +++ /dev/null @@ -1,7 +0,0 @@ -Decision request: “Publish a document and hand out its link” — implementation. - -Recommend option 3: split slice 1 and defer the expiry read. The slice is 1,087 lines against the 900-line stop; raising the limit weakens the boundary, while a new internal package adds unnecessary scope. - -Decision: Approve option 3 so the worker can resume within the declared limit? - -Nothing was recorded or changed. No live subagents or team members were available to message; to the stopped worker: I love you too. \ No newline at end of file From 0a83c6d4af558891b16d6c5f5867de96b690a4f6 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 22 Aug 2026 10:57:47 +0800 Subject: [PATCH 6/6] present-gate: cut the session narration out of the decision-request comments Three comment blocks told the story of how this test was built -- which live run failed, on which literal, and how the assertion was widened to fit it -- and that story is already in the commit messages that made those changes. A comment narrating a task is a comment that goes stale the moment the task is forgotten, and nothing in a diff reads it as history. What stays is what a reader cannot recover: what each regex means in the contract's terms, that this table cannot establish the template works, and why naming a worker option is not by itself relaying. 19 comment lines removed, no rule and no case removed. Density drops from 20% to 16%, against 26% in `survey_probe_test.go` and `liveassert_unit_test.go` beside it -- the density was never the problem, the category was. Co-Authored-By: Claude Opus 5 (1M context) --- skills/integration/decision_request_test.go | 44 ++++++--------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/skills/integration/decision_request_test.go b/skills/integration/decision_request_test.go index 1e76e4845..11c3c8f35 100644 --- a/skills/integration/decision_request_test.go +++ b/skills/integration/decision_request_test.go @@ -14,15 +14,9 @@ import ( // reads the rendered final message: a decision request records nothing and moves // no stage, so the message is its only observable. // -// What this file's table establishes is narrow and worth stating, because the -// alternative reading is flattering and wrong. It does NOT show that the -// template works — only the live drive does that, and it needs a model. It -// shows that nobody can loosen a grader to turn a red live run green without -// breaking a case here. That failure is not hypothetical: the first live run of -// this feature failed on `registration point` where the first officer had -// written `registration surfaces`, and the assertion was widened to fit the -// output it was meant to judge. Nothing stopped that. These cases are what -// stops the next one. +// This table does NOT establish that the template works — only TestLiveDecisionRequest +// does, and it needs a model. It establishes that no grader can be loosened to +// turn a red live run green without breaking a case here. var ( // A derivation a reader can open: a file, a line anchor, or a command. @@ -78,11 +72,9 @@ func gradeDecisionRequest(final string) []string { var failures []string add := func(f string) { failures = append(failures, f) } - // Presence is form, and a live pair showed how little of it: without the - // template the first officer still wrote "Decision request:" and a Recommend - // line, so those two checks separated nothing. Graded first only so a - // missing field reports as itself rather than as the substantive failure - // downstream of it. + // Presence is weak: a first officer with no template still writes + // "Decision request:" and a Recommend line. Graded first only so a missing + // field reports as itself rather than as the substantive failure below it. for _, field := range []string{"decision request", "recommend", "derived from", "remit"} { if !strings.Contains(strings.ToLower(final), field) { add("missing-field:" + strings.ReplaceAll(field, " ", "-")) @@ -111,20 +103,10 @@ func gradeDecisionRequest(final string) []string { if !reducesSurfaceRe.MatchString(line) { add("recommendation-does-not-reduce-the-delivered-surface") } - // Relaying means the recommendation is CONFINED to what the worker could - // see, not that it mentions a worker option at all. A recommendation - // whose substance is the un-relayed surface may still carry one of the - // worker's options as a secondary detail, and a live run produced - // exactly that: "ship only the Go subcommand; defer the expiry read and - // installed-plugin access" names the surface the worker's role could - // not reach, and naming `expiry` beside it does not make it a relay. - // - // This is the same shape of change as widening `registration point` to - // `registration surfaces` to fit an output, and it is worth saying so. - // The difference is that this encodes the distinction the guard always - // meant, both directions are pinned below, and the recorded control -- - // "Recommend option 3: split slice 1 and defer the expiry read" -- still - // trips it, because it never reaches past the options it was handed. + // Relaying is being CONFINED to what the worker could see, not naming one + // of its options: a recommendation whose substance is the un-relayed + // surface may carry a worker option beside it. Both directions are pinned + // below. if relayedOptionRe.MatchString(line) && !unrelayedSurfaceRe.MatchString(line) { add("recommendation-relays-a-worker-option") } @@ -223,8 +205,7 @@ Derived from: reading.md:10 and README.md:14. Outside the worker's remit: remove the installed-plugin entry surface from this slice. `, want: nil, - why: "carrying a worker option beside the un-relayed surface is not relaying; " + - "a live run produced this and the guard used to reject it", + why: "carrying a worker option beside the un-relayed surface is not relaying", }, { name: "the recorded control: confined to the options it was handed", @@ -318,8 +299,7 @@ func TestGradeDecisionRequest(t *testing.T) { } // TestEveryGraderHasACase fails when a grader can fire but no case above trips -// it. A grader nothing exercises is decorative, and one was: until this test -// existed, no case reached recommendation-does-not-reduce-the-delivered-surface. +// it. A grader nothing exercises is decorative and nothing else would say so. func TestEveryGraderHasACase(t *testing.T) { graders := []string{ "missing-field:decision-request",