Skip to content

bug(cli): phase uat-passed and verify.artifacts exit 0 while their JSON reports failure #4686

Closed as not planned

Description

@denniyahh

Pre-submission checklist

  • I have searched existing issues and this bug has not already been reported

Searched for uat-passed exit code and verify.artifacts exit. #4631 (gates fail closed when they find nothing) is related in spirit but covers scope resolution, not verdict exit codes.

GSD Version

1.13.0

Runtime

Claude Code

Operating System

Linux (Fedora/RHEL)

Node.js Version

v26.8.2

Shell

bash

Installation Method

Runtime-home install at ~/.claude/gsd-core (VERSION reads 1.13.0; the original install method was not recorded)

What happened?

Two gate verbs report a negative verdict only in their JSON and exit 0:

  • phase uat-passed 47 --require-verification printed "passed": false with "blockers": ["policy: verification status=stale"] and exited 0. It did the same later with "blockers": ["47-VERIFICATION.md: verification status=human_needed", …].
  • query verify.artifacts <phase-dir>/47-07-PLAN.md printed {"all_passed": false, "passed": 1, "total": 2, …} — one artifact failing with Missing pattern: … — and exited 0. Adding --exit-contract=v2 (ADR-3889, bin/lib/cli-exit.cjs) also exited 0.

A shell caller that gates on $?, set -e or && proceeds as if both gates passed. The verbs do use non-zero for errors — phase uat-passed on a phase that does not exist exits 1 — so a caller cannot tell "the check ran and failed" from "the check passed" by exit status.

What did you expect?

A negative verdict exits non-zero, at least under --exit-contract=v2, distinct from the exit code used for errors. If exit 0 on a failing verdict is intentional, the verbs' help and the exit-contract ADR should say so, so workflows and scripts know to parse the JSON.

Steps to reproduce

  1. Pick a plan with a must_haves.artifacts entry whose contains: pattern is absent from its file.
  2. node ~/.claude/gsd-core/bin/gsd-tools.cjs query verify.artifacts <plan>; echo "exit=$?" → JSON with "all_passed": false, then exit=0.
  3. Repeat with --exit-contract=v2 → still exit=0.
  4. For a phase whose verification is stale or human_needed: node ~/.claude/gsd-core/bin/gsd-tools.cjs phase uat-passed <N> --require-verification; echo "exit=$?" → "passed": false, then exit=0.

Error output / logs

{"all_passed":false,"passed":1,"total":2,"failed":[{"path":".planning/user/DEV-SETUP-CHECKLIST.md","issues":["Missing pattern: …"]}]}
exit=0

(failed above is a jq projection of the artifacts array.)

How often does this happen?

Every time (100% reproducible)

Impact

Moderate — Feature is broken but I have a workaround

Workaround (if any)

Gate on the JSON field (jq -r .passed, jq -r .all_passed), never on the exit code.

Additional context

Found in the same session as #4682 and #4683.

Privacy Checklist

  • I have reviewed all pasted output for PII (usernames, paths, API keys) and redacted where necessary

