Skip to content

fix(planning): check-open-questions refuses duplicate headings, names its bound line, skips fences - #4065

Open
kyle-sexton wants to merge 1 commit into
mainfrom
planning-interview-register-gate-heading
Open

fix(planning): check-open-questions refuses duplicate headings, names its bound line, skips fences#4065
kyle-sexton wants to merge 1 commit into
mainfrom
planning-interview-register-gate-heading

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Closes #4073

Summary

check-open-questions.sh graded the section under the first heading matching open-question register and nothing checked for a second one. Since planning 0.39.5 the checklist template's own register section carries unfenced example rows, so a ledger copied from the template with a live register appended below it was graded on the example rows and reported a phantom status=open. No message said which heading had been read. The template's register also holds a fenced bash block whose # Step 3 ... comment lines were read as headings, so a live row written after that fence was invisible.

Fix

  • New heading_matches helper lists every heading matching the pattern with its line number, skipping fenced blocks. More than one match exits 2 naming every line and stating that the gate reads exactly one section.
  • extract_section now takes the matched line number instead of re-matching a pattern, and skips fenced blocks, so the body graded is by construction the section named in stderr.
  • Every register-derived ungradeable message (zero rows, malformed row or id, duplicate or gapped id, unknown status) carries (register '<heading>' at line N). The --brief path gets the same duplicate refusal and names the deferred-questions heading and line in its missing-id message.
  • A fence opened and never closed is ungradeable, named as the cause, in both the ledger and the Brief. Found by the fresh-context code review: the row loop already skipped every row after such a fence as documentation, so an open row hidden behind a stray fence graded clean.
  • Header exit-2 cause list updated so --help stays current. Exit-code contract unchanged.
  • planning 0.39.7 with a changelog entry.

Verification

  • bash plugins/planning/scripts/check-open-questions.test.sh: eight new cases (26 to 33) written first and observed failing on the unchanged script (11 assertions red), then green; three more (34 to 36) added for the unterminated-fence finding; 55/55 green.
  • bash plugins/planning/tests/interview-defenses.test.sh: PASS=98 FAIL=0 (no skill prose touched).
  • bash scripts/affected-tests.sh --run --explain: selects the co-located suite, passes; plugin.json and CHANGELOG are recorded no-suite.
  • bash scripts/check-changelog-parity.sh --check-bump origin/main: clean.
  • shellcheck on both shell files: clean.
  • This session's own interview ledger and Brief, a single-heading ledger whose register holds a fenced bash block, still grade status=clean with and without --brief.

Related

🤖 Generated with Claude Code

… its bound line, skips fences

The gate graded the section under the first heading matching the
register pattern and nothing checked for a second one. Since 0.39.5 the
checklist template's own register carries unfenced example rows, so a
ledger copied from the template with a live register appended below was
graded on the example rows and reported a phantom status=open, and no
message said which heading had been read.

- heading_matches lists every matching heading with its line, skipping
  fenced blocks; more than one match exits 2 naming every line.
- extract_section takes the matched line number instead of re-matching,
  and skips fenced blocks, so the body graded is the section named in
  stderr. The template's fenced bash block holds "# Step 3" comment
  lines that used to terminate the section and hide later rows.
- Every register-derived ungradeable message carries the bound heading
  and line; the --brief path gets the same duplicate refusal and naming.
- A fence opened and never closed is ungradeable, named as the cause.
  The row loop already skipped every row after such a fence, so an open
  row behind a stray fence graded clean.

Exit-code contract unchanged. Cases 26 to 36 pin the new behaviour;
planning 0.39.7.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T04:44:54.773172Z 93dab38 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 6m 16s —— View job


Code review complete

  • Gather context (PR diff, files, custom instructions)
  • Invoke /review:code-review per custom instructions
  • Post findings as inline PR comments
  • Post summary comment

Reviewed plugins/planning/scripts/check-open-questions.sh (the production change) plus the accompanying test/changelog/version files, scoped to the CODE-REVIEW lane (security findings are out of scope here since this repo also runs claude-security-review). Two findings posted inline:

  1. [Correctness] check-open-questions.sh:294-301 — the new heading_matches call for --brief (and its "unterminated fenced block" refusal) runs unconditionally, before the script knows whether the deferred/blocked cross-check is even needed. When the ledger has zero deferred/blocked rows, deferred_ids is empty and the check is a no-op — but an unrelated stray unclosed code fence anywhere else in the Brief document still flips the whole run to status=ungradeable. The adjacent zero-match branch already guards the analogous "no such heading" case on -n "$deferred_ids"; the unterminated-fence branch has no equivalent guard. No test in the suite exercises this combination (the mixed fixture used by the related tests always has deferred/blocked rows).
  2. [Maintainability, non-blocking] check-open-questions.sh:65 — the fence-marker regex is now duplicated across three sites (heading_matches, extract_section, and the row loop). Worth a shared helper given the script's own emphasis on the two heading-detection functions staying in agreement.

