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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/issue-2129-docgate-suppressed-span.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

Phase 4.1 documentation gate records only run-specific workpad facts (issue #2129).

`scripts/read-doc-needed-deliverables.sh` now captures the extractor's stderr, forwards it unchanged to its own stderr, and relays the first suppressed Documentation Needed span onto stdout as a self-identifying `docgate-suppressed: ` line (the span's text with the breadcrumb's surrounding backticks removed). Phase 4.1 Stage 1 records a workpad note naming that span only when such a line is present — delivered through `--note-file`, never a double-quoted shell argument — replacing the fixed, always-false once-per-run disclosure sentence. The deferred documentation-AC discharge now ticks a criterion that names a check command only after the orchestrator has itself run that command (or the covering run the coverage map names) over the landed docs and quoted the result line, never on a subagent's report or a gate that has not yet run.
18 changes: 14 additions & 4 deletions docs/internal/implement-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -1777,8 +1777,9 @@ tracked file (`` `docs/a.md docs/b.md` ``, `` `docs/a.md LICENSE` ``). Any other
`(`, `:`, `*`, or any non-path character (a grant `` `Bash(x.sh:*)` ``), or a bare command word like
`` `bash lib/test/run.sh` `` (`bash` is extensionless and not an in-tree file) — is a command/grant
literal: it contributes no tokens, and a **one-time stderr breadcrumb** names the first suppressed span
(disclosed by Phase 4.1 as ephemeral on the cloud tier — the gate does not capture that stderr, so a
suppressed span leaves no run-record trace there; see the phase file's cloud-tier residual note). (2) Outside
(the read boundary `read-doc-needed-deliverables.sh` captures that stderr, forwards it unchanged, and
relays the span onto stdout as a `docgate-suppressed: ` line that Phase 4.1 Stage 1 records on the
workpad — see that helper's contract below). (2) Outside
spans, a `Word(...)` **call group** (a word immediately followed by a parenthesized group, e.g. an
un-backticked `Bash(lib/test/run.sh:*)`) contributes no tokens. (3) A **fenced code block** — opened and
closed by a line whose first non-whitespace characters are three-plus backticks or three-plus tildes (the
Expand All @@ -1804,7 +1805,9 @@ Stage B emits.

`scripts/read-doc-needed-deliverables.sh <issue-number>` owns the read both stages perform — the
`gh issue view` fetch, its scratch file, the invocation of `extract-doc-needed-paths.sh` over it, and
a retry on each. It prints an **outcome token** on a `docgate-outcome: ` line and, on success with
a retry on each. It prints an **outcome token** on a `docgate-outcome: ` line; on a success token, one
`docgate-suppressed: ` line when the extractor suppressed a span (the first suppressed span, its
surrounding backticks removed, which Phase 4.1 Stage 1 records on the workpad); and, on success with
paths, one `docgate-path: ` line per deliverable. **That helper's own header is the canonical
statement of its token vocabulary and the exit status paired with each; read it there rather than
from a copy.** Each token has its own status, and the success statuses are disjoint from the failure
Expand Down Expand Up @@ -1950,7 +1953,14 @@ Phase 3.4 gate deferred as Phase-4.1-owned (a `docs/…` deliverable, recorded i
Phase 4.1: {AC text}` workpad note — see the Phase 3.4 gate above) is this phase's obligation to close.
Once the docs pass has run and its changes are committed, for **each** such deferred doc-AC the
orchestrator confirms the required docs actually landed in this run's diff (Stage 2 already verified the
named deliverable paths) and ticks the criterion by its 1-based position, citing the deferral note. This
named deliverable paths) and ticks the criterion by its 1-based position, citing the deferral note. When a
deferred criterion instead **names a check command**, the orchestrator discharges it only after the docs
commit by running that command itself, in its own tool call over the landed docs — and when the tier does
not grant the command, the covering run `lib/test/modules/coverage-map.json` names for that unit — then
quotes the result line of the command it actually ran in the tick note; a subagent's report that it ran the
command does not discharge the criterion, and a tick note names no result of a gate that has not yet run.
When the tier refuses both the named command and its covering run, the criterion is left unticked and takes
the `Blocked` arm below, its reflection naming `prflow_implement.allowed_tools` as the remedy. This
tick **must** happen before §4.3's terminal `--status Complete` write, because `workpad.py`'s terminal
Complete gate hard-fails a Complete write while any non-post-merge acceptance-criteria row is still
unticked — a doc-AC left unticked would abort the finalize. A deferred doc-AC that genuinely cannot be
Expand Down
4 changes: 4 additions & 0 deletions lib/test/modules/coverage-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -1816,6 +1816,10 @@
"note": "the internal-identifier denylist class in lint-shipped-pruned-path.py: the module-constant comparand-shape pin (non-empty, covers the required floor), all three members fire, HTML/empty-reason marker discrimination, filename-boundary substring safety, and the fence-conditional marker pair",
"owner": "unmodularized"
},
"2129": {
"note": "",
"owner": "unmodularized"
},
"216": {
"note": "",
"owner": "review-dirty-tree"
Expand Down
53 changes: 45 additions & 8 deletions lib/test/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11289,6 +11289,11 @@ printf '%s\n' "## Implementation Notes" "" \
printf '%s\n' "## Implementation Notes" "" \
"- **Documentation Needed** — update \`docs/internal/implement-skill.md\`; verify with \`bash lib/test/run.sh\` and grant \`Bash(scripts/x.sh:*)\`." \
> "$rdnd_dir/body-adversarial.md"
# #2129: keep this body's one span a suppressed literal with NO real path — it must
# reach the no-deliverables emit site, not the deliverables one the adversarial body covers.
printf '%s\n' "## Implementation Notes" "" \
"- **Documentation Needed** — verify with \`bash lib/test/run.sh\`." \
> "$rdnd_dir/body-suppressed-only.md"

# An extractor stub that fails only its FIRST call, so the extractor retry has the
# same both-orderings coverage the gh retry does.
Expand All @@ -11302,6 +11307,20 @@ fi
exec "$RDND_REAL_EXTRACTOR"
RDND_FLAKY_STUB
chmod +x "$rdnd_dir/flaky-extractor"
# #2129: a stub whose FAILING attempts each emit the `suppressed a span` breadcrumb, then
# succeeds via the real extractor — do not fold it into flaky-extractor, whose failure
# prints no breadcrumb and so cannot tell a truncated stderr capture from an appended one.
cat > "$rdnd_dir/suppress-flaky-extractor" <<'RDND_SUPPRESS_FLAKY_STUB'
#!/usr/bin/env bash
n=$(cat "$RDND_EXTRACT_COUNT_FILE" 2>/dev/null || echo 0); n=$((n + 1)); echo "$n" > "$RDND_EXTRACT_COUNT_FILE"
if [ "$n" -le "${RDND_SUPPRESS_FAIL_TIMES:-1}" ]; then
echo "extract-doc-needed-paths.sh: suppressed a span (a command/grant/skill literal, or a path mixed with non-path tokens — not a set of bare-path deliverables, so no tokens emitted): \`phantom-span\`" >&2
echo "extract-doc-needed-paths.sh: token scan error" >&2
exit 3
fi
exec "$RDND_REAL_EXTRACTOR"
RDND_SUPPRESS_FLAKY_STUB
chmod +x "$rdnd_dir/suppress-flaky-extractor"

# rdnd_run BODY_FILE FAIL_TIMES [EXTRACTOR] -> prints the helper's stdout, then a
# final line `rc=<status>`, so one capture carries both halves of the contract.
Expand Down Expand Up @@ -11362,15 +11381,33 @@ assert_eq "#1554 token vocabulary: an extractor failing BOTH attempts prints \`e
assert_eq "#1554 arm order: an extractor succeeding on its SECOND attempt yields the success token" \
"$(printf 'docgate-outcome: deliverables\ndocgate-path: docs/internal/implement-skill.md\nrc=0')" \
"$(rdnd_lines "$rdnd_dir/body-paths.md" 0 "$rdnd_dir/flaky-extractor")"
# Adversarial input: the block carries a command span and a grant literal, which
# the extractor suppresses. Two things are asserted at once, because rdnd_run
# merges stderr: the literals are not phantom deliverables, AND the extractor's
# `suppressed a span` stderr breadcrumb — emitted on exactly this body — does not
# displace the outcome line or masquerade as a deliverable path. That is the whole
# reason the stdout shape is prefixed rather than positional.
assert_eq "#1554 adversarial input: a command span and a grant literal in the block are not deliverables, and the extractor's stderr breadcrumb does not corrupt the outcome" \
"$(printf 'docgate-outcome: deliverables\ndocgate-path: docs/internal/implement-skill.md\nrc=0')" \
# Adversarial input (command span + grant literal, both suppressed): the exact line
# set is load-bearing — the merged breadcrumb must not displace the outcome line or
# read as a path, and the relayed span is backtick-stripped (#1554, #2129).
assert_eq "#1554/#2129 adversarial input: literals are not deliverables and the first suppressed span is relayed as a docgate-suppressed line" \
"$(printf 'docgate-outcome: deliverables\ndocgate-suppressed: bash lib/test/run.sh\ndocgate-path: docs/internal/implement-skill.md\nrc=0')" \
"$(rdnd_lines "$rdnd_dir/body-adversarial.md")"
# #2129: capturing the extractor's stderr to a file must not swallow it — a helper
# that captures without forwarding drops the breadcrumb from the merged tool result.
assert_eq "#2129 adversarial input: the helper relays the extractor's 'suppressed a span' breadcrumb through the merged stream" \
"1" "$(rdnd_run "$rdnd_dir/body-adversarial.md" | grep -c 'extract-doc-needed-paths.sh: suppressed a span')"
# #2129: the docgate-suppressed emit lives on BOTH success branches; removing it from
# the no-deliverables one loses the span exactly when the block held nothing else.
assert_eq "#2129 no-deliverables input: a suppressed span with no real deliverable still relays the docgate-suppressed line" \
"$(printf 'docgate-outcome: no-deliverables\ndocgate-suppressed: bash lib/test/run.sh\nrc=10')" \
"$(rdnd_lines "$rdnd_dir/body-suppressed-only.md")"
# #2129: the extractor's stderr capture is TRUNCATED per attempt (`2>` after `rm -f`);
# `2>>` or a kept prior capture relays a failed attempt's span as a phantom
# docgate-suppressed line on the clean retry.
assert_eq "#2129 retry truncation: a failed attempt's suppressed-span breadcrumb never becomes a docgate-suppressed line on the clean retry" \
"$(printf 'docgate-outcome: deliverables\ndocgate-path: docs/internal/implement-skill.md\nrc=0')" \
"$(rdnd_lines "$rdnd_dir/body-paths.md" 0 "$rdnd_dir/suppress-flaky-extractor")"
# #2129: on the extract-failed token the span is NOT relayed, though the breadcrumb
# still reaches the merged stream — an emit added to the failure arm would report a
# span for a read the caller must treat as UNKNOWN.
assert_eq "#2129 extract-failed: a suppressed-span breadcrumb on both failing attempts yields the failure token, the forwarded breadcrumb, and no docgate-suppressed line" \
"$(printf 'docgate-outcome: extract-failed\nrc=12\nbreadcrumbs=1')" \
"$(RDND_SUPPRESS_FAIL_TIMES=2 rdnd_run "$rdnd_dir/body-paths.md" 0 "$rdnd_dir/suppress-flaky-extractor" | { _m="$(cat)"; printf '%s\n' "$_m" | grep -E '^(docgate-|rc=)'; printf 'breadcrumbs=%s\n' "$(printf '%s\n' "$_m" | grep -c 'suppressed a span')"; })"
# Stale-capture isolation (what "idempotent" has to mean here to be worth testing):
# seed the scratch body file with a DIFFERENT body, then fail both read attempts.
# A helper that extracted from whatever was already on disk would report that stale
Expand Down
2 changes: 2 additions & 0 deletions scripts/extract-doc-needed-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ span_token_ok() {
# all-output-channels-honesty guard) — it names both possibilities.
suppress_span() {
if [ "$span_warned" -eq 0 ]; then
# Keep the span the line's ONLY backticked text: read-doc-needed-deliverables.sh
# relays what sits between the first and last backtick (issue #2129).
printf '%s\n' "extract-doc-needed-paths.sh: suppressed a span (a command/grant/skill literal, or a path mixed with non-path tokens — not a set of bare-path deliverables, so no tokens emitted): \`$1\`" >&2
span_warned=1
fi
Expand Down
54 changes: 51 additions & 3 deletions scripts/read-doc-needed-deliverables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
# STDOUT SHAPE — each line is SELF-IDENTIFYING BY PREFIX, never by position:
#
# docgate-outcome: <token> exactly one, on every non-usage exit
# docgate-suppressed: <span> at most one, after the outcome line and only on a
# success token — the FIRST span the extractor
# suppressed, with the breadcrumb's surrounding
# backticks removed (issue #2129)
# docgate-path: <path> zero or more, one per deliverable, after the
# outcome line and only on `deliverables`
#
Expand All @@ -45,7 +49,12 @@
# extractor — and the extractor emits a `suppressed a span` breadcrumb on stderr for
# exactly the adversarial bodies this gate exists to handle. A positional "line 1 is
# the token" contract would read that breadcrumb as the token on a read that
# succeeded, and would read an interleaved stderr line as a deliverable path.
# succeeded, and would read an interleaved stderr line as a deliverable path. This
# helper captures the extractor's stderr to a scratch file, forwards it UNCHANGED to
# its own stderr (so the merged stream still carries every breadcrumb), and relays
# the first `suppressed a span` breadcrumb's span onto stdout as the self-identifying
# `docgate-suppressed: ` line above, so Phase 4.1 records a real span rather than a
# scripted once-per-run note (issue #2129).
#
# Failing the read means the deliverable list is UNKNOWN, never empty: a caller
# that treats a failure token as `no-deliverables` waves the gate through exactly
Expand Down Expand Up @@ -117,18 +126,57 @@ if ! "$DEVFLOW_GH" issue view "$ISSUE" --json body --jq '.body' > "$BODY_FILE" \
exit 11
fi

if ! DOC_NEEDED_PATHS="$("$EXTRACTOR" < "$BODY_FILE")" \
&& ! DOC_NEEDED_PATHS="$("$EXTRACTOR" < "$BODY_FILE")"; then
# Capture the extractor's stderr to a scratch file so its `suppressed a span`
# breadcrumb can be parsed (issue #2129) — the breadcrumb is the only channel that
# names the span. The file is TRUNCATED (not appended) on each attempt, so a retry
# overwrites the prior attempt's stderr rather than accumulating it.
EXTRACTOR_ERR="$SCRATCH/devflow-docgate-extractor-err-$ISSUE.txt"
rm -f "$EXTRACTOR_ERR"

# _rdnd_relay_extractor_stderr — forward the captured extractor stderr UNCHANGED to
# this helper's own stderr (so the merged tool result still carries every
# breadcrumb the caller relied on), and set SUPPRESSED_SPAN to the FIRST
# `suppressed a span` breadcrumb's span text with the breadcrumb's surrounding
# backticks removed. Bash builtins only (`case`, `while IFS= read -r`, `${var#…}`/
# `${var%…}`): the value decides an emitted stdout line, so it must not depend on a
# tool lib/preflight.sh does not guarantee.
SUPPRESSED_SPAN=""
_rdnd_relay_extractor_stderr() {
[ -f "$EXTRACTOR_ERR" ] || return 0
local _line _span
while IFS= read -r _line; do
printf '%s\n' "$_line" >&2
case "$_line" in
*"suppressed a span"*)
if [ -z "$SUPPRESSED_SPAN" ]; then
# Parses suppress_span() in extract-doc-needed-paths.sh: first backtick to
# last, so a backtick added to that breadcrumb's text relays the wrong span.
_span="${_line#*\`}"
_span="${_span%\`}"
SUPPRESSED_SPAN="$_span"
fi
;;
esac
done < "$EXTRACTOR_ERR"
}

if ! DOC_NEEDED_PATHS="$("$EXTRACTOR" < "$BODY_FILE" 2>"$EXTRACTOR_ERR")" \
&& ! DOC_NEEDED_PATHS="$("$EXTRACTOR" < "$BODY_FILE" 2>"$EXTRACTOR_ERR")"; then
_rdnd_relay_extractor_stderr
printf 'docgate-outcome: %s\n' extract-failed
exit 12
fi

_rdnd_relay_extractor_stderr

if [ -z "$DOC_NEEDED_PATHS" ]; then
printf 'docgate-outcome: %s\n' no-deliverables
[ -n "$SUPPRESSED_SPAN" ] && printf 'docgate-suppressed: %s\n' "$SUPPRESSED_SPAN"
exit 10
fi

printf 'docgate-outcome: %s\n' deliverables
[ -n "$SUPPRESSED_SPAN" ] && printf 'docgate-suppressed: %s\n' "$SUPPRESSED_SPAN"
# Read line-wise rather than word-splitting, so a path carrying whitespace stays
# one deliverable instead of becoming several.
printf '%s\n' "$DOC_NEEDED_PATHS" | while IFS= read -r _rdnd_path; do
Expand Down
Loading
Loading