diff --git a/.agents/pm/history/pm-github-0x3y.jsonl b/.agents/pm/history/pm-github-0x3y.jsonl index a29e99d7..c61cc76d 100644 --- a/.agents/pm/history/pm-github-0x3y.jsonl +++ b/.agents/pm/history/pm-github-0x3y.jsonl @@ -1 +1,33 @@ {"hash_algorithm":"sha256","ts":"2026-10-14T21:46:44.372Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"8883d09363214115c628d33e","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-0x3y"},{"op":"add","path":"/metadata/title","value":"Add a reusable gated closeout PR workflow"},{"op":"add","path":"/metadata/description","value":"Follow up on pm-github closeout with a workflow_call automation surface after command acceptance. Reuse the gated sync safety model without changing its existing workflow or inputs."},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-10-14T21:46:44.372Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-14T21:46:44.372Z"},{"op":"add","path":"/metadata/author","value":"codex-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Run verified closeout on a bot branch and open or update a PR; never push main; validate tracker privacy, regenerate changelog, and dispatch required CI before publication.; Test clean, malicious, no-op, permission-limited and repeat invocations with explicit evidence; document least-privilege callers."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"5f8d44137ec006bde948e6d40a869a600e67f67a80f682ca5cc49e1d66d68be8","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"98a52c199802bcfd4edf4b436bc4ef0faa249b94f4776a24bc09d3ae767c5a53"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:44:31.029Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:44:31.029Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"5f8d44137ec006bde948e6d40a869a600e67f67a80f682ca5cc49e1d66d68be8","after_hash":"d9e8eadf248dbf18a73f877c25c2869f0019e63864190d0de19d73f164827a41","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"bd71cfc3ffd03c9430f07bdca3f2ae32dc07a099555ef8ea5852d5cb78462838"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:44:33.113Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:44:33.113Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"d9e8eadf248dbf18a73f877c25c2869f0019e63864190d0de19d73f164827a41","after_hash":"b53ea3445033ff2af119449c118fc3a1b191f8eba8135f15c9625b84a0e41146","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f626407abd611816b960f3ad49cf33cfa32ffdcb1df3ca713a26d0c1af1aff86"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:45:04.811Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:45:04.811Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-16T00:45:04.810Z","author":"codex-agent","text":"Claimed. Implement a separate workflow_call without changing sync inputs or behavior. Preserve default-branch resolution, npm project containment, exact extension pin, full changed-path allowlist, staged-diff privacy gate, strict health, leased bot-only push and owned-PR lookup. Closeout scans merged PRs since 1970-01-01 to avoid missing deliveries after schedule gaps. Tests execute the real workflow bash with real SDK trackers and isolated Git remotes; only HTTP and external command boundaries are injected."}]}],"before_hash":"b53ea3445033ff2af119449c118fc3a1b191f8eba8135f15c9625b84a0e41146","after_hash":"e462e436648d1950d877baad89dc3d7dbf5bdce0191952a00b057f7670d8d281","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"9ddc1742495f17981ca9ed84bb48a1c8d14a4a763434be4b1c6f884f6858ad92"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:50:42.270Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:50:42.270Z"},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/pm-github-closeout.yml","scope":"project"},{"path":"test/closeout-workflow.test.ts","scope":"project"}]}],"before_hash":"e462e436648d1950d877baad89dc3d7dbf5bdce0191952a00b057f7670d8d281","after_hash":"79ad01b8926e4cc8259032c7a15a6bbc1f8b8e8ed645eed74422212cc0ecb831","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"a080f6f6f8615fdd9966c3282b7e1ec8abad18585e321dad97c7decbc54776c2"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:50:48.439Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:50:48.439Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/closeout-workflow-caller.yml","scope":"project"},{"path":"README.md","scope":"project"}]}],"before_hash":"79ad01b8926e4cc8259032c7a15a6bbc1f8b8e8ed645eed74422212cc0ecb831","after_hash":"d353e1a2daf12cd9744419aa212bc56f524b5cf7933d7036e46f82825218a22c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bee4c13ba593a6406355283130c9ca4d18d61f1a3a5f240177a28769df250d4d"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:51:51.603Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:51:51.603Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test --test-name-pattern=closeout.contract test/closeout-workflow.test.ts","scope":"project","timeout_seconds":1200,"provenance":{"author":"codex-agent","created_at":"2026-10-16T00:51:51.522Z","source_kind":"local_mutation","source_ref":"feat/reusable-closeout-workflow"}}]}],"before_hash":"d353e1a2daf12cd9744419aa212bc56f524b5cf7933d7036e46f82825218a22c","after_hash":"ffa23f3b9eb3a654a11c75d5853ae93ea10706b467604e7e1858f217583bee67","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"fe5ce2eb14a1ce1f376ef6c1fd26e8a867797efd6df135a8e8ff6f6c7edc4dca"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:52:51.723Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"node --test test/closeout-workflow.test.ts","scope":"project","timeout_seconds":1200,"provenance":{"author":"codex-agent","created_at":"2026-10-16T00:52:51.590Z","source_kind":"local_mutation","source_ref":"feat/reusable-closeout-workflow"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:52:51.723Z"}],"before_hash":"ffa23f3b9eb3a654a11c75d5853ae93ea10706b467604e7e1858f217583bee67","after_hash":"39c110fc5ff03f0bf6fbcbed60f4d052b8ce80adcb71044b7fc63fe0a44d42a6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"cceb5aae0a4058475210b6560b5337c31080798466ad285c389129def964f69a"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:54:37.782Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-16T00:54:37.782Z","author":"codex-agent","text":"Implementation and caller docs are complete. Safety helpers intentionally mirror the unchanged sync workflow, with executable parity assertions. Clean nested-project closeout opens one owned PR and repeat invocation edits it; malicious acceptance criteria is refused by the proposed-diff gate. Pure no-op and zero-close caller-only tracker drift publish nothing. Read, contents-write, PR-write and CI-dispatch refusals are explicit. Added a fixed bot/default-branch separation guard. Linked tests are running via pm test; repository policy disables automatic test-result tracking, so verified receipts will be recorded in comments."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:54:37.782Z"}],"before_hash":"39c110fc5ff03f0bf6fbcbed60f4d052b8ce80adcb71044b7fc63fe0a44d42a6","after_hash":"041536b907c1d19b5752de42ed617ff957f6be359beabf449e9369f2bef90bcc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"fbf481fc1510f66bcfb2eb6f0a7283f8ad803b95fb22ff9eaf18d4ce47b0a668"} +{"hash_algorithm":"sha256","ts":"2026-10-16T00:56:48.557Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-16T00:56:48.557Z","author":"codex-agent","text":"Behavioural revert proofs: node --test --test-name-pattern=behavioural test/closeout-workflow.test.ts passed 2/2 (zero failures/skips). With only the staged-diff privacy scan call removed from the executable scope helper, malicious acceptance criteria and copied close evidence were committed and pushed to the isolated bare remote and a PR receipt was created. The intact workflow refuses that same fixture before push. With only the zero-close/no-tracker-diff conditional removed, a run reporting closed=0 opened a PR containing caller-only tracker drift; the intact workflow creates no remote branch or PR for the same zero-close case. These are real tracker mutations, real Git commits/pushes and bash extracted from the actual workflow; only HTTP/publication command boundaries are injected."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T00:56:48.557Z"}],"before_hash":"041536b907c1d19b5752de42ed617ff957f6be359beabf449e9369f2bef90bcc","after_hash":"4edb57b14177a2402419a5f48fa8150946c8869c1005391fa06f6c0b3d7b2b02","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"b139cf1e336a0b238ddd993cc58981b74ad69e801cdf8a2b2cd2bcaf49bf6251"} +{"hash_algorithm":"sha256","ts":"2026-10-16T01:10:06.477Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-16T01:10:06.476Z","author":"codex-agent","text":"Linked workflow acceptance: pm test pm-github-0x3y --run --only-last --progress executed node --test test/closeout-workflow.test.ts and passed 13/13, with 0 failures, skips, cancellations or todos. The earlier focused contract command also passed. Covered owned-PR repeat behavior, schedule without repository metadata, all requested refusal/no-op cases, both behavioural reverts, input validation and competing-writer lease rejection. PM returned evidence_recording.reason=tracking_disabled under repository policy; this comment retains the verified receipt without changing that policy."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T01:10:06.477Z"}],"before_hash":"4edb57b14177a2402419a5f48fa8150946c8869c1005391fa06f6c0b3d7b2b02","after_hash":"e656f0773a15eee86cd96ff42542624ee23de148412a4946c6f26916e8c622dc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"ad662d9504336443e7faa9042bbb10725eda9029b5d98d22437d913f2ad18112"} +{"hash_algorithm":"sha256","ts":"2026-10-16T01:13:07.054Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-16T01:13:07.054Z","author":"codex-agent","text":"Final permission review: closeout reads the Checks API, not GitHub issues. The caller and reusable job now grant checks:read instead of sync issues:read; contents:write, pull-requests:write and actions:write remain unchanged. Official GitHub Checks API documentation requires the read grant for private check runs. The permission-limited fixture now also denies only check-run reads and verifies zero writes/publication. Re-running the full gates on this final configuration; sync remains untouched."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T01:13:07.054Z"}],"before_hash":"e656f0773a15eee86cd96ff42542624ee23de148412a4946c6f26916e8c622dc","after_hash":"f7d72568dcbe864ef5380ea8390e44e03869aca06ecea53afbdd60f8449260ad","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f20dd6a3eb661807ad85892f1f1cea1eda5c0a6413e7a2901ee88cb0afc1cfa9"} +{"hash_algorithm":"sha256","ts":"2026-10-16T01:13:37.110Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/2","value":{"command":"node --test --test-name-pattern=permission-limited test/closeout-workflow.test.ts","scope":"project","timeout_seconds":1200,"provenance":{"author":"codex-agent","created_at":"2026-10-16T01:13:36.961Z","source_kind":"local_mutation","source_ref":"feat/reusable-closeout-workflow"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T01:13:37.110Z"}],"before_hash":"f7d72568dcbe864ef5380ea8390e44e03869aca06ecea53afbdd60f8449260ad","after_hash":"377e79a3e1552465bbbf72e7fc8b8ca6df724b71624bd962b9228c6c65626ce1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"794ca0357b4e89a3df8fde1efb1915a93299ec3a03b4b87a36a8ff5ef546701a"} +{"hash_algorithm":"sha256","ts":"2026-10-16T01:16:45.532Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-16T01:16:45.532Z","author":"codex-agent","text":"Permission regression now passes through pm test: the fixture denies all PR reads, then denies only check-run reads. Both runs fail explicitly before publication and leave the item in progress. Existing command gap encountered: unavailable head evidence is returned as a skip receipt with exit 0, without a typed error code. The workflow checks unavailable/incomplete evidence in the receipt and turns it into a clear failure; command behavior remains unchanged. Final contract test and lint/typechecking pass with checks:read in both caller and reusable job. Full release gates are restarting with implementation frozen."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T01:16:45.532Z"}],"before_hash":"377e79a3e1552465bbbf72e7fc8b8ca6df724b71624bd962b9228c6c65626ce1","after_hash":"0fd89b295f6222475efc888307777e2973e2f3123c4d981f93cbf8d9f4bb5b96","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"a916262070c202619c69928968d8120fda00195e1b2db9dc697e00be08372b23"} +{"hash_algorithm":"sha256","ts":"2026-10-16T02:08:16.197Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-16T02:08:16.197Z","author":"codex-agent","text":"Final npm gate passed with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 npm run release:check. Tests 694/694; failures/skips/cancellations/todos all 0. Exact coverage across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Thresholds and package pins unchanged. Production audit reports 0 vulnerabilities; docstrings, zero-duplication, privacy, pack dry-run, changelog and publish-attestation checks passed. Starting the requested Bun gate and final full linked-test run."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T02:08:16.197Z"}],"before_hash":"0fd89b295f6222475efc888307777e2973e2f3123c4d981f93cbf8d9f4bb5b96","after_hash":"f83554872022fef845e7e2704e8eabb0fa9ba77ef38750d5bd457d7c8367d8fc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"c07cf2c3c10389806eec8aa5992be1288ba8c31dc30790b305ac411e102a3e08"} +{"hash_algorithm":"sha256","ts":"2026-10-16T02:19:38.982Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/3","value":{"command":"env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 node --test --test-name-pattern=closeout.contract test/closeout-workflow.test.ts","scope":"project","timeout_seconds":1200,"provenance":{"author":"codex-agent","created_at":"2026-10-16T02:19:38.716Z","source_kind":"local_mutation","source_ref":"feat/reusable-closeout-workflow"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T02:19:38.982Z"}],"before_hash":"f83554872022fef845e7e2704e8eabb0fa9ba77ef38750d5bd457d7c8367d8fc","after_hash":"1083eb2d46c62b03111410b10c4625e955d34921198a6a633b439cd086850c5f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"238ef93bdcfcf4f79d15f637efa6afd7bb463fa007be01fd304a895c146149e3"} +{"hash_algorithm":"sha256","ts":"2026-10-16T02:19:44.776Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-16T02:19:44.776Z","author":"codex-agent","text":"Bun validation exposed a test-fixture setup defect: the package receipt parser combined stdout and stderr, so first-use npm update notices after JSON made parsing fail in a fresh home. Reproduced with the fresh-home contract command (failed before the fix), separated stdout from diagnostic streams, then the identical command passed 1/1. Lint and test typechecking pass. Production workflow logic is unchanged. Earlier npm result was 694/694 with exact complete coverage; both complete gates are being rerun after this test-only fix. Added the fresh-home regression to linked tests."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T02:19:44.776Z"}],"before_hash":"1083eb2d46c62b03111410b10c4625e955d34921198a6a633b439cd086850c5f","after_hash":"0269dd98d3cf3761c5e9972b7319b966c3ef01d425e481166c34a28e173dc362","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"51e94e75f4af1206655d7bccb39bdb1a54668477556f747ab51bff8889158304"} +{"hash_algorithm":"sha256","ts":"2026-10-16T02:34:54.392Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-16T02:34:54.392Z","author":"codex-agent","text":"Final unfiltered linked-test run passed: pm test pm-github-0x3y --run --progress completed all 4/4 registered commands with exit 0 and every failure category at 0. The full workflow suite passed 13/13 with 0 failures/skips/cancellations/todos; the focused contract, permission-limited token and fresh-home package-receipt regressions each passed. Automatic test-result recording remains disabled by repository policy, so this comment preserves the verified receipt. Both required release gates remain in progress after the test-helper stream fix."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T02:34:54.392Z"}],"before_hash":"0269dd98d3cf3761c5e9972b7319b966c3ef01d425e481166c34a28e173dc362","after_hash":"9a04bdc772b8243056c8e4be872ea71146d4b074c2c1e31012b8f68e033f676d","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"ebc500deb7953c70da1513cee8e9b2b6769320f4e20b9e7def7fddc896fe796b"} +{"hash_algorithm":"sha256","ts":"2026-10-16T03:05:47.391Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-16T03:05:47.391Z","author":"codex-agent","text":"Npm release:check passed again after the package-receipt stream fix, using isolated CI identity. Tests 694/694 with 0 failures/skips/cancellations/todos. Coverage remains exact across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Production audit 0 vulnerabilities; every static, privacy, pack, changelog and attestation stage passed. The final 4/4 PM linked commands are also green. Starting the corrected Bun release gate on this same frozen implementation."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T03:05:47.391Z"}],"before_hash":"9a04bdc772b8243056c8e4be872ea71146d4b074c2c1e31012b8f68e033f676d","after_hash":"6c4b75a634012c4e5de25a4452ee5529b82bb1a630f8aff9856190dc1716c50a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"bd22929d9f7810287d75837304fbda5db3e95b288a3528f9cb33778198a996ae"} +{"hash_algorithm":"sha256","ts":"2026-10-16T03:39:34.857Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-10-16T03:39:34.857Z","author":"codex-agent","text":"Both final release gates passed on the corrected frozen implementation: npm run release:check and bun run release:check, each with isolated CI identity and 694/694 tests; failures/skips/cancellations/todos all 0. Both report exact coverage across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Unchanged thresholds and dependency pins; 0 duplicated authored lines and 0 production vulnerabilities. Docstrings, privacy, pack, changelog and publish-attestation checks passed in both gates. All 4/4 PM linked commands passed, including the 13/13 executable workflow cases, both behavioural revert proofs and fresh-home regression. Existing sync workflow and caller template remain unchanged. PM command gap: unreadable head evidence is a zero-exit skip receipt without a typed error code; the workflow explicitly refuses it. Full-history API cost and live caller adoption remain operational considerations. Leaving this item in progress for orchestrator verification on feat/reusable-closeout-workflow."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T03:39:34.857Z"}],"before_hash":"6c4b75a634012c4e5de25a4452ee5529b82bb1a630f8aff9856190dc1716c50a","after_hash":"2686ca3a2798ba4cda1e64344304b362b022ab889f31727ee042f54decd4287e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"1e91a19686e5604c339263b36e862873f8c6862b72e4f0fb4766e9350b44ddfa"} +{"hash_algorithm":"sha256","ts":"2026-10-16T03:39:37.250Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"eb88e63f6d4d65a809daad60","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T03:39:37.250Z"}],"before_hash":"2686ca3a2798ba4cda1e64344304b362b022ab889f31727ee042f54decd4287e","after_hash":"6565fee1233ac3c50c1645c0ffe90c9a1406a1b30b8730fca5968f34bd7ead53","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"af4b001005a261c84c5984dcc5d55167869f9c918aa05fef9aa182bea1df66a7"} +{"hash_algorithm":"sha256","ts":"2026-10-16T03:55:35.943Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T03:55:35.943Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"6565fee1233ac3c50c1645c0ffe90c9a1406a1b30b8730fca5968f34bd7ead53","after_hash":"3ba1984ee46c6923213d9741c1a664064ffd1a3da74e4f97abd14676672477c0","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"430bcc74f8b3d4e139d22c06c776c4f28527eec1da8817e7ad7b5ff8a383c5ea"} +{"hash_algorithm":"sha256","ts":"2026-10-16T03:55:37.384Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-10-16T03:55:37.384Z","author":"codex-agent","text":"Review round 1: confirmed the workflow scans PR-level checks without requiring a blocked local tracker row, and incomplete merge metadata shares the unavailable rule. Plan: inspect a real read-only report, add stable plan decision codes, gate only local evidence refusals with separate merge handling, compact default plans and PR summaries, preserve full JSON/full output, and prove regressions by behavioural revert."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T03:55:37.384Z"}],"before_hash":"3ba1984ee46c6923213d9741c1a664064ffd1a3da74e4f97abd14676672477c0","after_hash":"f6baae6953f81e4ad2ce9d9bcab691d45ba4aa704f45f198580e947920ffa385","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"23123cb42b923041c5ecdbadface30bee519a9d47ec591abe0a70fa656c3c36d"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:00:12.909Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/4","value":{"command":"node --test --test-name-pattern=\"compact report\" test/closeout.test.ts","scope":"project","timeout_seconds":180,"provenance":{"author":"codex-agent","created_at":"2026-10-16T04:00:12.763Z","source_kind":"local_mutation","source_ref":"feat/reusable-closeout-workflow"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:00:12.909Z"}],"before_hash":"f6baae6953f81e4ad2ce9d9bcab691d45ba4aa704f45f198580e947920ffa385","after_hash":"967dab943d1729c29467855f8c7a91795f4969bb7aded6d5514ac140e9892885","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"6495a2af2197e359f2dd28f40b70f21c5249f20dca1242441d405fbdfd5c4409"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:00:36.204Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/1/path","value":"closeout.ts"},{"op":"add","path":"/metadata/files/2","value":{"path":"test/closeout-workflow.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:00:36.204Z"}],"before_hash":"967dab943d1729c29467855f8c7a91795f4969bb7aded6d5514ac140e9892885","after_hash":"1829ddb88c651568cd6b5f23d3b04af1e3ab3b5b30fc5ecba2436c5358e697f4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"2040c8f9c8f85961194ec65e8eb60a203a21b465fbe26cecacc6b8b58e4d0041"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:00:40.959Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/path","value":"index.ts"},{"op":"add","path":"/metadata/files/3","value":{"path":"test/closeout-workflow.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:00:40.959Z"}],"before_hash":"1829ddb88c651568cd6b5f23d3b04af1e3ab3b5b30fc5ecba2436c5358e697f4","after_hash":"94e109504402c9a5a9a310f77479da40ca26b9ff79862d40b6e9bd1b68765648","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"70db325005e0c98ffe7976294a4b3760d658561b60de6d231acadbcb6f1e4e40"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:00:44.820Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/4","value":{"path":"test/closeout.test.ts","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:00:44.820Z"}],"before_hash":"94e109504402c9a5a9a310f77479da40ca26b9ff79862d40b6e9bd1b68765648","after_hash":"c307e42d6ddab30a374ab824c404e8327ad0e2ac570b84986fd558ad75f3ef29","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"49025bb3c2a5b2baa7984f4125ede7186b79c00b300519678a42c47ba6ea2617"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:00:49.178Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-10-16T04:00:49.178Z","author":"codex-agent","text":"Live read-only pm-brief dry run since 2026-10-01 confirmed 47 full rows: 2 closes, 9 already-terminal skips, 35 foreign-repository skips and 1 duplicate selection. Original row fields were item/pr/action/reason; PR checks carried rule/reason/passed/ok. New additive decision codes distinguish head_evidence_unavailable from incomplete_merge and preserve full JSON; default TOON and review PR summaries list actionable rows and count routine skips. Summary content passes a dedicated privacy gate before publication."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:00:49.178Z"}],"before_hash":"c307e42d6ddab30a374ab824c404e8327ad0e2ac570b84986fd558ad75f3ef29","after_hash":"dc676515991fd3a24f3b02fcd973be7417b271b4fb0189433b6a22d86bdc13a7","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"89d7c926e6b2cc278994603d23a745cc9d96de4d599187c1ae1c90482291975f"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:03:07.870Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/13","value":{"created_at":"2026-10-16T04:03:07.870Z","author":"codex-agent","text":"Behavioural revert proof: restoring only the original workflow PR-check predicate made all 3 selected regressions fail (unlinked unavailable, linked incomplete merge, unlinked incomplete merge; exit 1). Restoring only the uncompacted report behaviour and rebuilding made both compact-report and packed CLI output regressions fail (2 tests; exit 1). Restored fixes. Updated output-focused acceptance passes 3/3; linked pm test compact-report run passes 1/1. Real pm-brief before/after default output is 76 to 25 lines (67.1% reduction), with identical 47 full plan rows and unchanged 2 intended closes."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:03:07.870Z"}],"before_hash":"dc676515991fd3a24f3b02fcd973be7417b271b4fb0189433b6a22d86bdc13a7","after_hash":"4dfd8091d6ce8f499372ffbd49e01374e2d0283334c5ba43c1982eb23265caab","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f22615a976a7bc5c4926c99c289fcff2d8e3c7190f9e1c274a96a14cc4864ccd"} +{"hash_algorithm":"sha256","ts":"2026-10-16T04:44:48.115Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/14","value":{"created_at":"2026-10-16T04:44:48.115Z","author":"codex-agent","text":"Initial full npm gate: 702/702 tests passed with zero failures/skips/cancellations, but the unchanged 100% branch gate correctly refused 99.89% coverage. Source-level registration coverage missed compact/default and full flag branches because the SDK test harness defaults to JSON. Strengthened the existing handler test to assert compact skip counts and full row preservation with global JSON disabled; that focused test passes 1/1 and lint passes. No production code or threshold changes. Re-running both complete release gates with these assertions."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T04:44:48.115Z"}],"before_hash":"4dfd8091d6ce8f499372ffbd49e01374e2d0283334c5ba43c1982eb23265caab","after_hash":"9248e90dc3ef35c2ac1560ef42eb932af6f44de593fa56b39eae9943ee6f6b95","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f0db6a79c7be7075f993c995882e5b2eff8fd919f3ee447506d2c482ddbc4591"} +{"hash_algorithm":"sha256","ts":"2026-10-16T05:19:04.632Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/15","value":{"created_at":"2026-10-16T05:19:04.632Z","author":"codex-agent","text":"Fresh bun run release:check passed: 702/702 tests, zero failures/skips/cancellations/todos; all 13 authored modules have exact 100/100/100/100 coverage (10068/10068 lines and statements, 256/256 functions, 2845/2845 branches). Typecheck/build/docstrings/lint, zero duplication, privacy, production audit, pack, changelog check and release attestation all passed. The previously missed output branches now execute in direct handler tests. Latest origin/main is already contained. Starting the final npm release gate at unchanged thresholds."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T05:19:04.632Z"}],"before_hash":"9248e90dc3ef35c2ac1560ef42eb932af6f44de593fa56b39eae9943ee6f6b95","after_hash":"3cc44c061fded9faa3da00b78fdc3d99365451c4740da18c8e6c57e18ee98b6e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2ee50f1b95982139e8c67b6c50f1574d047e3bc82af8fa541388bc0f71c6770c"} +{"hash_algorithm":"sha256","ts":"2026-10-16T05:52:03.065Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/16","value":{"created_at":"2026-10-16T05:52:03.065Z","author":"codex-agent","text":"Final npm run release:check and bun run release:check both passed with isolated HOME/Git identity: 702/702 tests each; zero failures, skips, cancellations or todos; exact 100/100/100/100 coverage across all 13 authored modules (10068 lines/statements, 256 functions, 2845 branches, all covered). All remaining release checks passed at unchanged thresholds and dependency pins. Revert baseline b3b5f33: node --test --test-name-pattern='unavailable evidence.*(unlinked|incomplete)' test/closeout-workflow.test.ts failed 3/3 selected regressions with only the original workflow evidence predicate restored. After restoring only uncompacted report behaviour and rebuilding, node --test --test-name-pattern='compact report|packed CLI' test/closeout.test.ts failed 2/2 selected regressions. Fixes restored before successful gates. The workflow's exact summary projection on the captured real pm-brief report yields 2 actionable rows and skip counts already_terminal=9, other_repository=35, duplicate_selection=1. Default output is 76 to 25 lines; all 47 rows remain in JSON/full output. Caller pins must target a release emitting decision codes. Releasing the claim for orchestrator verification; the item remains in_progress."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T05:52:03.065Z"}],"before_hash":"3cc44c061fded9faa3da00b78fdc3d99365451c4740da18c8e6c57e18ee98b6e","after_hash":"b54c1241658d7bffc69a63c6d743063464d18b90d9b04e4a3e4bf44f2f539fbd","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d1038bd70c99d98527a4d19cdf0bf3f41e1ae85e3961c35df18afd62bd9ff81a"} +{"hash_algorithm":"sha256","ts":"2026-10-16T05:52:04.621Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"27925bf5cb01cdb497b58c21","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-16T05:52:04.621Z"}],"before_hash":"b54c1241658d7bffc69a63c6d743063464d18b90d9b04e4a3e4bf44f2f539fbd","after_hash":"895a94f83bb7bc6448c8d2b1a86c591f516d71fc973c0c7d51d76281b2b314d4","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"58e56b56bbde29f6fc3990fa6992887959e2bd9c428c36a3d9a963fa57560875"} diff --git a/.agents/pm/tasks/pm-github-0x3y.toon b/.agents/pm/tasks/pm-github-0x3y.toon index 068584df..8cfc1c57 100644 --- a/.agents/pm/tasks/pm-github-0x3y.toon +++ b/.agents/pm/tasks/pm-github-0x3y.toon @@ -2,11 +2,44 @@ id: pm-github-0x3y title: Add a reusable gated closeout PR workflow description: Follow up on pm-github closeout with a workflow_call automation surface after command acceptance. Reuse the gated sync safety model without changing its existing workflow or inputs. type: Task -status: open +status: in_progress priority: 2 tags: [] created_at: "2026-10-14T21:46:44.372Z" -updated_at: "2026-10-14T21:46:44.372Z" +updated_at: "2026-10-16T05:52:04.621Z" author: codex-agent acceptance_criteria: "Run verified closeout on a bot branch and open or update a PR; never push main; validate tracker privacy, regenerate changelog, and dispatch required CI before publication.; Test clean, malicious, no-op, permission-limited and repeat invocations with explicit evidence; document least-privilege callers." +comments[17]{created_at,author,text}: + "2026-10-16T00:45:04.810Z",codex-agent,"Claimed. Implement a separate workflow_call without changing sync inputs or behavior. Preserve default-branch resolution, npm project containment, exact extension pin, full changed-path allowlist, staged-diff privacy gate, strict health, leased bot-only push and owned-PR lookup. Closeout scans merged PRs since 1970-01-01 to avoid missing deliveries after schedule gaps. Tests execute the real workflow bash with real SDK trackers and isolated Git remotes; only HTTP and external command boundaries are injected." + "2026-10-16T00:54:37.782Z",codex-agent,"Implementation and caller docs are complete. Safety helpers intentionally mirror the unchanged sync workflow, with executable parity assertions. Clean nested-project closeout opens one owned PR and repeat invocation edits it; malicious acceptance criteria is refused by the proposed-diff gate. Pure no-op and zero-close caller-only tracker drift publish nothing. Read, contents-write, PR-write and CI-dispatch refusals are explicit. Added a fixed bot/default-branch separation guard. Linked tests are running via pm test; repository policy disables automatic test-result tracking, so verified receipts will be recorded in comments." + "2026-10-16T00:56:48.557Z",codex-agent,"Behavioural revert proofs: node --test --test-name-pattern=behavioural test/closeout-workflow.test.ts passed 2/2 (zero failures/skips). With only the staged-diff privacy scan call removed from the executable scope helper, malicious acceptance criteria and copied close evidence were committed and pushed to the isolated bare remote and a PR receipt was created. The intact workflow refuses that same fixture before push. With only the zero-close/no-tracker-diff conditional removed, a run reporting closed=0 opened a PR containing caller-only tracker drift; the intact workflow creates no remote branch or PR for the same zero-close case. These are real tracker mutations, real Git commits/pushes and bash extracted from the actual workflow; only HTTP/publication command boundaries are injected." + "2026-10-16T01:10:06.476Z",codex-agent,"Linked workflow acceptance: pm test pm-github-0x3y --run --only-last --progress executed node --test test/closeout-workflow.test.ts and passed 13/13, with 0 failures, skips, cancellations or todos. The earlier focused contract command also passed. Covered owned-PR repeat behavior, schedule without repository metadata, all requested refusal/no-op cases, both behavioural reverts, input validation and competing-writer lease rejection. PM returned evidence_recording.reason=tracking_disabled under repository policy; this comment retains the verified receipt without changing that policy." + "2026-10-16T01:13:07.054Z",codex-agent,"Final permission review: closeout reads the Checks API, not GitHub issues. The caller and reusable job now grant checks:read instead of sync issues:read; contents:write, pull-requests:write and actions:write remain unchanged. Official GitHub Checks API documentation requires the read grant for private check runs. The permission-limited fixture now also denies only check-run reads and verifies zero writes/publication. Re-running the full gates on this final configuration; sync remains untouched." + "2026-10-16T01:16:45.532Z",codex-agent,"Permission regression now passes through pm test: the fixture denies all PR reads, then denies only check-run reads. Both runs fail explicitly before publication and leave the item in progress. Existing command gap encountered: unavailable head evidence is returned as a skip receipt with exit 0, without a typed error code. The workflow checks unavailable/incomplete evidence in the receipt and turns it into a clear failure; command behavior remains unchanged. Final contract test and lint/typechecking pass with checks:read in both caller and reusable job. Full release gates are restarting with implementation frozen." + "2026-10-16T02:08:16.197Z",codex-agent,"Final npm gate passed with CI identity isolation: env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 npm run release:check. Tests 694/694; failures/skips/cancellations/todos all 0. Exact coverage across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Thresholds and package pins unchanged. Production audit reports 0 vulnerabilities; docstrings, zero-duplication, privacy, pack dry-run, changelog and publish-attestation checks passed. Starting the requested Bun gate and final full linked-test run." + "2026-10-16T02:19:44.776Z",codex-agent,"Bun validation exposed a test-fixture setup defect: the package receipt parser combined stdout and stderr, so first-use npm update notices after JSON made parsing fail in a fresh home. Reproduced with the fresh-home contract command (failed before the fix), separated stdout from diagnostic streams, then the identical command passed 1/1. Lint and test typechecking pass. Production workflow logic is unchanged. Earlier npm result was 694/694 with exact complete coverage; both complete gates are being rerun after this test-only fix. Added the fresh-home regression to linked tests." + "2026-10-16T02:34:54.392Z",codex-agent,"Final unfiltered linked-test run passed: pm test pm-github-0x3y --run --progress completed all 4/4 registered commands with exit 0 and every failure category at 0. The full workflow suite passed 13/13 with 0 failures/skips/cancellations/todos; the focused contract, permission-limited token and fresh-home package-receipt regressions each passed. Automatic test-result recording remains disabled by repository policy, so this comment preserves the verified receipt. Both required release gates remain in progress after the test-helper stream fix." + "2026-10-16T03:05:47.391Z",codex-agent,"Npm release:check passed again after the package-receipt stream fix, using isolated CI identity. Tests 694/694 with 0 failures/skips/cancellations/todos. Coverage remains exact across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Production audit 0 vulnerabilities; every static, privacy, pack, changelog and attestation stage passed. The final 4/4 PM linked commands are also green. Starting the corrected Bun release gate on this same frozen implementation." + "2026-10-16T03:39:34.857Z",codex-agent,"Both final release gates passed on the corrected frozen implementation: npm run release:check and bun run release:check, each with isolated CI identity and 694/694 tests; failures/skips/cancellations/todos all 0. Both report exact coverage across 13 authored modules: lines 10040/10040, statements 10040/10040, functions 255/255, branches 2832/2832. Unchanged thresholds and dependency pins; 0 duplicated authored lines and 0 production vulnerabilities. Docstrings, privacy, pack, changelog and publish-attestation checks passed in both gates. All 4/4 PM linked commands passed, including the 13/13 executable workflow cases, both behavioural revert proofs and fresh-home regression. Existing sync workflow and caller template remain unchanged. PM command gap: unreadable head evidence is a zero-exit skip receipt without a typed error code; the workflow explicitly refuses it. Full-history API cost and live caller adoption remain operational considerations. Leaving this item in progress for orchestrator verification on feat/reusable-closeout-workflow." + "2026-10-16T03:55:37.384Z",codex-agent,"Review round 1: confirmed the workflow scans PR-level checks without requiring a blocked local tracker row, and incomplete merge metadata shares the unavailable rule. Plan: inspect a real read-only report, add stable plan decision codes, gate only local evidence refusals with separate merge handling, compact default plans and PR summaries, preserve full JSON/full output, and prove regressions by behavioural revert." + "2026-10-16T04:00:49.178Z",codex-agent,"Live read-only pm-brief dry run since 2026-10-01 confirmed 47 full rows: 2 closes, 9 already-terminal skips, 35 foreign-repository skips and 1 duplicate selection. Original row fields were item/pr/action/reason; PR checks carried rule/reason/passed/ok. New additive decision codes distinguish head_evidence_unavailable from incomplete_merge and preserve full JSON; default TOON and review PR summaries list actionable rows and count routine skips. Summary content passes a dedicated privacy gate before publication." + "2026-10-16T04:03:07.870Z",codex-agent,"Behavioural revert proof: restoring only the original workflow PR-check predicate made all 3 selected regressions fail (unlinked unavailable, linked incomplete merge, unlinked incomplete merge; exit 1). Restoring only the uncompacted report behaviour and rebuilding made both compact-report and packed CLI output regressions fail (2 tests; exit 1). Restored fixes. Updated output-focused acceptance passes 3/3; linked pm test compact-report run passes 1/1. Real pm-brief before/after default output is 76 to 25 lines (67.1% reduction), with identical 47 full plan rows and unchanged 2 intended closes." + "2026-10-16T04:44:48.115Z",codex-agent,"Initial full npm gate: 702/702 tests passed with zero failures/skips/cancellations, but the unchanged 100% branch gate correctly refused 99.89% coverage. Source-level registration coverage missed compact/default and full flag branches because the SDK test harness defaults to JSON. Strengthened the existing handler test to assert compact skip counts and full row preservation with global JSON disabled; that focused test passes 1/1 and lint passes. No production code or threshold changes. Re-running both complete release gates with these assertions." + "2026-10-16T05:19:04.632Z",codex-agent,"Fresh bun run release:check passed: 702/702 tests, zero failures/skips/cancellations/todos; all 13 authored modules have exact 100/100/100/100 coverage (10068/10068 lines and statements, 256/256 functions, 2845/2845 branches). Typecheck/build/docstrings/lint, zero duplication, privacy, production audit, pack, changelog check and release attestation all passed. The previously missed output branches now execute in direct handler tests. Latest origin/main is already contained. Starting the final npm release gate at unchanged thresholds." + "2026-10-16T05:52:03.065Z",codex-agent,"Final npm run release:check and bun run release:check both passed with isolated HOME/Git identity: 702/702 tests each; zero failures, skips, cancellations or todos; exact 100/100/100/100 coverage across all 13 authored modules (10068 lines/statements, 256 functions, 2845 branches, all covered). All remaining release checks passed at unchanged thresholds and dependency pins. Revert baseline b3b5f33: node --test --test-name-pattern='unavailable evidence.*(unlinked|incomplete)' test/closeout-workflow.test.ts failed 3/3 selected regressions with only the original workflow evidence predicate restored. After restoring only uncompacted report behaviour and rebuilding, node --test --test-name-pattern='compact report|packed CLI' test/closeout.test.ts failed 2/2 selected regressions. Fixes restored before successful gates. The workflow's exact summary projection on the captured real pm-brief report yields 2 actionable rows and skip counts already_terminal=9, other_repository=35, duplicate_selection=1. Default output is 76 to 25 lines; all 47 rows remain in JSON/full output. Caller pins must target a release emitting decision codes. Releasing the claim for orchestrator verification; the item remains in_progress." +files[5]{path,scope}: + .github/workflows/pm-github-closeout.yml,project + closeout.ts,project + index.ts,project + test/closeout-workflow.test.ts,project + test/closeout.test.ts,project +tests[5]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: + node --test --test-name-pattern=closeout.contract test/closeout-workflow.test.ts,project,1200,codex-agent,"2026-10-16T00:51:51.522Z",local_mutation,feat/reusable-closeout-workflow + node --test test/closeout-workflow.test.ts,project,1200,codex-agent,"2026-10-16T00:52:51.590Z",local_mutation,feat/reusable-closeout-workflow + node --test --test-name-pattern=permission-limited test/closeout-workflow.test.ts,project,1200,codex-agent,"2026-10-16T01:13:36.961Z",local_mutation,feat/reusable-closeout-workflow + env HOME=$(mktemp -d) GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 node --test --test-name-pattern=closeout.contract test/closeout-workflow.test.ts,project,1200,codex-agent,"2026-10-16T02:19:38.716Z",local_mutation,feat/reusable-closeout-workflow + "node --test --test-name-pattern=\"compact report\" test/closeout.test.ts",project,180,codex-agent,"2026-10-16T04:00:12.763Z",local_mutation,feat/reusable-closeout-workflow +docs[2]{path,scope}: + docs/closeout-workflow-caller.yml,project + README.md,project body: "" diff --git a/.github/workflows/pm-github-closeout.yml b/.github/workflows/pm-github-closeout.yml new file mode 100644 index 00000000..cfd14160 --- /dev/null +++ b/.github/workflows/pm-github-closeout.yml @@ -0,0 +1,405 @@ +# Reusable closeout: verified merged delivery → gated tracker diff → review PR. +# Scope helpers intentionally match gated sync; executable parity tests prevent drift. +# The existing sync workflow and its inputs are unchanged. + +name: Gated merged-PR → pm closeout + +on: + workflow_call: + inputs: + repository: + description: "owner/repo whose merged PRs deliver items (defaults to the calling repository)" + type: string + required: false + default: ${{ github.repository }} + pm-github-version: + description: "Exact published pm-github version to install (pin; never a floating tag or latest)" + type: string + required: true + npm-project-dir: + description: "Caller npm project directory for dependencies and post-closeout-script (relative, inside the workspace)" + type: string + required: false + default: "." + post-closeout-script: + description: "Caller npm script after closeout, before the final gate; missing skips, empty disables it" + type: string + required: false + default: "changelog:full" + ci-workflows: + description: "Whitespace-separated caller workflow filenames with workflow_dispatch; empty disables dispatch" + type: string + required: false + default: "ci.yml" + node-version: + description: "Node.js version for the pm CLI" + type: string + required: false + default: "22.18.0" + +concurrency: + group: pm-github-closeout-${{ github.repository }} + cancel-in-progress: false + +# No workflow-wide grant: the job below requests exactly what it needs. +permissions: {} + +jobs: + gated-closeout: + # Read delivery evidence, push only the closeout branch, and maintain one PR. + permissions: + contents: write + pull-requests: write + checks: read + actions: write + runs-on: ubuntu-latest + env: + REPOSITORY: ${{ inputs.repository }} + SYNC_BRANCH: pm-github/closeout + PM_GITHUB_VERSION: ${{ inputs.pm-github-version }} + NPM_PROJECT_DIR: ${{ inputs.npm-project-dir }} + POST_CLOSEOUT_SCRIPT: ${{ inputs.post-closeout-script }} + CI_WORKFLOWS: ${{ inputs.ci-workflows }} + CALLING_REPOSITORY: ${{ github.repository }} + steps: + - name: Require caller default branch + id: caller_branch + env: + RUN_REF: ${{ github.ref }} + EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + DEFAULT_BRANCH="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.default_branch // empty' 2>/dev/null)" || DEFAULT_BRANCH="" + DEFAULT_BRANCH="${DEFAULT_BRANCH:-${EVENT_DEFAULT_BRANCH:-}}" + if [[ -z "${DEFAULT_BRANCH}" ]] || ! git check-ref-format "refs/heads/${DEFAULT_BRANCH}" >/dev/null 2>&1; then + echo "::error::Cannot resolve a valid caller default branch from the repository API or event payload." + exit 1 + fi + expected_ref="refs/heads/${DEFAULT_BRANCH}" + if [[ "${RUN_REF}" != "${expected_ref}" ]]; then + printf '::error::Closeout ref "%s" must match caller default branch ref "%s".\n' "${RUN_REF}" "${expected_ref}" + exit 1 + fi + printf 'branch=%s\n' "${DEFAULT_BRANCH}" >> "${GITHUB_OUTPUT}" + printf 'DEFAULT_BRANCH=%s\n' "${DEFAULT_BRANCH}" >> "${GITHUB_ENV}" + + - name: Validate closeout inputs + shell: bash + run: | + set -euo pipefail + [[ "${REPOSITORY}" == "${CALLING_REPOSITORY}" ]] || { echo "::error::repository must equal the calling repository (${CALLING_REPOSITORY})."; exit 1; } + [[ "${PM_GITHUB_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]] || { echo "::error::pm-github-version must be an exact numeric version: N.N.N or N.N.N-N."; exit 1; } + [[ "${SYNC_BRANCH}" == pm-github/closeout ]] || { echo "::error::Closeout branch must be pm-github/closeout."; exit 1; } + git check-ref-format "refs/heads/${SYNC_BRANCH}" + [[ "${SYNC_BRANCH}" != "${DEFAULT_BRANCH}" ]] || { echo "::error::The closeout bot branch cannot be the caller default branch."; exit 1; } + [[ -z "${POST_CLOSEOUT_SCRIPT:-}" || "${POST_CLOSEOUT_SCRIPT:-}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.:-]*$ ]] || { echo "::error::post-closeout-script must be an npm script name, not a shell command."; exit 1; } + + - name: Checkout caller default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + ref: ${{ steps.caller_branch.outputs.branch }} + persist-credentials: false + + - name: Validate npm project directory + shell: bash + run: | + set -euo pipefail + invalid_npm_project() { + echo "::error::npm-project-dir must be a relative path without .. segments, resolve inside the workspace, and contain package.json." + exit 1 + } + [[ -n "${NPM_PROJECT_DIR}" && "${NPM_PROJECT_DIR}" != /* && "${NPM_PROJECT_DIR}" != *[$'\r\n:']* ]] || invalid_npm_project + case "/${NPM_PROJECT_DIR}/" in */../*) invalid_npm_project ;; esac + workspace="$(realpath -e -- "${GITHUB_WORKSPACE}" 2>/dev/null)" || invalid_npm_project + IFS= read -r -d '' npm_project_path < <(realpath -ez -- "${GITHUB_WORKSPACE}/${NPM_PROJECT_DIR}" 2>/dev/null) || invalid_npm_project + [[ "${npm_project_path}" == "${workspace}" || "${npm_project_path}" == "${workspace}/"* ]] || invalid_npm_project + [[ "${npm_project_path}" != *[$'\r\n:']* && -f "${npm_project_path}/package.json" ]] || invalid_npm_project + printf 'NPM_PROJECT_PATH=%s\n' "${npm_project_path}" >> "${GITHUB_ENV}" + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: | + ${{ env.NPM_PROJECT_PATH }}/package-lock.json + ${{ env.NPM_PROJECT_PATH }}/npm-shrinkwrap.json + + # Rebuild the closeout branch from the current default branch: it only + # holds automated closeout commits, keeping the PR a clean review of + # exactly this run's proposed change instead of an ever-growing merge. + - name: Prepare sync branch from default branch + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + sync_lease="$(git rev-parse --verify "refs/remotes/origin/${SYNC_BRANCH}" 2>/dev/null || true)" + printf 'SYNC_LEASE=%s\n' "${sync_lease}" >> "${GITHUB_ENV}" + git switch --force-create "${SYNC_BRANCH}" "origin/${DEFAULT_BRANCH}" + + # The calling repo's own devDependency provides the pm CLI (npm ci). + - name: Install dependencies + shell: bash + run: | + set -euo pipefail + (cd "${NPM_PROJECT_PATH}" && npm ci --ignore-scripts) + printf '%s/node_modules/.bin\n' "${NPM_PROJECT_PATH}" >> "${GITHUB_PATH}" + + # The explicit publication allowlist is the SDK-resolved tracker (except + # extensions, locks, checkpoints and extension setup files) and CHANGELOG.md. Nothing else may + # change, even if unstaged or untracked. Keep all boundaries on this list. + - name: Resolve sync commit scope + shell: bash + run: | + set -euo pipefail + PM_SYNC_TRACKER="$(cd "${NPM_PROJECT_PATH}" && node --input-type=module -e 'import { resolvePmRoot } from "@unbrained/pm-cli/sdk"; import { relative } from "node:path"; const p = relative(process.env.GITHUB_WORKSPACE, resolvePmRoot(process.env.GITHUB_WORKSPACE)); if (!p || p === ".." || p.startsWith("../") || /[\r\n]/.test(p)) throw new Error("Sync tracker must be inside the repository"); process.stdout.write(p);')" + export PM_SYNC_TRACKER + printf 'PM_SYNC_TRACKER=%s\n' "${PM_SYNC_TRACKER}" >> "${GITHUB_ENV}" + cat > "${RUNNER_TEMP}/pm-github-sync-scope.sh" <<'SCOPE' + sync_scope=(":(top,literal)${PM_SYNC_TRACKER}" ':(top,literal)CHANGELOG.md' + ":(top,exclude,literal)${PM_SYNC_TRACKER}/extensions" + ":(top,exclude,literal)${PM_SYNC_TRACKER}/locks" + ":(top,exclude,literal)${PM_SYNC_TRACKER}/checkpoints" + ":(top,exclude,literal)${PM_SYNC_TRACKER}/settings.json" + ":(top,exclude,literal)${PM_SYNC_TRACKER}/history/_workspace.jsonl") + restore_sync_setup() { + for setup_path in "${PM_SYNC_TRACKER}/settings.json" "${PM_SYNC_TRACKER}/history/_workspace.jsonl"; do + if git cat-file -e "HEAD:${setup_path}" 2>/dev/null; then + git --literal-pathspecs restore --source=HEAD --staged --worktree -- "${setup_path}" + else + git --literal-pathspecs rm -q --cached --ignore-unmatch -- "${setup_path}" + rm -f -- "${setup_path}" + fi + done + } + check_sync_scope() { + local entry path failed=0 + git status --porcelain=v1 -z --untracked-files=all > "${RUNNER_TEMP}/pm-github-sync-status" + while IFS= read -r -d '' entry; do + path="${entry:3}" + check_sync_path "${path}" || failed=1 + # Porcelain -z renames/copies contain a second, original path. + if [[ "${entry:0:2}" == *R* || "${entry:0:2}" == *C* ]]; then + IFS= read -r -d '' path || { echo '::error::Incomplete Git status record.'; return 1; } + check_sync_path "${path}" || failed=1 + fi + done < "${RUNNER_TEMP}/pm-github-sync-status" + return "${failed}" + } + check_sync_path() { + case "$1" in + "${PM_SYNC_TRACKER}"/extensions|"${PM_SYNC_TRACKER}"/extensions/*|"${PM_SYNC_TRACKER}"/locks|"${PM_SYNC_TRACKER}"/locks/*|"${PM_SYNC_TRACKER}"/checkpoints|"${PM_SYNC_TRACKER}"/checkpoints/*) ;; + "${PM_SYNC_TRACKER}"/*|CHANGELOG.md) return 0 ;; + esac + printf '::error::Changed path outside sync commit scope: %q\n' "$1" + return 1 + } + gate_sync_change() { + check_sync_scope + git ls-files -z --modified --deleted --others --exclude-standard -- "${sync_scope[0]}" "${sync_scope[@]:2}" > "${RUNNER_TEMP}/pm-github-sync-stage-paths" + if [[ -s "${RUNNER_TEMP}/pm-github-sync-stage-paths" ]]; then + git --literal-pathspecs add --pathspec-from-file="${RUNNER_TEMP}/pm-github-sync-stage-paths" --pathspec-file-nul + fi + if [[ -e CHANGELOG.md ]] || git ls-files --error-unmatch -- CHANGELOG.md >/dev/null 2>&1; then + git --literal-pathspecs add -- CHANGELOG.md + fi + git diff --cached --name-only -z --no-renames -- "${sync_scope[@]}" > "${RUNNER_TEMP}/pm-github-sync-commit-paths" + mapfile -d '' -t sync_paths < "${RUNNER_TEMP}/pm-github-sync-commit-paths" + : > "${RUNNER_TEMP}/pm-github-sync.patch" + if [[ ${#sync_paths[@]} -gt 0 ]]; then + git --literal-pathspecs diff --cached --no-color --no-ext-diff --no-textconv --no-renames --binary --unified=2147483647 -- "${sync_paths[@]}" > "${RUNNER_TEMP}/pm-github-sync.patch" + fi + pm github gate --diff "${RUNNER_TEMP}/pm-github-sync.patch" --json + } + SCOPE + + - name: Install pinned pm-github extension + shell: bash + run: | + set -euo pipefail + pm package install "npm:pm-github@${PM_GITHUB_VERSION}" --project + node -e 'if (require("./" + process.argv[2] + "/extensions/pm-github/package.json").version !== process.argv[1]) process.exit(1)' "${PM_GITHUB_VERSION}" "${PM_SYNC_TRACKER}" + + - name: Configure PM merge drivers + run: pm merge install + + - name: Verify gate is available + run: pm github gate --json + + - name: Close delivered tracker items + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + # Full history catches up after missed schedules; the command skips terminal items. + # Keep the report private until the proposed change has passed the privacy gate. + if ! pm github closeout --repo "${REPOSITORY}" --since 1970-01-01 --apply --json > "${RUNNER_TEMP}/pm-github-closeout.json" 2> "${RUNNER_TEMP}/pm-github-closeout.err"; then + echo "::error::Closeout refused; check token read access to pull requests, checks and branch rules, and tracker close validation. No branch was published." + exit 1 + fi + jq -e '.command == "github closeout" and .dry_run == false and .failed == 0 and (.closed | type == "number")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null + # Only a local tracker refusal makes missing head evidence actionable. + # Foreign, terminal, excluded and unlinked PRs cannot block this run. + jq -e 'all(.plan[]; (.code | type) == "string")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null + if jq -e 'any(.plan[]; .action == "skip" and .code == "head_evidence_unavailable")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null; then + echo "::error::Closeout refused; check token read access to head checks for linked local tracker items. No branch was published." + exit 1 + fi + # Merge facts are checked before head lookup and have a distinct decision. + if jq -e 'any(.plan[]; .action == "skip" and .code == "incomplete_merge")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null; then + echo "::error::Closeout refused; incomplete merged-PR metadata blocks a linked local tracker item. No branch was published." + exit 1 + fi + printf 'CLOSEOUT_CLOSED=%s\n' "$(jq -r '.closed' "${RUNNER_TEMP}/pm-github-closeout.json")" >> "${GITHUB_ENV}" + + - name: Run caller post-closeout script + shell: bash + run: | + set -euo pipefail + if [[ -n "${POST_CLOSEOUT_SCRIPT}" ]]; then + (cd "${NPM_PROJECT_PATH}" && npm run --if-present "${POST_CLOSEOUT_SCRIPT}") + fi + + # Check every changed path immediately after the caller script. Extension + # setup files stay outside the patch and are restored before publication. + - name: Gate tracker and regenerated changelog + shell: bash + run: | + set -euo pipefail + source "${RUNNER_TEMP}/pm-github-sync-scope.sh" + gate_sync_change + + - name: Verify strict pm health + run: pm health --strict-exit + + - name: Commit gated closeout changes and open review PR + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + trap 'echo "::error::Closeout publication refused; token requires contents:write and pull-requests:write, and repository settings must allow Actions to create PRs."' ERR + source "${RUNNER_TEMP}/pm-github-sync-scope.sh" + gate_sync_change + # gh pr list --head matches branch names, and a fork can open a PR + # from a branch with the same name, so filter the results to PRs whose + # head repository is this repository (never trust .[0] alone). + own_open_pr() { + gh pr list -R "${REPOSITORY}" --head "${SYNC_BRANCH}" --base "${DEFAULT_BRANCH}" --state open \ + --json number,headRepository,headRepositoryOwner,isCrossRepository \ + | jq -r --arg repository "${REPOSITORY}" \ + '[ .[] | select(.isCrossRepository == false and (.headRepositoryOwner.login + "/" + .headRepository.name) == $repository) | .number ][0] // empty' + } + close_own_open_pr() { + local number + number="$(own_open_pr)" + if [[ -n "${number}" ]]; then + gh pr close "${number}" -R "${REPOSITORY}" --comment "No delivered tracker items remain to close; closing this review PR." + fi + } + if [[ "${CLOSEOUT_CLOSED}" == 0 ]] || git diff --cached --quiet -- "${sync_scope[0]}" "${sync_scope[@]:2}"; then + # Changelog drift alone belongs to the caller's changelog check; + # publish regenerated output only alongside tracker changes. + if ! git diff --cached --quiet -- CHANGELOG.md; then + if git cat-file -e HEAD:CHANGELOG.md 2>/dev/null; then + git --literal-pathspecs restore --source=HEAD --staged --worktree -- CHANGELOG.md + else + git --literal-pathspecs rm -q --cached --ignore-unmatch -- CHANGELOG.md + rm -f -- CHANGELOG.md + fi + echo "::notice::The caller's changelog differs from a fresh regeneration; the repository's own changelog:check owns that drift. Discarding CHANGELOG.md because no tracker item changed." + fi + restore_sync_setup + echo "No delivered tracker items closed; no branch or PR is needed." + # A stale own review PR would still look actionable although this + # run has no change to propose, so close it before returning. + close_own_open_pr + exit 0 + fi + git diff --cached --check + links_file="${RUNNER_TEMP}/pm-github-sync-links.md" + : > "${links_file}" + while IFS= read -r changed; do + case "${changed}" in + "${PM_SYNC_TRACKER}"/*/*.toon) + item_id="${changed##*/}" + item_id="${item_id%.toon}" + # The default-branch link is the permanent one after merge; the branch link + # shows the proposed item while the review PR is open. + printf -- '- [%s](https://github.com/%s/blob/%s/%s) ([proposed](https://github.com/%s/blob/%s/%s))\n' "${item_id}" "${REPOSITORY}" "${DEFAULT_BRANCH}" "${changed}" "${REPOSITORY}" "${SYNC_BRANCH}" "${changed}" >> "${links_file}" + ;; + esac + done < <(git diff --cached --name-only -- "${sync_scope[0]}" "${sync_scope[@]:2}") + if [[ ! -s "${links_file}" ]]; then + echo "::error::Closeout changed tracker state but no item link could be generated." + exit 1 + fi + # Publish only actionable decisions and counts, with no API prose. + summary_file="${RUNNER_TEMP}/pm-github-closeout-summary.json" + jq '{plan: [.plan[] | select(.code != "already_terminal" and .code != "other_repository" and .code != "duplicate_selection" and .code != "explicit_skip") | {item, pr, action: (if .action == "close" then "close" else "refuse" end), code}], skipped: ([.plan[] | select(.code == "already_terminal" or .code == "other_repository" or .code == "duplicate_selection" or .code == "explicit_skip")] | group_by(.code) | map({key: .[0].code, value: length}) | from_entries)}' "${RUNNER_TEMP}/pm-github-closeout.json" > "${summary_file}" + # Scan the public summary as added diff content before any publication. + summary_patch="${RUNNER_TEMP}/pm-github-closeout-summary.patch" + { + printf '%s\n' '--- /dev/null' '+++ b/closeout-summary.json' + printf '@@ -0,0 +1,%s @@\n' "$(wc -l < "${summary_file}")" + sed 's/^/+/' "${summary_file}" + } > "${summary_patch}" + pm github gate --diff "${summary_patch}" --json + # Re-enumerate all status paths, re-stage and re-scan immediately before + # committing. --only prevents any unrelated staged path from leaking + # even if the scope check is bypassed. NUL paths preserve odd filenames. + gate_sync_change + restore_sync_setup + check_sync_scope + git --literal-pathspecs commit --only --pathspec-from-file="${RUNNER_TEMP}/pm-github-sync-commit-paths" --pathspec-file-nul -m "chore(pm): close items delivered by merged pull requests" + gh auth setup-git + git push --force-with-lease="refs/heads/${SYNC_BRANCH}:${SYNC_LEASE}" origin "HEAD:refs/heads/${SYNC_BRANCH}" + body_file="${RUNNER_TEMP}/pm-github-sync-pr.md" + { + printf '%s\n\n' "Automated pm-github merged-PR closeout for ${REPOSITORY}." + printf '%s\n\n' "The closeout command verified merged-PR delivery and head checks. The proposed tracker change and regenerated changelog passed the fail-closed privacy gate before anything was committed. \`${DEFAULT_BRANCH}\` is never pushed directly." + printf '%s\n\n' "Tracker items changed:" + cat "${links_file}" + printf '\n%s\n\n%s\n' 'Closeout plan (routine skips counted by reason):' '```json' + cat "${summary_file}" + printf '%s\n' '```' + } > "${body_file}" + pr_number="$(own_open_pr)" + if [[ -z "${pr_number}" ]]; then + gh pr create -R "${REPOSITORY}" --base "${DEFAULT_BRANCH}" --head "${SYNC_BRANCH}" --title "chore(pm): close items delivered by merged pull requests" --body-file "${body_file}" + else + gh pr edit "${pr_number}" -R "${REPOSITORY}" --title "chore(pm): close items delivered by merged pull requests" --body-file "${body_file}" + fi + export SYNC_PUBLISHED=true + printf 'SYNC_PUBLISHED=true\n' >> "${GITHUB_ENV}" + + - name: Dispatch caller CI and report parked runs + if: env.SYNC_PUBLISHED == 'true' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + trap 'echo "::error::Caller CI dispatch failed; token requires actions:write and configured workflows must support workflow_dispatch. The review PR remains unmerged."' ERR + [[ "${SYNC_PUBLISHED:-false}" == true ]] || exit 0 + while IFS= read -r workflow_file; do + [[ -n "${workflow_file}" ]] || continue + [[ "${workflow_file}" =~ ^[a-zA-Z0-9_.-]+\.ya?ml$ ]] || { echo "::error::ci-workflows must contain workflow filenames."; exit 1; } + if [[ ! -f ".github/workflows/${workflow_file}" ]]; then + echo "::notice::Skipping missing caller workflow: ${workflow_file}" + continue + fi + gh workflow run "${workflow_file}" -R "${REPOSITORY}" --ref "${SYNC_BRANCH}" + done < <(printf '%s\n' "${CI_WORKFLOWS}" | tr ' \t\r' '\n') + head_sha="$(git rev-parse HEAD)" + parked_runs="$(gh run list -R "${REPOSITORY}" --commit "${head_sha}" --status action_required --limit 100 --json url --jq '.[].url')" + while IFS= read -r run_url; do + [[ -n "${run_url}" ]] || continue + echo "::notice::A pull-request workflow requires maintainer approval: ${run_url}. Review this parked run if needed; caller CI dispatch runs are separate." + done <<< "${parked_runs}" diff --git a/README.md b/README.md index 6ce79111..2dd0044b 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,15 @@ It never discards a known requirement because another source is unreadable. Missing, malformed or incomplete head evidence, malformed metadata and other metadata errors fail closed. The plan reports which rule was used. -TOON is the compact default; global `--json` returns the stable shape -`{command, repo, dry_run, plan, checks, closed, failed}`. Each plan row is exactly -`{item, pr, action: "close" | "skip", reason}`. `checks` reports each PR's rule, +TOON lists actionable `close`/`refuse` rows individually and summarizes routine skips +as counts by stable code, for example `skipped: {already_terminal: 30, +other_repository: 10, duplicate_selection: 5}`. Explicit exclusions use +`explicit_skip`. Only checks for actionable rows are shown. Use `--full` for every +plan row and selected PR check. Global `--json` always returns the full shape +`{command, repo, dry_run, plan, checks, closed, failed}`. Each full plan row is +`{item, pr, action: "close" | "skip", code, reason}`. Codes distinguish +`head_evidence_unavailable`, `incomplete_merge`, `head_checks_failed`, and +`head_checks_passed` without parsing explanations. `checks` reports each PR's rule, verdict, explanation and successful check names. Dry-run rows describe intended closes; `closed` counts writes only with `--apply`. `--dry-run` overrides `--apply`. All API planning finishes before the first SDK close. Individual SDK @@ -111,8 +117,8 @@ criteria remain the only prose source; PR body and comments supply no field text Completion time is the GitHub merge time. Repeating a completed selection skips terminal items and performs zero additional tracker writes. The command edits only the tracker; commit it with its history and regenerate the caller's -changelog in a closeout PR. Reusable bot-branch automation is tracked separately -in [pm-github-0x3y](https://github.com/unbraind/pm-github/blob/main/.agents/pm/tasks/pm-github-0x3y.toon). +changelog in a closeout PR. The [automated closeout workflow](#automated-closeout-workflow) +performs that daily review-PR preparation for fleet repositories. ## Revalidate GitHub blockers @@ -444,6 +450,72 @@ Rendered values are also scanned in memory before mutation, so a known finding c The `gate` receipt reports the verdict, scanned file count, findings, and allowlisted count. With `--json`, successful reports go to stdout; failures use the host CLI's JSON error envelope on stderr, with the redacted gate report serialized in `detail` (`jq ' .detail | fromjson ' gate-error.json`). Unreadable inputs produce a nonzero JSON refusal. +## Automated closeout workflow + +[`.github/workflows/pm-github-closeout.yml`](.github/workflows/pm-github-closeout.yml) +is a reusable `workflow_call` that opens or updates **one** PR titled +`chore(pm): close items delivered by merged pull requests` from `pm-github/closeout`. +Copy [the caller template](docs/closeout-workflow-caller.yml) into the caller's +workflows directory and replace `RELEASE_COMMIT_SHA` and `RELEASE_VERSION` with a +reviewed workflow commit and an exact published pm-github version containing +`github closeout`. The template schedules a daily run and allows manual dispatch. +Adopting callers is a separate change; the existing sync workflow is unchanged. + +The workflow resolves the caller's default branch through the repository API, +with event metadata as fallback, and refuses other run refs before checkout. +This works for scheduled payloads without a `repository` object and for branches +such as `trunk` or `release/stable`. The caller's pinned PM CLI comes from +`npm ci --ignore-scripts` in `npm-project-dir` (default `.`), which must resolve +inside the workspace and contain `package.json`. The SDK resolves the tracker +from the repository root. The exact `pm-github-version` is installed and verified. + +Each run rebuilds the bot branch from the current default branch and invokes +`pm github closeout --since 1970-01-01 --apply --json`. Scanning all merge history +avoids losing deliveries during schedule outages; it costs more API reads for +large repositories. Terminal items are skipped and the command retains its +normal eligibility, child/epic, required-check and strict-close rules. Full +closeout reports stay in runner scratch storage. The PR body lists +actionable rows with item, PR number, action and code, plus skip counts by reason. +This compact summary passes its own privacy scan before publication. The caller's `post-closeout-script` defaults to `changelog:full`, +run through `npm run --if-present` in the npm project; empty disables it. +The script must emit the root `CHANGELOG.md` and remain inside the publication scope. + +| Safety boundary | Closeout and sync parity | +| --- | --- | +| Entry and install | Caller default-branch guard, contained npm project, exact package version, SHA-pinned Actions, no persisted checkout credentials | +| Proposed publication | SDK-resolved tracker plus root changelog; setup, extension, lock and checkpoint files excluded; every changed path checked | +| Privacy | Same fail-closed full-context staged-diff scan after the caller script and immediately before commit; unreadable input refuses publication | +| Validation | Strict PM health; explicit item/history scope committed together | +| Branch and PR | Explicit force-with-lease against the observed bot ref; push only bot branch; owned PR lookup excludes forks | +| No-op | Zero closes or no tracker diff creates no remote branch or PR; caller-only drift unpublished; stale owned PR closed | +| CI | Caller workflow dispatch after PR create/update; parked approval-required runs reported; no merge | + +`ci-workflows` defaults to `ci.yml`; use whitespace-separated filenames for every +required caller CI workflow. Each must support `workflow_dispatch` and produce +its required checks on that event. Missing files are skipped with a notice, +matching sync; empty disables dispatch. CI dispatch starts checks; it does not +certify that they passed. Review and required-check enforcement remain with the +caller's protected branch. A repository concurrency group serializes closeout +runs, and the explicit lease rejects competing writers. + +The caller grants only `contents: write`, `pull-requests: write`, `checks: read` +and `actions: write`. Closeout uses `checks: read` instead of sync's `issues: read` +to retrieve head-check evidence ([GitHub Checks API permissions](https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference)). +The reusable workflow cannot elevate the caller's token +permissions ([GitHub reusable workflow permissions](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)). +Repository Actions settings must allow Actions to create PRs. Read denial stops +closeout before push with a diagnostic, including unavailable head-check evidence +reported by the command as a skip when it blocks a local, existing, non-terminal, +non-excluded tracker item. Unlinked PRs and foreign/terminal/excluded links do +not fail the run for unavailable evidence. Incomplete merge facts use a separate +`incomplete_merge` refusal before any head lookup. The pinned extension must +emit plan decision codes; repin to a release containing this contract. A push/PR denial fails publication and +CI dispatch denial fails the job explicitly. A PR creation denial may leave the +gated bot branch pushed, and a dispatch denial leaves the review PR open; neither +case changes the default branch or merges anything. Explicit dispatch is used +for reliable caller CI invocation with `GITHUB_TOKEN` +([GitHub token event behavior](https://docs.github.com/en/actions/concepts/security/github_token)). + ## Automated sync workflow The reusable workflow's first step resolves the caller's default branch using the repository API with the job token, falling back to `github.event.repository.default_branch` only when present. Scheduled payloads need no `repository` object. If neither source provides a valid branch, the job fails closed with a clear error before checkout. It requires `github.ref` to equal `refs/heads/`. A mismatch fails before checkout or any sync operation with a one-line error naming both refs. Scheduled calls and manual `workflow_dispatch` calls on the default branch are accepted; manual dispatches selecting another branch are refused. Checkout, sync-branch preparation, PR lookup/base and tracker links all use that resolved branch, including defaults such as `trunk` or `release/stable`. Callers need no guard of their own. Repin the reusable workflow to a release commit containing this guard to receive it. diff --git a/closeout.ts b/closeout.ts index 41744323..af5b3ff6 100644 --- a/closeout.ts +++ b/closeout.ts @@ -14,11 +14,18 @@ export interface CloseoutRow { pr: number; action: "close" | "skip"; reason: string; + code: CloseoutCode; } +/** Stable decisions used by automation and compact skip counts. */ +export type CloseoutCode = "already_terminal" | "other_repository" | "duplicate_selection" | "explicit_skip" + | "unknown_item" | "epic" | "active_children" | "not_delivery" | "close_failed" + | "head_checks_passed" | "head_checks_failed" | "head_evidence_unavailable" | "incomplete_merge"; + /** Which head-check policy was evaluated for one selected pull request. */ interface CheckGate { pr: number; + code: CloseoutCode; rule: "required" | "fallback" | "unavailable"; reason: string; passed: string[]; @@ -179,7 +186,7 @@ async function requirements(api: CloseoutApi, repo: string, branch: string): Pro /** Verify required checks or the explicitly reported conservative fallback at the PR head. */ export async function checkCloseoutHead(api: CloseoutApi, repo: string, pr: PullRequest): Promise { - const result: CheckGate = { pr: pr.number, rule: "unavailable", reason: "Head check evidence unavailable or incomplete.", passed: [], ok: false }; + const result: CheckGate = { pr: pr.number, code: "head_evidence_unavailable", rule: "unavailable", reason: "Head check evidence unavailable or incomplete.", passed: [], ok: false }; try { const policy = await requirements(api, repo, pr.base.ref); result.rule = policy.fallback ? "fallback" : "required"; @@ -196,6 +203,7 @@ export async function checkCloseoutHead(api: CloseoutApi, repo: string, pr: Pull const context = text(status.context); if (!statuses.has(context)) statuses.set(context, { context, state: text(status.state) }); } + result.code = "head_checks_failed"; for (const check of policy.checks) { const matching = runs.filter(run => run.name === check.name && (check.app === null || run.app.id === check.app)); const legacy = statuses.get(check.name); @@ -218,8 +226,10 @@ export async function checkCloseoutHead(api: CloseoutApi, repo: string, pr: Pull } result.passed = [...new Set([...runs.filter(run => run.status === "completed" && run.conclusion === "success").map(run => run.name), ...[...statuses.values()].filter(status => status.state === "success").map(status => status.context)])].sort(); + result.code = "head_checks_passed"; result.ok = true; } catch { + result.code = "head_evidence_unavailable"; // Never echo an API body, exception payload, PR body or comment into the report. } return result; @@ -365,26 +375,28 @@ export async function runCloseout(pmRoot: string, repo: string | undefined, opti const closures: { row: CloseoutRow; reason: string; at: string; resolution: string; expectedResult: string; actualResult: string }[] = []; for (const number of [...new Set(numbers)].sort((a, b) => a - b)) { const pr = pullRequest(await payload(api, `/repos/${repo}/pulls/${number}`), repo, number); - let gate: CheckGate = { pr: number, rule: "unavailable", ok: false, passed: [], reason: "Pull request is not merged or merge evidence is incomplete." }; + let gate: CheckGate = { pr: number, code: "incomplete_merge", rule: "unavailable", ok: false, passed: [], reason: "Pull request is not merged or merge evidence is incomplete." }; if (mergeEvidence(pr)) gate = await checkCloseoutHead(api, repo, pr); checks.push(gate); for (const ref of extractCloseoutItems(pr.body ?? "")) { let reason: string; - if (ref.repo.toLowerCase() !== repo.toLowerCase()) reason = "Link points to another repository."; - else if (!items.has(ref.item)) reason = "Item does not exist in the tracker."; - else if (skip.has(ref.item)) reason = "Explicitly excluded by --skip."; - else if (TERMINAL_STATUSES.includes(items.get(ref.item)!.status)) reason = "Item is already closed or canceled."; - else if (!gate.ok) reason = gate.reason; - else if (items.get(ref.item)!.type === "Epic") reason = "Epics close when their children are done, not by a pull request."; - else if (activeChildren.has(ref.item)) reason = `Item has ${activeChildren.get(ref.item)} non-terminal child item(s).`; + let code: CloseoutCode; + if (ref.repo.toLowerCase() !== repo.toLowerCase()) { code = "other_repository"; reason = "Link points to another repository."; } + else if (!items.has(ref.item)) { code = "unknown_item"; reason = "Item does not exist in the tracker."; } + else if (skip.has(ref.item)) { code = "explicit_skip"; reason = "Explicitly excluded by --skip."; } + else if (TERMINAL_STATUSES.includes(items.get(ref.item)!.status)) { code = "already_terminal"; reason = "Item is already closed or canceled."; } + else if (!gate.ok) { code = gate.code; reason = gate.reason; } + else if (items.get(ref.item)!.type === "Epic") { code = "epic"; reason = "Epics close when their children are done, not by a pull request."; } + else if (activeChildren.has(ref.item)) { code = "active_children"; reason = `Item has ${activeChildren.get(ref.item)} non-terminal child item(s).`; } else if (items.get(ref.item)!.status !== "in_progress" && !ref.declared) { + code = "not_delivery"; reason = `Item is ${items.get(ref.item)!.status}, not in progress; a mention is not delivery. Prefix its link with Closes, Fixes, Resolves or Delivers to close it.`; } - else if (selected.has(ref.item)) reason = "Item already selected by another pull request."; + else if (selected.has(ref.item)) { code = "duplicate_selection"; reason = "Item already selected by another pull request."; } else { reason = gate.reason; selected.add(ref.item); - const row: CloseoutRow = { item: ref.item, pr: number, action: "close", reason }; + const row: CloseoutRow = { item: ref.item, pr: number, action: "close", code: gate.code, reason }; const criteria = items.get(ref.item)!.acceptance_criteria; closures.push({ row, reason: closeReason(pr, gate.passed), at: pr.merged_at!, resolution: `Delivered by ${pr.html_url} (merge ${pr.merge_commit_sha!.slice(0, 12)}, head ${pr.head.sha.slice(0, 12)})`, @@ -395,7 +407,7 @@ export async function runCloseout(pmRoot: string, repo: string | undefined, opti plan.push(row); continue; } - plan.push({ item: ref.item, pr: number, action: "skip", reason }); + plan.push({ item: ref.item, pr: number, action: "skip", code, reason }); } } let closed = 0; @@ -410,9 +422,24 @@ export async function runCloseout(pmRoot: string, repo: string | undefined, opti } catch (error: unknown) { failed++; entry.row.action = "skip"; + entry.row.code = "close_failed"; entry.row.reason = closeoutFailureReason(error); } } } return { command: "github closeout", repo, dry_run: !apply, plan, checks, closed, failed }; } + +/** Keep actionable decisions and their checks while counting routine skips by stable code. */ +export function compactCloseoutReport(report: Awaited>) { + const skipped: Partial> = {}; + const plan = report.plan.flatMap(row => { + if (["already_terminal", "other_repository", "duplicate_selection", "explicit_skip"].includes(row.code)) { + skipped[row.code] = (skipped[row.code] ?? 0) + 1; + return []; + } + return [{ ...row, action: row.action === "close" ? "close" : "refuse" }]; + }); + const prs = new Set(plan.map(row => row.pr)); + return { ...report, plan, skipped, checks: report.checks.filter(gate => prs.has(gate.pr)) }; +} diff --git a/docs/closeout-workflow-caller.yml b/docs/closeout-workflow-caller.yml new file mode 100644 index 00000000..5d749fa7 --- /dev/null +++ b/docs/closeout-workflow-caller.yml @@ -0,0 +1,24 @@ +# Pin both placeholders to a reviewed release containing github closeout and this workflow. +# Callers need no guard of their own: API default-branch resolution supports schedule +# payloads without repository metadata; manual dispatch from another branch fails closed. +# Configure required CI workflows to support workflow_dispatch and emit their usual checks. +# Missing CI filenames are skipped with a notice, matching the reusable sync workflow. +# Settings -> Actions -> General must allow Actions to create pull requests. +name: Close pm items delivered by merged pull requests +on: + schedule: + - cron: "43 3 * * *" + workflow_dispatch: +permissions: + contents: write + pull-requests: write + checks: read + actions: write +jobs: + gated-closeout: + uses: unbraind/pm-github/.github/workflows/pm-github-closeout.yml@RELEASE_COMMIT_SHA + with: + pm-github-version: "RELEASE_VERSION" + npm-project-dir: "." # Use tools/oracle or another contained npm project when needed. + post-closeout-script: "changelog:full" # Missing script skips; "" disables it. + ci-workflows: "ci.yml" # Whitespace-separated workflow filenames; "" disables dispatch. diff --git a/index.ts b/index.ts index 61328028..21396bfd 100644 --- a/index.ts +++ b/index.ts @@ -23,7 +23,7 @@ import type { PreflightOverrideContext, SearchProviderQueryContext, } from "@unbrained/pm-cli/sdk/authoring"; -import { CloseoutUsageError, runCloseout } from "./closeout.ts"; +import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.ts"; import http from "node:http"; import https from "node:https"; import crypto from "node:crypto"; @@ -6691,6 +6691,7 @@ export default defineExtension({ { long: "--skip", value_name: "id", repeatable: true, description: "Never close this item (repeatable)" }, { long: "--apply", description: "Close eligible items (default: dry-run)" }, { long: "--dry-run", description: "Preview only; overrides --apply" }, + { long: "--full", description: "Include every plan row and PR check (also available with --json)" }, ], async run(ctx: CommandHandlerContext) { try { @@ -6699,7 +6700,7 @@ export default defineExtension({ base: githubApiBase(), get: url => fetchJSON(url, token), next: parseNextLink, }); if (report.failed > 0) throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE); - return report; + return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report); } catch (error) { // A plain Error would make the host re-run the handler (repeating GitHub reads and remaining closes). throw new CommandError((error as Error).message, error instanceof CloseoutUsageError ? EXIT_CODE.USAGE : EXIT_CODE.GENERIC_FAILURE); diff --git a/test/closeout-workflow.test.ts b/test/closeout-workflow.test.ts new file mode 100644 index 00000000..986bf75d --- /dev/null +++ b/test/closeout-workflow.test.ts @@ -0,0 +1,393 @@ +/** Execute the reusable workflow's real bash against SDK trackers and isolated Git remotes. */ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { create, get, update } from "@unbrained/pm-cli/sdk"; +import { jsonResponse, startMockGithub } from "./helpers/mock-github-server.ts"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const workflow = readFileSync(join(repoRoot, ".github/workflows/pm-github-closeout.yml"), "utf8"); +const sync = readFileSync(join(repoRoot, ".github/workflows/pm-github-sync.yml"), "utf8"); +const cli = join(repoRoot, "node_modules/.bin/pm"); +const branch = "pm-github/closeout"; +const title = "chore(pm): close items delivered by merged pull requests"; +const version = (JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { version: string }).version; +const packedRoot = mkdtempSync(join(tmpdir(), "closeout-package-")); +let archive: string; + +/** Extract executable bash from a named real workflow step. */ +function shell(name: string, source = workflow): string { + const step = source.split(" - name: ").find(part => part.startsWith(name + "\n")); + assert.ok(step, name); + const block = / run: \|\n([\s\S]*)/.exec(step); + if (block) return block[1]!.split("\n").filter(line => line.startsWith(" ")).map(line => line.slice(10)).join("\n"); + const single = / run: (.+)/.exec(step); + assert.ok(single, name); + return single[1]!; +} + +/** Await a real shell while the HTTP evidence fixture continues serving requests. */ +function bash(script: string, cwd: string, env: NodeJS.ProcessEnv): Promise<{ code: number | null; out: string; stdout: string }> { + return new Promise((resolveResult, reject) => { + const child = spawn("bash", ["-c", "set -euo pipefail\n" + script], { cwd, env }); + let out = ""; + let stdout = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { out += chunk; stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { out += chunk; }); + child.on("error", reject); + child.on("close", code => resolveResult({ code, out, stdout })); + }); +} + +/** Check a synchronous setup command without replacing its behavior. */ +function command(cwd: string, executable: string, args: string[], env = process.env): string { + const result = spawnSync(executable, args, { cwd, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr + result.stdout); + return result.stdout.trim(); +} + +test.before(async () => { + const result = await bash('npm pack --ignore-scripts --foreground-scripts=false --json --pack-destination "$PACK_DEST"', repoRoot, + { ...process.env, PACK_DEST: packedRoot, PM_PATH: join(repoRoot, ".agents/pm") }); + assert.equal(result.code, 0, result.out); + // First-use npm notices can follow JSON on stderr; parse only the stdout receipt. + const receipt = result.stdout; + archive = join(packedRoot, (JSON.parse(receipt) as [{ filename: string }])[0].filename); +}); +test.after(() => rmSync(packedRoot, { recursive: true, force: true })); + +/** A real caller with one eligible item, nested npm dependencies, and a local publication boundary. */ +async function fixture(fn: (f: { root: string; base: string; pmRoot: string; id: string; env: NodeJS.ProcessEnv; + run: (names: string[], source?: string) => ReturnType; git: (...args: string[]) => string }) => Promise) { + const base = mkdtempSync(join(tmpdir(), "closeout-caller-")); + const root = join(base, "caller"); + const remote = join(base, "remote.git"); + mkdirSync(root); + const env: NodeJS.ProcessEnv = { ...process.env, PM_AUTHOR: "fixture", PM_GLOBAL_PATH: join(base, "global"), + GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@users.noreply.github.com", + GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@users.noreply.github.com" }; + const git = (...args: string[]) => command(root, "git", args, env); + const pmRoot = join(root, ".agents/pm"); + command(root, "git", ["init", "--bare", remote], env); + git("init", "-b", "trunk"); + command(root, cli, ["--pm-path", pmRoot, "init", "fixture"], env); + const item = await create({ title: "Verified delivery", type: "Task", author: "fixture", acceptanceCriteria: "Delivery verified" }, { pmRoot, noExtensions: true }); + const id = item.item.id; + await update(id, { status: "in_progress" }, { pmRoot, noExtensions: true }); + const project = join(root, "tools/oracle"); + mkdirSync(join(project, "node_modules/@unbrained"), { recursive: true }); + mkdirSync(join(project, "node_modules/.bin"), { recursive: true }); + symlinkSync(join(repoRoot, "node_modules/@unbrained/pm-cli"), join(project, "node_modules/@unbrained/pm-cli")); + symlinkSync(cli, join(project, "node_modules/.bin/pm")); + const generator = join(repoRoot, "node_modules/pm-changelog/dist/cli.js"); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "fixture", version: "2026.10.15", scripts: { + "changelog:full": `node ${JSON.stringify(generator)} --pm-root ../../.agents/pm --mode replace --output ../../CHANGELOG.md --status closed --date-from-version --release-version-from-package`, + } })); + appendFileSync(join(root, ".gitignore"), "node_modules/\n.agents/pm/extensions/\n.agents/pm/locks/\n.agents/pm/checkpoints/\n"); + writeFileSync(join(root, "CHANGELOG.md"), "Caller changelog before closeout\n"); + mkdirSync(join(root, ".github/workflows"), { recursive: true }); + writeFileSync(join(root, ".github/workflows/ci.yml"), "on: workflow_dispatch\n"); + command(root, cli, ["--pm-path", pmRoot, "merge", "install"], env); + git("add", "--", ".gitattributes", ".agents/pm", ".gitignore", "CHANGELOG.md", "tools/oracle/package.json", ".github/workflows/ci.yml"); + git("commit", "-qm", "Initialize caller"); + git("remote", "add", "origin", remote); + git("push", "-u", "origin", "trunk"); + const tools = join(base, "commands"); + mkdirSync(tools); + writeFileSync(join(tools, "npm"), `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == ci ]]; then [[ "$2" == --ignore-scripts ]] || exit 94; exit 0; fi +exec "$REAL_NPM" "$@" +`, { mode: 0o755 }); + writeFileSync(join(tools, "pm"), `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == 'package install' ]]; then + [[ "$3" == "npm:pm-github@$PM_GITHUB_VERSION" && "$4" == --project ]] || exit 95 + exec "$REAL_PM" package install "$ARCHIVE" --project +fi +exec "$REAL_PM" "$@" +`, { mode: 0o755 }); + writeFileSync(join(tools, "gh"), `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$RECEIPTS" +case "$1 $2" in + "api repos/acme/widgets") [[ "\${API_DENIED:-false}" != true ]] || exit 1; printf '%s' "\${API_BRANCH:-trunk}" ;; + "auth setup-git") ;; + "pr list") + if [[ -f "$PR_STATE" ]]; then + printf '[{"number":7,"headRepository":{"name":"widgets"},"headRepositoryOwner":{"login":"acme"},"isCrossRepository":false}]' + else printf '[{"number":99,"isCrossRepository":true}]'; fi ;; + "pr create"|"pr edit") + [[ "\${PR_DENIED:-false}" != true ]] || exit 1 + touch "$PR_STATE" + while [[ $# -gt 0 ]]; do + if [[ "$1" == --body-file ]]; then cp "$2" "$PR_BODY"; break; fi + shift + done ;; + "pr close") rm -f "$PR_STATE" ;; + "workflow run") [[ "\${DISPATCH_DENIED:-false}" != true ]] || exit 1 ;; + "run list") ;; + *) exit 96 ;; +esac +`, { mode: 0o755 }); + Object.assign(env, { PATH: tools + ":" + project + "/node_modules/.bin:" + process.env.PATH, + REAL_PM: cli, REAL_NPM: command(root, "bash", ["-c", "command -v npm"]), ARCHIVE: archive, + PM_PATH: pmRoot, GH_TOKEN: "synthetic-token", GITHUB_TOKEN: "synthetic-token", REPOSITORY: "acme/widgets", + CALLING_REPOSITORY: "acme/widgets", GITHUB_REPOSITORY: "acme/widgets", RUN_REF: "refs/heads/trunk", + EVENT_DEFAULT_BRANCH: "", GITHUB_WORKSPACE: root, NPM_PROJECT_DIR: "tools/oracle", PM_GITHUB_VERSION: version, + SYNC_BRANCH: branch, POST_CLOSEOUT_SCRIPT: "changelog:full", CI_WORKFLOWS: "ci.yml", RUNNER_TEMP: base, + GITHUB_ENV: join(base, "job-env"), GITHUB_OUTPUT: join(base, "job-output"), GITHUB_PATH: join(base, "job-path"), + PR_STATE: join(base, "pr-state"), PR_BODY: join(base, "pr-body"), RECEIPTS: join(base, "receipts") }); + const server = await startMockGithub((req, res) => { + const url = req.url!; + if (url.includes("/check-runs")) env.HEAD_LOOKUPS = String(Number(env.HEAD_LOOKUPS ?? 0) + 1); + if (env.READ_DENIED === "true" || (env.CHECKS_DENIED === "true" && url.includes("/check-runs"))) { jsonResponse(res, 403, { message: "Resource not accessible by integration" }); return; } + const sha = "a".repeat(40); + if (url.includes("/pulls?")) jsonResponse(res, 200, env.NO_DELIVERY === "true" ? [] : [{ number: 42, merged_at: "2026-10-15T00:00:00Z", updated_at: "2026-10-15T00:00:00Z" }]); + else if (url.endsWith("/pulls/42")) jsonResponse(res, 200, { number: 42, body: env.NO_LINKS === "true" ? "Routine maintenance" : `https://github.com/${env.FOREIGN_LINK === "true" ? "other/project" : "acme/widgets"}/blob/main/.agents/pm/tasks/${id}.toon${env.SUMMARY_ROWS === "true" ? `\nhttps://github.com/other/project/blob/main/.agents/pm/tasks/${id}.toon` : ""}${env.SUMMARY_ITEM ? `\nhttps://github.com/acme/widgets/blob/main/.agents/pm/tasks/${env.SUMMARY_ITEM}.toon` : ""}`, + merged: true, merged_at: "2026-10-15T00:00:00Z", merge_commit_sha: env.INCOMPLETE_MERGE === "true" ? null : "b".repeat(40), html_url: "https://github.com/acme/widgets/pull/42", + head: { sha }, base: { ref: "trunk", repo: { full_name: "acme/widgets" } } }); + else if (url.includes("/protection/")) jsonResponse(res, 200, { contexts: ["CI"], checks: [] }); + else if (url.includes("/rules/branches/")) jsonResponse(res, 200, []); + else if (url.includes("/check-runs")) jsonResponse(res, 200, { total_count: 1, check_runs: [{ name: "CI", head_sha: sha, status: "completed", conclusion: "success", app: { id: 7 } }] }); + else if (url.includes("/statuses")) jsonResponse(res, 200, []); + else jsonResponse(res, 404, {}); + }); + Object.assign(env, { PM_GITHUB_API_BASE: server.baseUrl }); + const run = (names: string[], source = workflow) => bash(names.map(name => `( +set -a +if [[ -f "$GITHUB_ENV" ]]; then source "$GITHUB_ENV"; fi +set +a +${shell(name, source)} +)`).join("\n"), root, env); + try { await fn({ root, base, pmRoot, id, env, run, git }); } + finally { await server.close(); rmSync(base, { recursive: true, force: true }); } +} + +const setup = ["Require caller default branch", "Validate closeout inputs", "Validate npm project directory", "Prepare sync branch from default branch", + "Install dependencies", "Resolve sync commit scope", "Install pinned pm-github extension", "Configure PM merge drivers", "Verify gate is available"]; +const finish = ["Close delivered tracker items", "Run caller post-closeout script", "Gate tracker and regenerated changelog", "Verify strict pm health", + "Commit gated closeout changes and open review PR", "Dispatch caller CI and report parked runs"]; + +/** No remote bot ref is the publication invariant for every refused or no-op run. */ +function unpublished(git: (...args: string[]) => string): void { + assert.equal(git("ls-remote", "origin", `refs/heads/${branch}`), ""); +} + +test("closeout contract keeps sync safety helpers, exact pins, env bindings and least privilege", () => { + assert.match(workflow, /^on:\n workflow_call:/m); + assert.match(workflow, /group: pm-github-closeout-\$\{\{ github.repository \}\}/); + assert.match(workflow, /cancel-in-progress: false/); + assert.match(workflow, /contents: write\n pull-requests: write\n checks: read\n actions: write/); + assert.match(workflow, /SYNC_BRANCH: pm-github\/closeout/); + assert.doesNotMatch(workflow, /inputs.sync-branch|persist-credentials: true/); + for (const step of workflow.split(" - name: ").slice(1)) { + const name = step.split("\n")[0]!; + if (!step.includes("run:")) continue; + assert.doesNotMatch(shell(name), /\$\{\{/); + } + for (const name of ["Validate npm project directory", "Resolve sync commit scope", "Install dependencies", "Install pinned pm-github extension", "Gate tracker and regenerated changelog", "Verify strict pm health"]) { + assert.equal(shell(name), shell(name, sync), name); + } + assert.match(workflow, /--force-with-lease="refs\/heads\/\$\{SYNC_BRANCH\}:\$\{SYNC_LEASE\}"/); + assert.match(workflow, /--since 1970-01-01 --apply --json/); + for (const action of workflow.matchAll(/uses: (actions\/[^@]+)@([^\s]+)/g)) assert.match(action[2]!, /^[a-f0-9]{40}$/); + const caller = readFileSync(join(repoRoot, "docs/closeout-workflow-caller.yml"), "utf8"); + assert.match(caller, /schedule:/); + assert.match(caller, /workflow_dispatch:/); + assert.match(caller, /npm-project-dir: "\."/); + assert.match(caller, /@RELEASE_COMMIT_SHA/); + assert.match(caller, /checks: read/); + assert.doesNotMatch(caller, /issues: read/); +}); + +test("schedule without repository payload resolves trunk; clean closeout opens one PR and repeat edits it", async () => fixture(async f => { + f.env.SUMMARY_ROWS = "true"; + const baseHead = f.git("rev-parse", "HEAD"); + const first = await f.run([...setup, ...finish]); + assert.equal(first.code, 0, first.out); + assert.equal((await get(f.id, {}, { pmRoot: f.pmRoot, noExtensions: true })).item.status, "closed"); + const published = f.git("ls-remote", "origin", `refs/heads/${branch}`).split(/\s/)[0]; + assert.ok(published); + assert.equal(f.git("ls-remote", "origin", "refs/heads/trunk").split(/\s/)[0], baseHead); + const body = readFileSync(f.env.PR_BODY!, "utf8"); + assert.match(body, /blob\/trunk\/\.agents\/pm/); + assert.match(body, /"action": "close"/); + assert.match(body, /"other_repository": 1/); + assert.doesNotMatch(body, /"code": "other_repository"/); + assert.doesNotMatch(body, /"checks"|"reason"/); + assert.match(f.git("show", `${published}:CHANGELOG.md`), /Verified delivery/); + const second = await f.run([...setup, ...finish]); + assert.equal(second.code, 0, second.out); + const receipts = readFileSync(f.env.RECEIPTS!, "utf8"); + assert.equal(receipts.split("\n").filter(line => line.startsWith("pr create ")).length, 1); + assert.equal(receipts.split("\n").filter(line => line.startsWith("pr edit 7 ")).length, 1); + assert.equal(receipts.split("\n").filter(line => line.startsWith("workflow run ci.yml ")).length, 2); + assert.ok(receipts.includes(`--title ${title}`)); +})); + +test("malicious tracker acceptance criteria fails the final privacy gate before any remote write", async () => fixture(async f => { + const prepared = await f.run(setup); + assert.equal(prepared.code, 0, prepared.out); + await update(f.id, { acceptanceCriteria: "ghp_" + "A".repeat(36) }, { pmRoot: f.pmRoot, noExtensions: true }); + const result = await f.run(finish); + assert.notEqual(result.code, 0, result.out); + assert.match(result.out, /github-token-classic|fail/); + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); +})); + +test("zero closes discards caller drift and creates no remote branch or PR", async () => fixture(async f => { + f.env.NO_DELIVERY = "true"; + const result = await f.run([...setup, ...finish]); + assert.equal(result.code, 0, result.out); + assert.match(result.out, /no branch or PR/); + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); + assert.equal(readFileSync(join(f.root, "CHANGELOG.md"), "utf8"), "Caller changelog before closeout\n"); +})); + +test("permission-limited read token fails closed with a clear diagnostic", async () => fixture(async f => { + f.env.READ_DENIED = "true"; + const result = await f.run([...setup, ...finish]); + assert.notEqual(result.code, 0); + assert.match(result.out, /Closeout refused; check token read access/); + unpublished(f.git); + assert.equal((await get(f.id, {}, { pmRoot: f.pmRoot, noExtensions: true })).item.status, "in_progress"); + Object.assign(f.env, { READ_DENIED: "false", CHECKS_DENIED: "true" }); + const checks = await f.run(finish); + assert.notEqual(checks.code, 0); + assert.match(checks.out, /Closeout refused; check token read access/); + unpublished(f.git); + assert.equal((await get(f.id, {}, { pmRoot: f.pmRoot, noExtensions: true })).item.status, "in_progress"); +})); + +test("PR-write and dispatch restrictions fail clearly and never alter the default branch", async () => { + for (const denied of ["PR_DENIED", "DISPATCH_DENIED"]) await fixture(async f => { + const head = f.git("rev-parse", "HEAD"); + f.env[denied] = "true"; + const result = await f.run([...setup, ...finish]); + assert.notEqual(result.code, 0); + assert.match(result.out, denied === "PR_DENIED" ? /token requires contents:write and pull-requests:write/ : /token requires actions:write/); + assert.equal(f.git("ls-remote", "origin", "refs/heads/trunk").split(/\s/)[0], head); + }); +}); + +test("default-branch guard refuses non-default refs and unresolved metadata before tracker edits", async () => { + for (const mode of ["wrong-ref", "unresolved", "payload-fallback"]) await fixture(async f => { + if (mode === "wrong-ref") f.env.RUN_REF = "refs/heads/feature"; + else f.env.API_DENIED = "true"; + if (mode === "payload-fallback") f.env.EVENT_DEFAULT_BRANCH = "trunk"; + const result = await f.run(["Require caller default branch"]); + assert.equal(result.code, mode === "payload-fallback" ? 0 : 1, result.out); + unpublished(f.git); + }); +}); + +test("contents-write denial rejects the leased push before PR creation", async () => fixture(async f => { + writeFileSync(join(f.base, "remote.git/hooks/pre-receive"), "#!/usr/bin/env bash\nexit 1\n", { mode: 0o755 }); + const result = await f.run([...setup, ...finish]); + assert.notEqual(result.code, 0); + assert.match(result.out, /token requires contents:write/); + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); +})); + +test("zero closes refuses caller-only tracker drift as an empty closeout PR", async () => fixture(async f => { + f.env.NO_DELIVERY = "true"; + const prepared = await f.run(setup); + assert.equal(prepared.code, 0, prepared.out); + await update(f.id, { title: "Caller observation without delivery" }, { pmRoot: f.pmRoot, noExtensions: true }); + const result = await f.run(finish); + assert.equal(result.code, 0, result.out); + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); +})); + +test("behavioural revert: removing only the staged privacy scan publishes malicious tracker content", async () => fixture(async f => { + const mutant = workflow.replace('pm github gate --diff "${RUNNER_TEMP}/pm-github-sync.patch" --json', ": # privacy scan removed"); + assert.notEqual(mutant, workflow); + const prepared = await f.run(setup, mutant); + assert.equal(prepared.code, 0, prepared.out); + const secret = "ghp_" + "A".repeat(36); + await update(f.id, { acceptanceCriteria: secret }, { pmRoot: f.pmRoot, noExtensions: true }); + const result = await f.run(finish, mutant); + assert.equal(result.code, 0, result.out); + const remoteItem = f.git("show", `refs/remotes/origin/${branch}:.agents/pm/tasks/${f.id}.toon`); + assert.ok(remoteItem.includes(secret)); + assert.equal(existsSync(f.env.PR_STATE!), true); +})); + +test("behavioural revert: removing only the no-op guard opens a PR with zero closes", async () => fixture(async f => { + f.env.NO_DELIVERY = "true"; + const prepared = await f.run(setup); + assert.equal(prepared.code, 0, prepared.out); + await update(f.id, { title: "Caller observation without delivery" }, { pmRoot: f.pmRoot, noExtensions: true }); + const mutant = workflow.replace('if [[ "${CLOSEOUT_CLOSED}" == 0 ]] || git diff --cached --quiet -- "${sync_scope[0]}" "${sync_scope[@]:2}"; then', "if false; then"); + assert.notEqual(mutant, workflow); + const result = await f.run(finish, mutant); + assert.equal(result.code, 0, result.out); + assert.equal(existsSync(f.env.PR_STATE!), true); + assert.equal((JSON.parse(readFileSync(join(f.base, "pm-github-closeout.json"), "utf8")) as { closed: number }).closed, 0); + assert.equal((await get(f.id, {}, { pmRoot: f.pmRoot, noExtensions: true })).item.status, "in_progress"); +})); + +test("closeout inputs reject cross-repo writes, floating pins, command injection and a default bot branch", async () => fixture(async f => { + for (const [key, value] of [["REPOSITORY", "other/widgets"], ["PM_GITHUB_VERSION", "latest"], + ["POST_CLOSEOUT_SCRIPT", "changelog:full; exit 0"], ["SYNC_BRANCH", "trunk"]]) { + const saved = f.env[key]; + f.env[key] = value; + const result = await f.run(["Require caller default branch", "Validate closeout inputs"]); + assert.equal(result.code, 1, result.out); + Object.assign(f.env, { [key]: saved }); + unpublished(f.git); + } + f.env.API_BRANCH = branch; + f.env.RUN_REF = `refs/heads/${branch}`; + const result = await f.run(["Require caller default branch", "Validate closeout inputs"]); + assert.equal(result.code, 1, result.out); + assert.match(result.out, /bot branch cannot be the caller default branch/); +})); + +test("an explicit lease refuses a competing bot-branch writer", async () => fixture(async f => { + const prepared = await f.run([...setup, ...finish.slice(0, 4)]); + assert.equal(prepared.code, 0, prepared.out); + f.git("push", "origin", `trunk:refs/heads/${branch}`); + const competingHead = f.git("rev-parse", "trunk"); + const result = await f.run(finish.slice(4)); + assert.notEqual(result.code, 0); + assert.match(result.out, /stale info|rejected/); + assert.equal(f.git("ls-remote", "origin", `refs/heads/${branch}`).split(/\s/)[0], competingHead); + assert.equal(existsSync(f.env.PR_STATE!), false); +})); + +for (const mode of ["unlinked", "foreign", "terminal", "local", "incomplete", "unlinked-incomplete"]) { + test(`unavailable evidence is scoped to local tracker refusals: ${mode}`, async () => fixture(async f => { + f.env.CHECKS_DENIED = "true"; + if (mode.startsWith("unlinked")) f.env.NO_LINKS = "true"; + if (mode === "foreign") f.env.FOREIGN_LINK = "true"; + if (mode.includes("incomplete")) f.env.INCOMPLETE_MERGE = "true"; + if (mode === "terminal") await update(f.id, { status: "canceled" }, { pmRoot: f.pmRoot, noExtensions: true }); + const result = await f.run([...setup, ...finish]); + assert.equal(result.code, mode === "local" || mode === "incomplete" ? 1 : 0, result.out); + if (mode === "local") assert.match(result.out, /token read access to head checks for linked local tracker items/); + if (mode === "incomplete") { + assert.match(result.out, /incomplete merged-PR metadata blocks a linked local tracker item/); + assert.equal(f.env.HEAD_LOOKUPS, undefined); + } + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); + })); +} + +test("compact PR summary is privacy gated before the bot branch is published", async () => fixture(async f => { + f.env.SUMMARY_ITEM = "ghp_" + "A".repeat(36); + const result = await f.run([...setup, ...finish]); + assert.notEqual(result.code, 0, result.out); + assert.match(result.out, /github-token-classic/); + unpublished(f.git); + assert.equal(existsSync(f.env.PR_STATE!), false); +})); diff --git a/test/closeout.test.ts b/test/closeout.test.ts index 9bc1e52b..07fe32be 100644 --- a/test/closeout.test.ts +++ b/test/closeout.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import test from "node:test"; import { create, close, update, get, config, acquireLock, PmCliError } from "@unbrained/pm-cli/sdk"; import extension, { fetchJSON, githubApiBase, parseNextLink } from "../index.ts"; -import { CloseoutUsageError, closeoutFailureReason, declaresDelivery, extractCloseoutItems, runCloseout, type CloseoutApi } from "../closeout.ts"; +import { CloseoutUsageError, closeoutFailureReason, compactCloseoutReport, declaresDelivery, extractCloseoutItems, runCloseout, type CloseoutApi } from "../closeout.ts"; import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; import { decode } from "@toon-format/toon"; import { boundedCli } from "./helpers/bounded-cli.ts"; @@ -119,7 +119,7 @@ test("dry-run plans same-repo, foreign, unknown, terminal and excluded items wit assert.match(report.plan[3]!.reason, /already closed or canceled/); assert.match(report.plan[4]!.reason, /already closed or canceled/); assert.match(report.plan[5]!.reason, /--skip/); - assert.deepEqual(Object.keys(report.plan[0]!), ["item", "pr", "action", "reason"]); + assert.deepEqual(Object.keys(report.plan[0]!), ["item", "pr", "action", "code", "reason"]); assert.equal(snapshot(root), before); await closeout(root, { pr: "42", apply: true, "dry-run": true }); await closeout(root, { pr: "42", apply: true, dryRun: true }); @@ -454,6 +454,14 @@ test("public command registration reuses repo resolution and shared transport", await withEnv({ PM_GITHUB_REPO: repo, GITHUB_TOKEN: "fixture" }, () => withMockGithub(fixture(link(ids[0]!)), async () => { const result = await harness.runCommand({ command: "github closeout", pmRoot: root, options: { pr: ["42"] } }); assert.equal((result.result as { plan: { action: string }[] }).plan[0]!.action, "close"); + const compact = await harness.runCommand({ command: "github closeout", pmRoot: root, + global: { json: false }, options: { pr: ["42", "43", "44"] } }); + assert.equal((compact.result as { plan: object[] }).plan.length, 1); + assert.deepEqual((compact.result as { skipped: object }).skipped, { duplicate_selection: 2 }); + const full = await harness.runCommand({ command: "github closeout", pmRoot: root, + global: { json: false }, options: { pr: ["42", "43", "44"], full: true } }); + assert.equal((full.result as { plan: object[] }).plan.length, 3); + assert.equal("skipped" in (full.result as object), false); })); // Failures surface as handled command errors (usage 2, evidence 1), so the host never re-runs the handler. await withEnv({ PM_GITHUB_REPO: repo, GITHUB_TOKEN: "fixture" }, () => withMockGithub(fixture(link(ids[0]!), (request, response) => { @@ -502,7 +510,14 @@ test("packed CLI preserves repeatable selectors, TOON/JSON shape and no-op apply assert.equal(preview.code, 0, preview.stderr); const report = decode(preview.stdout) as { dry_run: boolean; plan: { action: string }[] }; assert.equal(report.dry_run, true); - assert.equal(report.plan.length, 8); + assert.equal(report.plan.length, 2); + assert.deepEqual((report as unknown as { skipped: object }).skipped, { explicit_skip: 4, duplicate_selection: 2 }); + const full = await call([...flags, "--full"]); + assert.equal(full.code, 0, full.stderr); + assert.equal((decode(full.stdout) as { plan: object[] }).plan.length, 8); + const json = await call([...flags, "--json"]); + assert.equal(json.code, 0, json.stderr); + assert.equal((JSON.parse(json.stdout) as { plan: object[] }).plan.length, 8); assert.equal(report.plan.filter(row => row.action === "close").length, 2); assert.equal(snapshot(root), before); const applied = await call([...flags, "--apply", "--json"]); @@ -698,3 +713,19 @@ test("successful optional checks are never labelled as required", async () => tr assert.doesNotMatch(String(item.actual_result), /required checks passing/); }); })); + +test("compact report counts routine skips and retains individual refusals with their head evidence", async () => tracker(async (root, ids) => { + await update(ids[1]!, { status: "canceled" }, { pmRoot: root, noExtensions: true }); + await withMockGithub(fixture([link(ids[0]!), link(ids[1]!), link(ids[0]!, "other/project")].join("\n"), (request, response) => { + if (request.url!.includes("/check-runs")) jsonResponse(response, 200, { total_count: 1, check_runs: [run({ conclusion: "failure" })] }); + }), async () => { + const full = await closeout(root); + const report = compactCloseoutReport(full); + assert.deepEqual(report.skipped, { already_terminal: 1, other_repository: 1 }); + assert.equal(report.plan.length, 1); + assert.equal(report.plan[0]!.action, "refuse"); + assert.equal(report.plan[0]!.code, "head_checks_failed"); + assert.equal(report.checks.length, 1); + assert.equal(full.plan.length, 3); + }); +}));