Everything else checked out: the heading_matches/extract_section line-number-bound design correctly guarantees a matched heading is never inside a fence, the duplicate-heading refusal and its line-listing helpers (match_count/match_lines) are correct, the new $where/$brief_where annotations only touch stderr text (verified no pre-existing test asserts exact stderr, so no regression there), and the CHANGELOG/plugin.json version bump are consistent with the change. I did not independently re-run the test suite or shellcheck (not available in this lane's toolset — Read/Glob/Grep/gh pr diff only), so the author's stated 55/55 and shellcheck-clean results are author-claimed and unverified by me.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 6s —— View job


Security review (/review:security-review)

  • Gather context (PR diff, files changed)
  • Run /review:security-review per repo custom instructions
  • Post findings

Result: skip gate applies — no security-relevant surface.

All four changed files are local, non-networked shell tooling and its docs/tests:

  • plugins/planning/scripts/check-open-questions.sh — a local CLI gate that reads markdown files named via --ledger/--brief args and parses them with awk/bash string ops (heading matching, section extraction, row parsing). Checked for the obvious injection classes:
    • awk -v pattern="$1" ... "$2" (check-open-questions.sh#L64, #L90) — pattern is a fixed literal supplied by the script itself ('open-question register', 'deferred questions'), never file content or an external argument, and file content is only ever read, never interpolated into the awk program text or eval'd.
    • No eval, no source, no subshell execution of file contents anywhere in the diff.
    • File paths from --ledger/--brief are passed as positional args to awk/bash [[ -f ]], not interpolated into a shell command string, so a path with spaces or shell metacharacters doesn't get re-interpreted.
    • All new interpolations into error messages ($where, $line, $id, matched headings/line numbers) are printed with echo/die_ungradeable, not executed.
  • plugins/planning/scripts/check-open-questions.test.sh — test fixtures only, run locally.
  • plugins/planning/.claude-plugin/plugin.json, CHANGELOG.md — version bump and changelog text.

This is a local mechanical gate over files the same session already controls (an interview ledger/Brief), not a boundary that takes untrusted input from a network caller, an external user, or another privilege domain. Per this skill's skip gate ("change has no security-relevant surface after reading the diff"), there's nothing to report — no findings posted.

GitHub Actions hardening (workflow triggers, permissions, unpinned actions) is out of scope for this lane and unaffected here; no workflow files changed.

@github-actions

Copy link
Copy Markdown

Last security-reviewed head: 93dab381d88020039e88a6071cb975a6e5ddc368. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93dab381d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/^#+[[:space:]]/ {
if (inside) { exit }
if (tolower($0) ~ pattern) { inside = 1; found = 1; next }
/^[[:space:]]*(```|~~~)/ { fenced = !fenced; next }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track the actual Markdown fence delimiter

For valid nested-fence documentation, this parity toggle exposes headings that are still inside a code block. For example, a four-backtick fence may legitimately contain a three-backtick ledger example; the inner opening toggles fenced off, so an example ## Open-question register is counted as a second live heading and the gate exits 2. The same problem occurs when ~~~ appears inside a backtick fence. Track the opener's character and length, and only close it with a compatible delimiter.

Useful? React with 👍 / 👎.

Comment on lines 294 to +298
if [[ "$brief_named" -eq 1 ]]; then
deferred_section="$(extract_section 'deferred questions' "$brief")"
brief_awk=$?

if [[ "$brief_awk" -eq 3 ]]; then
brief_matches="$(heading_matches 'deferred questions' "$brief")"
brief_matches_status=$?
if [[ "$brief_matches_status" -eq 4 ]]; then
die_ungradeable "unterminated fenced block in: $brief (every heading after it is hidden; close the fence)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This heading_matches call (and its exit-4 check) runs unconditionally whenever --brief is named, before anything downstream knows whether the cross-check is even needed. If the ledger's register has zero deferred/blocked rows, deferred_ids is empty and the cross-check is a no-op — the zero-match branch two lines below (if [[ -z "$brief_matches" ]]; then ... if [[ -n "$deferred_ids" ]]) already accounts for that by skipping the "no section" refusal when nothing needs checking. But the unterminated-fence refusal on line 298 has no equivalent guard: it fires as soon as the Brief has any unclosed fence anywhere in the document, even in a section unrelated to "Deferred questions", even when there are no deferred/blocked ids to verify.

In practice a Brief is often a large planning document with its own code samples. A single unrelated stray unclosed fence in some other section would flip an otherwise-clean run (nothing deferred, ledger fully resolved) to status=ungradeable, which contradicts this gate's own stated goal of a narrowly-scoped mechanical check. Consider deferring the heading_matches/unterminated-fence check on the Brief until it's known that deferred_ids is non-empty, matching the short-circuit that already exists for the "no such heading" case right below it.

No test in check-open-questions.test.sh currently exercises "ledger has no deferred/blocked rows" + "Brief has an unrelated unterminated fence" — the existing mixed fixture (used by tests 30/31/36) always has deferred/blocked rows, so this gap isn't caught.

Fix this →

/^#+[[:space:]]/ {
if (inside) { exit }
if (tolower($0) ~ pattern) { inside = 1; found = 1; next }
/^[[:space:]]*(```|~~~)/ { fenced = !fenced; next }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor maintainability note: the fence-marker regex ^[[:space:]]*(\``|~~~)is now duplicated three times — here, inextract_section(line 92), and in the row loop (line 199, bash=~form). The comment above this function already flags thatheading_matchesandextract_section` "must agree on what a heading is"; a future change to fence detection (e.g. requiring matched fence length/char per GFM) has three call sites to update in lockstep, with nothing enforcing they stay in sync. Not blocking, but worth a shared constant or helper given how central "don't silently drift" is to this script's own design philosophy.

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

planning/interview: template's own "Open-question register" heading shadows the live register, making the Step 3 gate ungradeable

1 participant