Activity

  1. trek-e commented on Sep 13, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Reproduced: Yes, by code inspection (control-flow trace, matching the reporter's repro exactly, including --exit-contract=v2).
    Root cause: Neither cmdVerifyArtifacts (src/verify.cts:1395) nor cmdPhaseUatPassed (src/phase.cts:4655) ever declares an outcome to the exit-contract seam. output()'s only exit-relevant hook, isPayloadCarriedError (src/io.cts:176), fires solely on an {error: ...}-shaped payload (used by these same functions' precondition arms, e.g. "File not found" — which is why those correctly exit 1 today). A computed domain verdict (all_passed: false / passed: false) is not {error}-shaped, so nothing ever sets runMain's (src/cli-exit.cts:317) pending-outcome cell, and process.exitCode stays at its default 0 — under v1 and v2, since getContractVersion() only changes how a declared outcome projects, and none is ever declared here. This is a defect, not contract-as-designed: ADR-3889's Decision §1 states the 0/1 band explicitly and unversioned — "0 Free. Pass — the operation ran and its verdict is affirmative" / "1 Free. Fail — the operation ran and its verdict is negative" — so a negative verdict is supposed to exit 1 today, not only under --exit-contract=v2. The mechanism the ADR designed for this (main() returning 'PASS'/'FAIL' for runMain to project) has simply never been wired into these two verbs — a repo-wide grep for return 'FAIL'/return 'PASS' across src/*.cts finds zero call sites at all.
    Introduced by: Long-standing — get_timeline on cmdVerifyArtifacts shows the same un-declared-outcome shape across all 21 recorded versions back to 2026-07-30, predating ADR-3889 (2026-08-26) itself.
    Blast radius: A correctly-scoped fix touches only the two verb functions (declare 'FAIL'/'PASS', or set process.exitCode directly, based on each function's own computed verdict) and does not require changing output(), runMain, or the exit-code registry. The shared seam those two functions would plug into is used by effectively every CLI command (get_impact measured CRITICAL, 180 affected symbols via the shared output()/cli-exit helper fan-out) — real reason to keep the fix narrowly scoped to the two verbs' own final output() call, not the shared seam.
    Coupled (must not miss): The existing "File not found" / "No must_haves.artifacts" precondition arms in cmdVerifyArtifacts already exit 1 correctly via {error} — must not be touched. Any other verb with the same un-declared plain-output()-verdict shape has the identical latent bug, but is out of scope for this issue's exact repro.
    Regression test: query verify.artifacts <plan> on a plan with a failing must_haves.artifacts check, and phase uat-passed <N> --require-verification on a phase with stale/human_needed verification, must both exit non-zero (with --exit-contract=v2 and without) while still printing their current JSON verdict unchanged; the affirmative-verdict (exit 0) and precondition-error (exit via {error}) paths must remain byte-identical.

    Agent Brief

    Category: bug
    Summary: Two CLI gate verbs report a negative verdict only in their JSON payload and always exit 0, so a caller cannot detect failure via $?.
    Current behavior: A failing artifact-verification check or a failing UAT-passed policy check prints the failure in JSON (all_passed: false / passed: false with blockers) but the process exits 0 regardless of the exit-contract version selected.
    Desired behavior: When the computed verdict is negative, the process must exit non-zero (matching the exit-contract's documented 0=pass/1=fail band, unversioned — this must work under the default contract, not only under --exit-contract=v2), while the JSON payload's content and the existing precondition-error exit codes (file/phase not found, etc.) stay exactly as they are today.
    Key interfaces: phase uat-passed --require-verification, query verify.artifacts, the exit-contract declaration mechanism (a command declaring PASS/FAIL for the process exit code), --exit-contract=v2 / GSD_EXIT_CONTRACT.
    Acceptance criteria: query verify.artifacts on a plan with a failing artifact check exits non-zero (both with and without --exit-contract=v2) while still printing all_passed: false in its JSON. phase uat-passed --require-verification on a phase with a stale/human_needed/policy-blocked verification status exits non-zero while still printing passed: false and blockers in its JSON. Both verbs continue to exit 0 on an affirmative verdict. Both verbs' existing precondition-error arms (missing plan/phase/file) are unchanged.
    Out of scope: Changing the shared output()/runMain/exit-code-registry seam itself; auditing or fixing other verbs that may share this same latent pattern; changing the JSON payload shape of either verb.

  2. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Sep 13, 2026
  3. added 5 commits that reference this issue on Sep 23, 2026
  4. trek-e commented on Sep 27, 2026

    @trek-e
    Collaborator

    Closing as superseded by epic #5056, not as won't-fix. This defect will be architected away in Phase 8: Phase 8 derives every gate verb's exit code from its typed verdict through the process exit contract (cli-exit), so no verb chooses its own exit status (ADR-5057 §4).

    The point-fix PR #4938 was closed unmerged for the same reason, with thanks. The Phase 8 PR will reference this issue with Refs #4686 and keep a fail-first regression for it. The mapping is recorded in Phase 0 (#5057, ADR-5057).

  5. added a commit that references this issue on Sep 30, 2026
  6. added 3 commits that reference this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions