Skip to content

fix(des-commit)!: emit Task-Id trailer required by stop hook - #86

Open
tburny wants to merge 4 commits into
nWave-ai:mainfrom
tburny:fix/78-des-commit-task-id-trailer
Open

fix(des-commit)!: emit Task-Id trailer required by stop hook#86
tburny wants to merge 4 commits into
nWave-ai:mainfrom
tburny:fix/78-des-commit-task-id-trailer

Conversation

@tburny

@tburny tburny commented Aug 8, 2026

Copy link
Copy Markdown

Why

The two halves of the commit contract disagreed, and neither side named the value that would satisfy the other. The SubagentStop verifier greps for both Step-Id: {step_id} and Task-Id: {project_id} on the same commit (git log --grep … --all-match), while des-commit — the producer the DES template mandates — only ever appended Step-Id: and had no input for a task id at all. A crafter following the shipped nw-execute COMMIT template verbatim was therefore guaranteed a COMMIT_NOT_VERIFIED block on its first commit, and could only escape by hand-embedding an undocumented trailer discovered through trial and error.

What changed

  • src/des/cli/commit.py — new required --task-id; both ids are appended as one well-formed trailer block.
  • nWave/skills/nw-execute/SKILL.md, nWave/tasks/nw/execute.md — the COMMIT template now documents both trailers and the value format the verifier greps for.
  • tests/des/integration/test_des_commit_cli.py, tests/des/unit/cli/test_cli_help_contract.py — coverage for the trailer, the dual-grep satisfaction, and the usage error.

Not changed: the plugins/** copies of commit.py and execute.md. That tree is generated by scripts/build_plugin.py and is on the mirror bot's sensitive-file denylist — including it blocked the nwave-dev mirror. The marketplace payload therefore still lacks --task-id and needs a maintainer-side regen.

A second defect found while fixing the first

Appending Task-Id: as its own paragraph is not enough: git interpret-trailers and %(trailers) only parse the final trailer paragraph, so an author-placed Task-Id: in a separate block was silently dropped by trailer parsing even when it was visibly present in the message. The fix joins both keys into the existing trailer paragraph rather than opening a new one. This is why the change is slightly larger than "add a flag".

Why --task-id is required rather than optional

The load-bearing property named in #78 is that the failure must not be guessable-wrong. An optional flag with a silent default reproduces exactly the failure being fixed whenever the flag is omitted — only later, and in the hook rather than at the producer, which is the worse of the two places to discover it. Required means omission is a usage error that names the missing input.

This is the reviewable judgment call in this PR, and it is reversible: a maintainer who prefers optional-with-env-default (say, reading the project id from the dispatch prompt) can relax the flag without touching the trailer-writing logic, which is the part that carries the real fix.

Test plan

  • ./.venv/bin/python -m pytest tests/des/integration/test_des_commit_cli.py tests/des/unit/cli/test_cli_help_contract.py -q — 28 passed.
  • Full tests/des compared within the same worktree and the same virtualenv, change reverted vs applied: 37 failures → 21. Zero regressions; 16 tests move from red to green, including all five new TestDesCommitTaskIdTrailer cases (they fail with the CLI change reverted, which is the RED proof).
  • Not tested: the SubagentStop hook end-to-end against a live Claude Code session. The verifier's dual --grep … --all-match is asserted directly against a real commit instead.

A note on the absolute failure counts: this repo's tests/des has pre-existing failures unrelated to this change (12 on a pristine 1d0f13c in my environment). Cross-environment counts are not comparable, because uv sync fails on a fresh clone here — pyproject.toml force-includes lib/python/des, which is untracked in this repository — so the suite has to be run from a hand-built virtualenv. The base-vs-head comparison above is therefore done inside one environment, which is the only comparison that means anything.

Focus areas

  • Required vs optional --task-id. This is the one decision worth your time; see the section above. If you want it optional, say so and the trailer logic stays as-is.
  • The trailer-paragraph join. Please check the behaviour when the author's --message already ends in a trailer paragraph, and when it ends in prose. Both are covered by tests, but this is the subtle part.
  • Value format. The value must equal the project_id recorded in execution-log.json — which subagent_stop_service.py documents as being the feature id, e.g. the slug auth-upgrade used in the template's own exemplar. It is not an issue number. The template says {feature-id} and shows Task-Id: auth-upgrade; if the intended format is different, the template and the tests both need to change with it.
  • The plugins/** payload copy diverges from src/ until it is regenerated (see Nothing tells contributors plugins/nw is generated output #97).

Risk / rollback

Blast radius is every DELIVER commit path. Any existing caller invoking des-commit without --task-id now exits 2 with a usage error — loudly, not silently, and at the producer.

Revert with git revert <sha>; nothing persists outside the commit message text, so there is no migration or data to undo.

BREAKING CHANGE: des-commit now requires --task-id. Callers must pass the feature id recorded as project_id in the execution log.

Closes #78

The SubagentStop commit verifier greps for BOTH `Step-Id: {step_id}` and
`Task-Id: {project_id}` on the same commit (`git log --grep ... --all-match`),
but `des-commit` only ever appended `Step-Id:` and had no input for the task
id. A crafter following the shipped nw-execute COMMIT template verbatim was
therefore guaranteed a `COMMIT_NOT_VERIFIED` block on its first commit.

des-commit now takes `--task-id` and appends both ids as ONE well-formed final
trailer block (joined to an existing trailer paragraph instead of always
opening a new one), so `git interpret-trailers` / `%(trailers)` also see both
keys — previously an author-placed `Task-Id:` landed in a separate paragraph
and was dropped by trailer parsing.

`--task-id` is REQUIRED, not optional-with-default: an optional flag with a
silent default reproduces the exact failure this fixes whenever it is omitted,
only later and in the hook rather than at the producer. Required means the
failure is a usage error naming the missing input.

BREAKING CHANGE: `des-commit` invocations without `--task-id` now exit 2. Any
existing caller must pass the bare feature id (`44`, not `nWave-ai#44`) — that literal
is what the verifier greps for. A maintainer who prefers optional-with-env-
default (e.g. reading DES-PROJECT-ID from the dispatch prompt) can relax the
flag without touching the trailer-writing logic.

The nw-execute COMMIT template (skill + command) now documents both trailers
and the bare-id value format, mirroring what the verifier greps.

Issue: nWave-ai#78
@nwaveai

nwaveai commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ This PR cannot be automatically mirrored to nwave-dev.

The following files are on the sensitive-file denylist and cannot be
changed via the external PR flow:

  • plugins/nw/commands/execute.md (matched rule: plugins/**)
  • plugins/nw/scripts/des/cli/commit.py (matched rule: plugins/**)

These files are typically managed by the release pipeline or contain
project infrastructure. If you believe this change is legitimate,
please open an issue explaining the intent — a maintainer can assist
or apply the change directly.

This PR remains open for discussion. No mirror branch has been created.

@tburny

tburny commented Aug 8, 2026

Copy link
Copy Markdown
Author

Correction to this PR's own description, not to the diff.

I originally wrote that the verifier greps for "the bare feature id (44, not #44)", which implies an issue number. That is wrong. subagent_stop_service.py states plainly that project_id IS feature_id, and the value is read from execution-log.json — so it is the feature identifier, e.g. the slug auth-upgrade that the template's own exemplar shows in Task-Id: auth-upgrade.

The code, the template and the tests were always consistent on this; only my description was misleading. The body is now corrected.

@tburny

tburny commented Aug 8, 2026

Copy link
Copy Markdown
Author

Question on the plugins/nw/** hunks in this PR.

This PR edits the marketplace payload under plugins/nw/** alongside the source, because the two are byte-identical and I could not tell from the public tree whether the payload is regenerated upstream or maintained by hand. The release workflows that would answer that are not synced here — .github/ contains only dispatch-pr.yml.

I kept the payload edits rather than stripping them, deliberately, because the asymmetry favours it: if the payload IS regenerated, these hunks are harmless noise that your pipeline overwrites; if it is NOT, stripping them would ship a marketplace copy contradicting the source — which is exactly the divergence issue #83 describes.

If the payload is generated, say so and I will drop those hunks from this PR. Nothing else in the diff depends on them.

plugins/** is on the repository's sensitive-file denylist and cannot be changed
through the external PR flow -- the mirror bot refuses to create a nwave-dev
branch while those paths are touched.

The payload copies of commit.py and execute.md are reverted to their committed
state. They therefore still lack --task-id. If plugins/ is regenerated at
release time this resolves itself; if it is not, the marketplace channel needs
the same change applied maintainer-side.

Issue: nWave-ai#78
@nwaveai

nwaveai commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ Automatic mirror failed due to a rebase conflict.

This change does not apply cleanly onto the matching private base.
A maintainer will review and either resolve the conflict manually
or request a rebase against the latest base.

No action is required from you right now. We'll comment again once
the mirror is in place or if we need input.

Review found the first pass recreated issue nWave-ai#78 inside its own fix. The trailer
guard was a substring test over the whole message:

    if "Task-Id:" not in body:

so any message merely MENTIONING the key suppressed the real trailer. A commit
message like "fix: correct Step-Id: parsing in the verifier" produced a commit
with no Step-Id trailer at all, which the SubagentStop verifier then rejected
with COMMIT_NOT_VERIFIED and no indication why. That is the exact failure this
CLI change exists to remove.

Detection now parses the final trailer block, which is the only place git's own
trailer machinery looks. A key mentioned in the subject, in prose, or in a code
sample is text, not a trailer.

IF the final trailer block already carries the key with a DIFFERENT value, the
CLI now refuses (exit 2) instead of silently keeping one of the two. Guessing
which value the verifier should see is how the original bug reached production.
A key already present with the SAME value is left alone and never duplicated.

Also fixed in the same pass:

- Empty or whitespace-padded --step-id / --task-id are rejected. They passed
  argparse and produced a commit the verifier could never match.
- The post-commit `git reset` return code was discarded, so a failed resync of
  the shared index reported success. It now warns, naming the recovery command.
  It does NOT fail the run: the commit already exists at that point, and exit 1
  would tell the caller to retry a commit that succeeded.
- Commit errors now name the owned paths that were attempted.
- --task-id help said "bare value (e.g. 44, not nWave-ai#44)", implying an issue number.
  subagent_stop_service states project_id IS feature_id, so the value is the
  feature slug (auth-upgrade). Help and usage example corrected.

Four tests cover the branches that carried the defect and previously had none:
prose mention does not suppress the trailer, a conflicting trailer is refused
with no commit created, a matching trailer is accepted without duplication, and
a blank id is a usage error.

Issue: nWave-ai#78
@nwaveai

nwaveai commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ Automatic mirror failed due to a rebase conflict.

This change does not apply cleanly onto the matching private base.
A maintainer will review and either resolve the conflict manually
or request a rebase against the latest base.

No action is required from you right now. We'll comment again once
the mirror is in place or if we need input.

@tburny
tburny force-pushed the fix/78-des-commit-task-id-trailer branch from 3a0a256 to 1367005 Compare August 8, 2026 01:39
@nwaveai

nwaveai commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ Automatic mirror failed due to a rebase conflict.

This change does not apply cleanly onto the matching private base.
A maintainer will review and either resolve the conflict manually
or request a rebase against the latest base.

No action is required from you right now. We'll comment again once
the mirror is in place or if we need input.

…dness

The headline Task-Id test searched the whole commit message for the
substring "Task-Id: 44". That passes even when the key lands where git's
trailer parser cannot see it — the exact defect issue nWave-ai#78 fixes — so it
gave false confidence. It now reads the value back through
%(trailers:key=Task-Id,valueonly), as its sibling already did.

The help-contract test claimed to check that --task-id is advertised as
required but only checked that the string appeared. It now asserts on the
usage line, where argparse distinguishes required from optional by
bracketing; flipping required=False on the option makes it fail.

Fixture task ids move from "44" to the slug "auth-upgrade": the Task-Id
value is the feature-id slug read from execution-log.json, not an issue
number, so the tests now document the real semantics.

Issue: nWave-ai#78
@tburny

tburny commented Aug 8, 2026

Copy link
Copy Markdown
Author

Pushed 144053d, closing out the review findings on the tests.

The headline test was asserting the wrong thing. test_commit_carries_task_id_trailer checked "Task-Id: 44" in body — a substring search over %B, which passes even when the trailer sits where git's own parsing cannot see it. That is precisely the failure this PR exists to prevent, so the test could have gone green on a broken commit. It now reads both keys back through %(trailers:key=…,valueonly), matching its sibling.

Fixtures corrected to the real semantics. The tests used task_id="44", which encodes an issue number. subagent_stop_service states project_id IS feature_id, so the value is the feature slug; fixtures now use auth-upgrade. Low churn — one default and a few literals in a single file, no production call sites — and it makes the tests document the contract correctly rather than merely pass.

The help-contract test now asserts requiredness, and that assertion was mutation-checked. It previously asserted only that --task-id appeared in --help, which is true regardless of whether the option is required. argparse encodes requiredness reliably in the usage line (optional options bracketed), so after normalizing argparse's wrapping the test asserts --task-id TASK_ID present and [--task-id absent. Flipping required=True to False makes it fail; the source was restored and the diff is clean.

Deliberately left out of this PR: src/des/adapters/driven/git/git_commit_verifier.py collapses git-missing, timeout and OS errors into one Git verification error string, so a caller cannot distinguish "commit not found" from "git is not installed". It is a real defect, but this PR is about the producer emitting the trailer, not the verifier's error taxonomy — fixing it here would turn a test-hardening commit into a runtime-behaviour change and needs its own tests for three injected failure modes. Happy to file it separately, or to fold it in if you would rather have it here.

113 passed, 1 pre-existing xfail. ruff check and format --check clean.

@nwaveai

nwaveai commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ Automatic mirror failed due to a rebase conflict.

This change does not apply cleanly onto the matching private base.
A maintainer will review and either resolve the conflict manually
or request a rebase against the latest base.

No action is required from you right now. We'll comment again once
the mirror is in place or if we need input.

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.

des-commit commits fail the stop-hook's Task-Id verification

2 participants