@zhuxixi/pi-agent-board 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +41 -3
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +573 -2
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +38 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +40 -2
- package/src/core/host-coordination.mjs +159 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch.mjs +3 -1
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +29 -1
- package/src/runtime/service.mjs +967 -109
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +26 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here, generated from
|
|
4
|
+
conventional commits by `scripts/release_helper.mjs`. Entries are
|
|
5
|
+
forward-only: they begin with the first release after this file landed —
|
|
6
|
+
for earlier history, see the git log and the pull-request list.
|
|
7
|
+
|
|
8
|
+
## [0.6.0] - 2026-09-07
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
- conventional-commit driven CHANGELOG via release_helper.mjs (issue #64) (#83)
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- pty host stacking and control.sock cross-deletion via per-instance ownership protocol (issue #70) (#84)
|
|
17
|
+
- guard mention fallback against placeholders, code spans, and pr-context (issue #61) (#82)
|
|
18
|
+
- truncate never splits surrogate pairs or emits lone surrogates (issue #39) (#81)
|
|
19
|
+
- extract evidence outputPreview from AgentToolResult content (issue #41) (#77)
|
|
20
|
+
- reclaim idle warm PTY hosts via periodic sweep + lifecycle cleanup (issue #75) (#76)
|
|
21
|
+
- narrow code-refs PR back-link extraction by evidence context (issue #65) (#73)
|
|
22
|
+
|
|
23
|
+
### Performance
|
|
24
|
+
|
|
25
|
+
- use cached PTY probe on the reconcile drain path (issue #13) (#79)
|
|
26
|
+
|
|
27
|
+
### Changes
|
|
28
|
+
|
|
29
|
+
- Windows WezTerm IME needs a visible hardware cursor (issue #38) (#80)
|
|
30
|
+
- poll markCompleted to success instead of racing the persist window (issue #63) (#78)
|
|
31
|
+
|
|
32
|
+
[0.6.0]: https://github.com/zhuxixi/pi-agent-board/compare/v0.5.2...v0.6.0
|
package/README.md
CHANGED
|
@@ -236,6 +236,26 @@ steer:awaiting-approval
|
|
|
236
236
|
|
|
237
237
|
Evidence is collected locally from session events. Agent Board can extract issue and pull-request references from that evidence and show badges such as `#40` or `▸#45` on rows; Peek includes the provider, confidence, source, and URL when available. Built-in GitHub/GitLab-style providers are available, and an optional per-store `providers.json` can extend the provider rules. The `AGENT_BOARD_CODE_REFS=off` setting disables extraction.
|
|
238
238
|
|
|
239
|
+
For internal code platforms (non-github/gitlab hosts) or custom CLIs, add a per-store `providers.json` so claim/action rules exist and sessions stop depending on the low-confidence mention fallback. Example — an internal CLI (`acli`) with claim-strength issue rules:
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"providers": [
|
|
244
|
+
{
|
|
245
|
+
"name": "acode",
|
|
246
|
+
"hosts": ["acode.internal.example.com"],
|
|
247
|
+
"rules": [
|
|
248
|
+
{ "pattern": "acli\\s+issue\\s+update\\s+#?(\\d+)(?=[\\s\\S]*--assignee)", "kind": "issue", "strength": "claim" },
|
|
249
|
+
{ "pattern": "acli\\s+issue\\s+(?:note|comment|close)\\s+#?(\\d+)", "kind": "issue", "strength": "action" },
|
|
250
|
+
{ "pattern": "acli\\s+issue\\s+(?:show|view)\\s+#?(\\d+)", "kind": "issue", "strength": "view" }
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`hosts` matches the repo's remote host; rules follow the same `pattern`/`kind`/`strength` shape as the built-in `gh`/`glab` tables (`strength`: `claim` > `action` > `view`), and the `#N` capture group supplies the number. Validation errors from a broken file surface in the diagnostics panel and never break extraction — an invalid file is simply ignored.
|
|
258
|
+
|
|
239
259
|
## Attach and Fallback Behavior
|
|
240
260
|
|
|
241
261
|
When PTY support is healthy, Agent Board uses an interactive PTY host for attach and start-and-attach. If PTY support is unavailable, eligible managed sessions can still run in the background through the JSON runner; start-and-attach falls back to background launch with a warning. Adopted external foreground sessions require PTY to continue safely. Press `!` in the dashboard for diagnosis and repair hints.
|
|
@@ -319,6 +339,20 @@ Press `!` in the dashboard to open the diagnostic panel and follow its repair hi
|
|
|
319
339
|
|
|
320
340
|
A cold PTY host may briefly show a loading or reconnecting surface while it starts. Check the PTY status in the dashboard with `!`; stale hosts are diagnosed separately from active task workers. If the host never becomes healthy, repair `node-pty` or use background mode for eligible managed sessions.
|
|
321
341
|
|
|
342
|
+
### IME candidate window is stuck at the window edge (Windows WezTerm)
|
|
343
|
+
|
|
344
|
+
On Windows WezTerm with a WSL2 backend, the IME candidate window may stay pinned to the right edge instead of following the text cursor in an attached session. Windows WezTerm only tracks the IME candidate position from the visible hardware cursor, and Pi hides the hardware cursor by default — the block cursor you see in the editor is drawn content, not the hardware cursor. Linux terminals are not affected.
|
|
345
|
+
|
|
346
|
+
Make the hardware cursor visible, either way:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
export PI_HARDWARE_CURSOR=1 # machine-local, e.g. ~/.zshrc.local
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
or set `"showHardwareCursor": true` in Pi's `settings.json` (syncs across machines if the config is version-controlled; harmless on Linux).
|
|
353
|
+
|
|
354
|
+
Trade-off: the real terminal cursor becomes visible inside the TUI. This is cosmetic only.
|
|
355
|
+
|
|
322
356
|
### Start & attach falls back to background
|
|
323
357
|
|
|
324
358
|
Start & attach requires PTY support. When PTY is unavailable, the task is still dispatched in the background and the dashboard displays a warning. Repair PTY and retry attach from the normal `/agent-board` command path.
|
|
@@ -346,15 +380,19 @@ npm run verify
|
|
|
346
380
|
|
|
347
381
|
## Publishing
|
|
348
382
|
|
|
349
|
-
Before publishing a release, verify the package,
|
|
383
|
+
Before publishing a release, verify the package, generate the changelog, then bump, and publish — the changelog must be generated **before** `npm version`, because `npm version` commits and tags the bump, which would empty the generation range:
|
|
350
384
|
|
|
351
385
|
```bash
|
|
352
386
|
npm run verify
|
|
353
|
-
npm version patch
|
|
387
|
+
npm run changelog -- --dry-run # preview the next section (version = current + patch)
|
|
388
|
+
npm run changelog -- patch # inserts the section into CHANGELOG.md
|
|
389
|
+
node scripts/release_helper.mjs verify # must exit 0: no functional PR missing from the top section
|
|
390
|
+
git add CHANGELOG.md && git commit -m "docs(changelog): <version>"
|
|
391
|
+
npm version patch # bumps package.json, commits, and tags vX.Y.Z
|
|
354
392
|
npm publish
|
|
355
393
|
```
|
|
356
394
|
|
|
357
|
-
Use `
|
|
395
|
+
Use `minor`/`major` in both the changelog and `npm version` steps when appropriate. The changelog is generated from conventional commits since the last `vX.Y.Z` tag (`scripts/release_helper.mjs`, ported from the jfox release flow); review the preview before committing. The `verify` step guards against PRs merged after the changelog was generated — rerun it after any late merge and, after removing the stale top section, re-run `npm run changelog -- <bump>` if it reports missing PRs (apply refuses when the section already exists — remove the stale section first). Release notes for the GitHub Release are the top CHANGELOG section. After publishing, users install the scoped package with:
|
|
358
396
|
|
|
359
397
|
```bash
|
|
360
398
|
pi install npm:@zhuxixi/pi-agent-board
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
# code-refs PR Back-Link Narrowing Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Stop code-review report text (e.g. "上轮 issue #1") from being mis-extracted as a claim-strength PR back-link, by splitting the back-link grammar by evidence context (PR-create command vs. later assistant text) and removing the unbounded follow-up command scan (issue #65).
|
|
6
|
+
|
|
7
|
+
**Architecture:** All behavior changes live in the pure engine `src/core/code-refs.mjs`. One shared regex (`PR_BACKLINK_RE`) becomes two context-specific matchers: `PR_CREATE_BACKLINK_RE` (explicit `pr create` body, keeps the legacy bare `issue #N` form) and `PR_FOLLOWUP_BACKLINK_RE` (later assistant text, canonical `Closes/Fixes/Resolves #N` only). Rule 4b's resolver becomes assistant-only and runs only when exactly one distinct PR-create command index exists. Store/render layers are untouched.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ≥20 ESM (`node --test`), zero new dependencies.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md` (approved 2026-09-03).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- All edits happen in the worktree `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-65-code-refs-pr-backlink-narrow` (branch `issue-65-code-refs-pr-backlink-narrow`). Never touch the main checkout.
|
|
16
|
+
- Commit messages: English, conventional commits, reference `issue #65`. Stage by exact file path, never `git add -A`.
|
|
17
|
+
- No new exports from `code-refs.mjs` (internal helpers stay module-private; tests go through `extractCodeRefs`).
|
|
18
|
+
- Do not modify `code-refs-store.mjs` runtime logic, `github.json` schema, `mergeWithExisting`, providers.json schema, or the mention fallback (issue #61).
|
|
19
|
+
- No network calls, no I/O in the engine — it stays a pure function module.
|
|
20
|
+
- Coverage gates from `.c8rc.json`: lines ≥85%, functions ≥80%, branches ≥70% (`npm run verify` enforces).
|
|
21
|
+
- Test commands run from the worktree root: `node --test test/code-refs-extract.test.mjs` / `node --test test/code-refs-store.test.mjs`. (NOT `npm test -- <file>` — that script keeps its glob and runs everything.)
|
|
22
|
+
|
|
23
|
+
**Acceptance mapping:** Task 1 → A2; Task 2 → A1, A2, A3; Task 3 → A4; Task 4 → A5. Spec IDs are quoted in each task.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### Task 1: Split the back-link regex into create/followup matchers
|
|
28
|
+
|
|
29
|
+
**Files:**
|
|
30
|
+
- Modify: `src/core/code-refs.mjs:509-510` (constant), `src/core/code-refs.mjs:637-640` (`applyPrBacklink`), `src/core/code-refs.mjs:651-660` (`resolveBacklinkAfter` body — matcher swap only, signature unchanged in this task)
|
|
31
|
+
- Test: `test/code-refs-extract.test.mjs` (append new test)
|
|
32
|
+
|
|
33
|
+
**Interfaces:**
|
|
34
|
+
- Consumes: existing `applyPrBacklink(text, index, candidates)` call site at `src/core/code-refs.mjs:571`; existing `resolveBacklinkAfter(marker, commands, assistantTexts, stopBefore)` (Task 1 only swaps the regex used inside it).
|
|
35
|
+
- Produces (module-private, used by Task 2): `matchPrCreateBacklink(text) → number|null`, `matchPrFollowupBacklink(text) → number|null`, constants `PR_CREATE_BACKLINK_RE`, `PR_FOLLOWUP_BACKLINK_RE`. `PR_BACKLINK_RE` is deleted.
|
|
36
|
+
|
|
37
|
+
Covers spec A2: create body keeps bare `issue #40`; both regexes gain word boundaries and the `(?!\w)` number guard.
|
|
38
|
+
|
|
39
|
+
- [ ] **Step 1: Write the failing test**
|
|
40
|
+
|
|
41
|
+
Append to `test/code-refs-extract.test.mjs` (after the "pr-body back-link is case-insensitive" test):
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
test("pr create body back-link word boundaries and number guard (issue #65)", () => {
|
|
45
|
+
const run = (body) =>
|
|
46
|
+
extractCodeRefs(
|
|
47
|
+
{
|
|
48
|
+
commands: [{ command: `gh pr create --title t --body "${body}"` }],
|
|
49
|
+
assistantTexts: [],
|
|
50
|
+
worktreePath: null,
|
|
51
|
+
branch: null,
|
|
52
|
+
repoUrl: "owner/repo",
|
|
53
|
+
host: "github.com",
|
|
54
|
+
},
|
|
55
|
+
github()
|
|
56
|
+
);
|
|
57
|
+
// Embedded-keyword prose must not match.
|
|
58
|
+
assert.equal(run("prefix #1 disclose #2 unresolved #3").issue, null);
|
|
59
|
+
// 8-digit numbers are rejected wholesale, not truncated to 7 digits.
|
|
60
|
+
assert.equal(run("closes #12345678").issue, null);
|
|
61
|
+
// Canonical and legacy forms keep working.
|
|
62
|
+
assert.equal(run("fixes issue #40").issue?.number, 40);
|
|
63
|
+
assert.equal(run("fixes issue #40").issue?.source, "pr-body");
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
68
|
+
|
|
69
|
+
Run: `node --test test/code-refs-extract.test.mjs`
|
|
70
|
+
Expected: FAIL — the old regex matches `fix #1` inside `"prefix #1 ..."` so `run("prefix #1 ...").issue` is `1`, not `null` (assert diff shows `1 !== null`).
|
|
71
|
+
|
|
72
|
+
- [ ] **Step 3: Implement the split**
|
|
73
|
+
|
|
74
|
+
In `src/core/code-refs.mjs`, replace lines 509-510:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
/** `closes #N` / `fixes #N` / `issue #N` back-link inside a `pr create` body. */
|
|
78
|
+
const PR_BACKLINK_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})/i;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
with:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
/**
|
|
85
|
+
* Back-link inside an explicit `pr create` command body: closing keywords
|
|
86
|
+
* (optionally followed by "issue") or the legacy bare `issue #N` form.
|
|
87
|
+
* Word boundaries keep embedded keywords (prefix/disclose/unresolved) out;
|
|
88
|
+
* `(?!\w)` rejects longer numbers instead of truncating them to 7 digits.
|
|
89
|
+
*/
|
|
90
|
+
const PR_CREATE_BACKLINK_RE =
|
|
91
|
+
/\b(?:(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+(?:issue\s+)?|issue\s+)#(\d{1,7})(?!\w)/i;
|
|
92
|
+
/**
|
|
93
|
+
* Back-link in later assistant evidence: canonical closing-keyword syntax
|
|
94
|
+
* only (`closes #N` etc.). A bare `issue #N` mention — e.g. a code-review
|
|
95
|
+
* report's finding number — never matches here (issue #65).
|
|
96
|
+
*/
|
|
97
|
+
const PR_FOLLOWUP_BACKLINK_RE =
|
|
98
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+#(\d{1,7})(?!\w)/i;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Add the two module-private matchers right after the constants (before `WORKTREE_RE`):
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
/**
|
|
105
|
+
* Match a PR→issue back-link in the text of an explicit `pr create` command
|
|
106
|
+
* (closing keywords or the legacy bare `issue #N` body form).
|
|
107
|
+
* @param {string} text
|
|
108
|
+
* @returns {number|null}
|
|
109
|
+
*/
|
|
110
|
+
function matchPrCreateBacklink(text) {
|
|
111
|
+
const m = PR_CREATE_BACKLINK_RE.exec(text);
|
|
112
|
+
return m ? Number(m[1]) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Match a PR→issue back-link in later assistant evidence — canonical
|
|
117
|
+
* closing-keyword syntax only, never a bare `issue #N` (issue #65).
|
|
118
|
+
* @param {string} text
|
|
119
|
+
* @returns {number|null}
|
|
120
|
+
*/
|
|
121
|
+
function matchPrFollowupBacklink(text) {
|
|
122
|
+
const m = PR_FOLLOWUP_BACKLINK_RE.exec(text);
|
|
123
|
+
return m ? Number(m[1]) : null;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Rewrite `applyPrBacklink` (currently lines 637-640):
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
function applyPrBacklink(text, index, candidates) {
|
|
131
|
+
const number = matchPrCreateBacklink(text);
|
|
132
|
+
if (number !== null) addCandidate(candidates, "issue", number, "claim", "pr-body", index);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Inside `resolveBacklinkAfter` (lines 651-660), replace the matching lines
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const m = PR_BACKLINK_RE.exec(text);
|
|
140
|
+
if (m) return { number: Number(m[1]), index };
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
with:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const number = matchPrFollowupBacklink(text);
|
|
147
|
+
if (number !== null) return { number, index };
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
(Signature, loop bounds, and `stopBefore` stay as-is in this task — Task 2 restructures them.)
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 4: Run tests to verify pass**
|
|
153
|
+
|
|
154
|
+
Run: `node --test test/code-refs-extract.test.mjs`
|
|
155
|
+
Expected: PASS — new test plus all 19 existing tests (legacy `issue #40` body at :54, case-insensitive `Closes #40` at :77, later-assistant-message back-link at :150 all stay green because their forms are preserved by the split).
|
|
156
|
+
|
|
157
|
+
- [ ] **Step 5: Commit**
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
git add src/core/code-refs.mjs test/code-refs-extract.test.mjs
|
|
161
|
+
git commit -m "fix(code-refs): split PR back-link regex into create/followup matchers with word boundaries (issue #65)"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
### Task 2: Assistant-only follow-up scan, gated on a single PR create
|
|
167
|
+
|
|
168
|
+
**Files:**
|
|
169
|
+
- Modify: `src/core/code-refs.mjs:590-608` (Rule 3 loop + Rule 4b call site), `src/core/code-refs.mjs:642-660` (`resolveBacklinkAfter` rewrite; `evidenceTextAt` callers)
|
|
170
|
+
- Test: `test/code-refs-extract.test.mjs` (2 existing tests rewritten, 4 new tests)
|
|
171
|
+
|
|
172
|
+
**Interfaces:**
|
|
173
|
+
- Consumes: `matchPrFollowupBacklink(text)` from Task 1.
|
|
174
|
+
- Produces: `resolveBacklinkAfter(assistantTexts, baseIndex)` → `{ number: number, index: number } | null` (module-private). `evidenceTextAt` is deleted if it has no remaining callers after this task — verify with `rg -n "evidenceTextAt" src/core/code-refs.mjs` and remove it only when unused (its other caller `resolveCreate` still needs it — check first; if `resolveCreate` keeps it, leave `evidenceTextAt` in place).
|
|
175
|
+
|
|
176
|
+
Covers spec A1 (real false-positive fixture), A3 (commands never promoted; multi-PR skips), and the follow-up half of A2 (assistant negative forms).
|
|
177
|
+
|
|
178
|
+
- [ ] **Step 1: Rewrite the two behavior-contract tests (they must fail before implementation)**
|
|
179
|
+
|
|
180
|
+
In `test/code-refs-extract.test.mjs`, replace the test at :110 ("back-link between two pr creates belongs to the first create only") with:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
test("echo back-link command between two pr creates is ignored (issue #65)", () => {
|
|
184
|
+
const result = extractCodeRefs(
|
|
185
|
+
{
|
|
186
|
+
commands: [
|
|
187
|
+
{ command: "gh pr create --title A" },
|
|
188
|
+
{ command: 'echo "closes #40"' },
|
|
189
|
+
{ command: "gh pr create --title B" },
|
|
190
|
+
],
|
|
191
|
+
assistantTexts: [],
|
|
192
|
+
worktreePath: null,
|
|
193
|
+
branch: null,
|
|
194
|
+
repoUrl: "owner/repo",
|
|
195
|
+
host: "github.com",
|
|
196
|
+
},
|
|
197
|
+
github()
|
|
198
|
+
);
|
|
199
|
+
// Later commands are never scanned as PR back-links, and two PR creates
|
|
200
|
+
// disable the assistant scan — so nothing is extracted here.
|
|
201
|
+
assert.equal(result.issue, null);
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Replace the test at :131 ("second pr create absorbs only the back-link that follows it") with:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
test("assistant back-link with two pr creates produces no pr-backlink (issue #65)", () => {
|
|
209
|
+
const result = extractCodeRefs(
|
|
210
|
+
{
|
|
211
|
+
commands: [{ command: "gh issue comment 40 --body hi" }, { command: "gh pr create --title A" }, { command: "gh pr create --title B" }],
|
|
212
|
+
assistantTexts: ["PR B closes #60"],
|
|
213
|
+
worktreePath: null,
|
|
214
|
+
branch: null,
|
|
215
|
+
repoUrl: "owner/repo",
|
|
216
|
+
host: "github.com",
|
|
217
|
+
},
|
|
218
|
+
github()
|
|
219
|
+
);
|
|
220
|
+
// Ambiguous attribution (two PR creates) → assistant scan skipped; the
|
|
221
|
+
// issue falls back to the plain command action, never a guessed PR link.
|
|
222
|
+
assert.equal(result.issue?.number, 40);
|
|
223
|
+
assert.equal(result.issue?.strength, "action");
|
|
224
|
+
assert.equal(result.issue?.source, "command");
|
|
225
|
+
assert.ok(!result.allRefs.some((r) => r.source === "pr-backlink"));
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
- [ ] **Step 2: Add the four new tests (A1 + A3 + follow-up negatives)**
|
|
230
|
+
|
|
231
|
+
Append to `test/code-refs-extract.test.mjs`:
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
test("issue 65 regression: CR-report finding numbers never become pr-backlinks", () => {
|
|
235
|
+
const result = extractCodeRefs(
|
|
236
|
+
{
|
|
237
|
+
commands: [{ command: 'gh pr create --title fix --body "Closes #19"' }],
|
|
238
|
+
assistantTexts: [
|
|
239
|
+
"CR 报告:本轮仅验证上轮 issue #1(no-pushback)",
|
|
240
|
+
"pushback verdict for issue #1",
|
|
241
|
+
],
|
|
242
|
+
worktreePath: "/repo/.pi/worktrees/issue-19-fix-thing",
|
|
243
|
+
branch: null,
|
|
244
|
+
repoUrl: "owner/repo",
|
|
245
|
+
host: "github.com",
|
|
246
|
+
},
|
|
247
|
+
github()
|
|
248
|
+
);
|
|
249
|
+
assert.equal(result.issue?.number, 19);
|
|
250
|
+
assert.ok(!result.allRefs.some((r) => r.kind === "issue" && r.number === 1));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("later assistant bare issue mentions and non-canonical forms are not pr back-links", () => {
|
|
254
|
+
const result = extractCodeRefs(
|
|
255
|
+
{
|
|
256
|
+
commands: [{ command: "gh pr create --title t" }],
|
|
257
|
+
assistantTexts: [
|
|
258
|
+
"Progress note: 上轮 issue #1 已验证",
|
|
259
|
+
"pushback verdict for issue #1, plus prefix #2 disclose #3",
|
|
260
|
+
],
|
|
261
|
+
worktreePath: null,
|
|
262
|
+
branch: null,
|
|
263
|
+
repoUrl: "owner/repo",
|
|
264
|
+
host: "github.com",
|
|
265
|
+
},
|
|
266
|
+
github()
|
|
267
|
+
);
|
|
268
|
+
assert.equal(result.issue, null);
|
|
269
|
+
assert.ok(!result.allRefs.some((r) => r.source === "pr-backlink"));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("later close/comment/echo commands are not promoted to pr back-links", () => {
|
|
273
|
+
const result = extractCodeRefs(
|
|
274
|
+
{
|
|
275
|
+
commands: [
|
|
276
|
+
{ command: 'gh pr create --title t --body "Closes #19"' },
|
|
277
|
+
{ command: "gh issue close #21" },
|
|
278
|
+
{ command: 'gh pr comment 20 --body "fixes #21"' },
|
|
279
|
+
{ command: 'echo "closes #21"' },
|
|
280
|
+
],
|
|
281
|
+
assistantTexts: [],
|
|
282
|
+
worktreePath: null,
|
|
283
|
+
branch: null,
|
|
284
|
+
repoUrl: "owner/repo",
|
|
285
|
+
host: "github.com",
|
|
286
|
+
},
|
|
287
|
+
github()
|
|
288
|
+
);
|
|
289
|
+
assert.equal(result.issue?.number, 19);
|
|
290
|
+
assert.ok(!result.allRefs.some((r) => r.source === "pr-backlink"));
|
|
291
|
+
// #21 keeps its own plain command-action semantics (not a PR back-link).
|
|
292
|
+
const ref21 = result.allRefs.find((r) => r.kind === "issue" && r.number === 21);
|
|
293
|
+
assert.equal(ref21?.source, "command");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("follow-up back-link still found in later assistant message with a single pr create", () => {
|
|
297
|
+
const result = extractCodeRefs(
|
|
298
|
+
{
|
|
299
|
+
commands: [{ command: "gh pr create --title t --body-file /tmp/body.md" }],
|
|
300
|
+
assistantTexts: ["Opened the PR. This PR closes #40."],
|
|
301
|
+
worktreePath: null,
|
|
302
|
+
branch: null,
|
|
303
|
+
repoUrl: "owner/repo",
|
|
304
|
+
host: "github.com",
|
|
305
|
+
},
|
|
306
|
+
github()
|
|
307
|
+
);
|
|
308
|
+
assert.equal(result.issue?.number, 40);
|
|
309
|
+
assert.equal(result.issue?.source, "pr-backlink");
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
(The last test duplicates coverage of the existing :150 test but pins the new single-create gate explicitly; keep both.)
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 3: Run tests to verify the new expectations fail**
|
|
316
|
+
|
|
317
|
+
Run: `node --test test/code-refs-extract.test.mjs`
|
|
318
|
+
Expected: FAIL — the two rewritten tests fail because the current code still scans commands (`echo "closes #40"` yields issue 40) and still attributes `PR B closes #60` to create B; the A3 command-promotion test fails because `gh issue close #21` currently becomes a `pr-backlink` claim that beats #19.
|
|
319
|
+
|
|
320
|
+
- [ ] **Step 4: Implement the gate and the assistant-only resolver**
|
|
321
|
+
|
|
322
|
+
In `src/core/code-refs.mjs`, restructure the Rule 3 loop (lines 590-608). Replace:
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
// Rule 3: resolve pending create markers against subsequent evidence.
|
|
326
|
+
for (let mi = 0; mi < pendingCreates.length; mi++) {
|
|
327
|
+
const marker = pendingCreates[mi];
|
|
328
|
+
const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
|
|
329
|
+
if (resolved) {
|
|
330
|
+
addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
|
|
331
|
+
}
|
|
332
|
+
// Rule 4b: the issue back-link of a created PR may live in a later
|
|
333
|
+
// assistant message ("This PR closes #40") or in --body-file content
|
|
334
|
+
// that never appears in the command string — scan subsequent evidence
|
|
335
|
+
// for the back-link pattern as well (not just the command itself).
|
|
336
|
+
// The scan stops at the NEXT pr-create marker so an earlier create
|
|
337
|
+
// never absorbs a later PR's back-link.
|
|
338
|
+
if (marker.kind === "pr") {
|
|
339
|
+
const nextPr = pendingCreates.slice(mi + 1).find((m) => m.kind === "pr");
|
|
340
|
+
const backlink = resolveBacklinkAfter(marker, commands, assistantTexts, nextPr?.index ?? Infinity);
|
|
341
|
+
if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
with:
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
// Rule 3: resolve pending create markers against subsequent evidence.
|
|
350
|
+
for (const marker of pendingCreates) {
|
|
351
|
+
const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
|
|
352
|
+
if (resolved) {
|
|
353
|
+
addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Rule 4b: the issue back-link of a created PR may live in a later
|
|
357
|
+
// assistant message ("This PR closes #40") or in --body-file content that
|
|
358
|
+
// never appears in the command string. The flattened evidence input keeps
|
|
359
|
+
// no interleaving timestamps, so a follow-up back-link can be attributed
|
|
360
|
+
// only when exactly one distinct PR-create command exists; with zero or
|
|
361
|
+
// multiple PR creates the assistant scan is skipped rather than guessing.
|
|
362
|
+
// Later commands are never scanned: `gh issue close #N` or
|
|
363
|
+
// `gh pr comment ... fixes #N` are their own signals, not this PR's
|
|
364
|
+
// back-link (issue #65).
|
|
365
|
+
const prCreateIndexes = new Set(pendingCreates.filter((m) => m.kind === "pr").map((m) => m.index));
|
|
366
|
+
if (prCreateIndexes.size === 1) {
|
|
367
|
+
const backlink = resolveBacklinkAfter(assistantTexts, commands.length);
|
|
368
|
+
if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Replace `resolveBacklinkAfter` (lines 642-660) entirely with:
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
/**
|
|
376
|
+
* Find the first PR→issue back-link in later assistant texts — canonical
|
|
377
|
+
* closing-keyword syntax only ("Closes #N" etc., see
|
|
378
|
+
* PR_FOLLOWUP_BACKLINK_RE). Commands are never scanned. `baseIndex` (the
|
|
379
|
+
* command count) keeps the existing ordering contract; it is not a real
|
|
380
|
+
* timestamp.
|
|
381
|
+
* @param {string[]} assistantTexts
|
|
382
|
+
* @param {number} baseIndex
|
|
383
|
+
* @returns {{number: number, index: number}|null}
|
|
384
|
+
*/
|
|
385
|
+
function resolveBacklinkAfter(assistantTexts, baseIndex) {
|
|
386
|
+
for (let i = 0; i < assistantTexts.length; i++) {
|
|
387
|
+
const text = assistantTexts[i];
|
|
388
|
+
if (typeof text !== "string" || !text) continue;
|
|
389
|
+
const number = matchPrFollowupBacklink(text);
|
|
390
|
+
if (number !== null) return { number, index: baseIndex + i };
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Then check `evidenceTextAt`: `resolveCreate` still uses it — leave it in place. Verify with `rg -n "evidenceTextAt" src/core/code-refs.mjs` (expect 2 hits: definition + `resolveCreate`).
|
|
397
|
+
|
|
398
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
399
|
+
|
|
400
|
+
Run: `node --test test/code-refs-extract.test.mjs`
|
|
401
|
+
Expected: PASS — all tests green, including the two rewritten contract tests and the four new tests.
|
|
402
|
+
|
|
403
|
+
- [ ] **Step 6: Commit**
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
git add src/core/code-refs.mjs test/code-refs-extract.test.mjs
|
|
407
|
+
git commit -m "fix(code-refs): scan only assistant texts for follow-up back-links, gated on a single PR create (issue #65)"
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
### Task 3: Store-level regression tests (persistence path)
|
|
413
|
+
|
|
414
|
+
**Files:**
|
|
415
|
+
- Test only: `test/code-refs-store.test.mjs` (append two tests after "partial re-trigger carries forward the untouched kind's earned ref")
|
|
416
|
+
|
|
417
|
+
**Interfaces:**
|
|
418
|
+
- Consumes: existing helpers `freshRoot()`, `makeRepo()`, `gitAvailable()` (defined at the top of the test file, lines 18-40); existing exports `updateCodeRefsFromEvidence`, `readCodeRefs`, `writeCodeRefs` from `src/core/code-refs-store.mjs`; engine behavior from Tasks 1-2.
|
|
419
|
+
- Produces: nothing (pure test additions — proves spec A4).
|
|
420
|
+
|
|
421
|
+
Covers spec A4: the persisted `github.json` never gains a new `pr-backlink #1` from CR-report evidence, and a pre-existing stale `pr-backlink` ref is explicitly NOT retroactively cleaned (documented non-goal).
|
|
422
|
+
|
|
423
|
+
- [ ] **Step 1: Write the two failing tests**
|
|
424
|
+
|
|
425
|
+
Append to `test/code-refs-store.test.mjs`:
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
test("CR-report finding numbers never surface as issue refs through the store hook (issue #65)", { skip: !gitAvailable() }, () => {
|
|
429
|
+
const root = freshRoot();
|
|
430
|
+
const repo = makeRepo();
|
|
431
|
+
try {
|
|
432
|
+
const evidence = {
|
|
433
|
+
viewId: "v1",
|
|
434
|
+
commands: [{ id: "c1", command: 'gh pr create --title fix --body "Closes #19"' }],
|
|
435
|
+
assistantEvidence: [
|
|
436
|
+
{ at: 1, text: "CR 报告:本轮仅验证上轮 issue #1(no-pushback)" },
|
|
437
|
+
{ at: 2, text: "pushback verdict for issue #1" },
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
assert.equal(updateCodeRefsFromEvidence(root, "v1", evidence, { cwd: repo, repoRoot: repo, worktreePath: "issue-19-fix-thing" }), true);
|
|
441
|
+
const snap = readCodeRefs(root, "v1");
|
|
442
|
+
assert.equal(snap.provider, "github");
|
|
443
|
+
assert.equal(snap.issue?.number, 19);
|
|
444
|
+
assert.equal(snap.issue?.source, "pr-body");
|
|
445
|
+
assert.ok(!snap.allRefs.some((r) => r.kind === "issue" && r.number === 1));
|
|
446
|
+
} finally {
|
|
447
|
+
rmSync(root, { recursive: true, force: true });
|
|
448
|
+
rmSync(repo, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("pre-existing stale pr-backlink ref is not retroactively cleaned (issue #65 non-goal)", { skip: !gitAvailable() }, () => {
|
|
453
|
+
const root = freshRoot();
|
|
454
|
+
const repo = makeRepo();
|
|
455
|
+
try {
|
|
456
|
+
const stale = {
|
|
457
|
+
kind: "issue",
|
|
458
|
+
number: 1,
|
|
459
|
+
strength: "claim",
|
|
460
|
+
confidence: "high",
|
|
461
|
+
source: "pr-backlink",
|
|
462
|
+
url: null,
|
|
463
|
+
lastIndex: 3,
|
|
464
|
+
};
|
|
465
|
+
writeCodeRefs(root, { version: 1, viewId: "v1", updatedAt: 1, provider: "github", issue: stale, pr: null, allRefs: [stale] });
|
|
466
|
+
const evidence = {
|
|
467
|
+
viewId: "v1",
|
|
468
|
+
commands: [{ id: "c1", command: 'gh pr create --title fix --body "Closes #19"' }],
|
|
469
|
+
assistantEvidence: [],
|
|
470
|
+
};
|
|
471
|
+
assert.equal(updateCodeRefsFromEvidence(root, "v1", evidence, { cwd: repo, repoRoot: repo }), true);
|
|
472
|
+
const after = readCodeRefs(root, "v1");
|
|
473
|
+
// Fresh extraction wins the badge...
|
|
474
|
+
assert.equal(after.issue?.number, 19);
|
|
475
|
+
// ...but the historical stale ref carries forward (documented non-goal:
|
|
476
|
+
// no retroactive artifact cleanup in this issue).
|
|
477
|
+
assert.ok(after.allRefs.some((r) => r.kind === "issue" && r.number === 1));
|
|
478
|
+
} finally {
|
|
479
|
+
rmSync(root, { recursive: true, force: true });
|
|
480
|
+
rmSync(repo, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 2: Run tests to verify they pass already (engine fix landed in Task 2)**
|
|
486
|
+
|
|
487
|
+
Run: `node --test test/code-refs-store.test.mjs`
|
|
488
|
+
Expected: PASS. These are persistence-path proofs: on the unfixed engine the first test would fail with `snap.issue.number === 1`; now the engine from Tasks 1-2 makes them green. If either fails, the engine regression (not these tests) is what to investigate.
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 3: Commit**
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
git add test/code-refs-store.test.mjs
|
|
494
|
+
git commit -m "test(code-refs): store-level regression — CR findings never surface as issue refs (issue #65)"
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
### Task 4: Documentation sync + full verification
|
|
500
|
+
|
|
501
|
+
**Files:**
|
|
502
|
+
- Modify: `src/core/code-refs.mjs` (doc comments only — verify no stale references), `docs/superpowers/specs/2026-08-29-code-refs-badges-design.md:66` (C2 claim row)
|
|
503
|
+
|
|
504
|
+
**Interfaces:**
|
|
505
|
+
- Consumes: final engine behavior from Tasks 1-2.
|
|
506
|
+
- Produces: docs consistent with the code; `npm run verify` green (spec A5).
|
|
507
|
+
|
|
508
|
+
Covers spec F3 and A5.
|
|
509
|
+
|
|
510
|
+
- [ ] **Step 1: Update the original design doc's C2 claim row**
|
|
511
|
+
|
|
512
|
+
In `docs/superpowers/specs/2026-08-29-code-refs-badges-design.md`, replace the row:
|
|
513
|
+
|
|
514
|
+
```markdown
|
|
515
|
+
| claim | PR 回链:`gh pr create` 的 body/后续文本中的 `issue #N` / `Closes #N`(同时定 issue + PR 两个值) | commands + assistantTexts |
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
with:
|
|
519
|
+
|
|
520
|
+
```markdown
|
|
521
|
+
| claim | PR 回链(按证据上下文拆分,issue #65):`gh pr create` body 中的 `Closes #N` / `fixes issue #N` / 兼容裸 `issue #N`;后续 assistant 文本仅认 canonical `Closes/Fixes/Resolves #N`(带单词边界与 7 位编号边界);仅在恰好一个 PR create 命令时扫描 assistant,后续 command 不参与回链 | create 命令自身 + assistantTexts |
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
- [ ] **Step 2: Verify no stale references remain in the engine**
|
|
525
|
+
|
|
526
|
+
Run: `rg -n "PR_BACKLINK_RE" src/ test/`
|
|
527
|
+
Expected: no hits (only `PR_CREATE_BACKLINK_RE` / `PR_FOLLOWUP_BACKLINK_RE` exist). Also run `rg -n "next pr create|stopBefore" src/core/code-refs.mjs` — expected: no hits (Task 2 removed them; if any comment still mentions them, update the comment).
|
|
528
|
+
|
|
529
|
+
- [ ] **Step 3: Full verification (spec A5)**
|
|
530
|
+
|
|
531
|
+
Run: `npm run verify`
|
|
532
|
+
Expected: typecheck clean, all tests pass (including service / runner integration end-to-end badge tests, which use `gh issue edit` + URL paths unaffected by this change), c8 coverage gates met (lines ≥85%, functions ≥80%, branches ≥70%), `npm pack --dry-run` succeeds.
|
|
533
|
+
|
|
534
|
+
- [ ] **Step 4: Commit**
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
git add docs/superpowers/specs/2026-08-29-code-refs-badges-design.md src/core/code-refs.mjs
|
|
538
|
+
git commit -m "docs(code-refs): sync back-link grammar in design spec and comments (issue #65)"
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## Verification checklist (spec acceptance matrix)
|
|
544
|
+
|
|
545
|
+
| Spec ID | Verified by | Task |
|
|
546
|
+
|---------|-------------|------|
|
|
547
|
+
| A1 | `issue 65 regression` extractor test (winner #19, allRefs has no issue 1) + store hook test | 2, 3 |
|
|
548
|
+
| A2 | create-body word-boundary/number-guard test + rewritten legacy-compatible tests + assistant negative-forms test | 1, 2 |
|
|
549
|
+
| A3 | command-promotion test + two-pr-create skip tests (rewritten ×2, new ×0) | 2 |
|
|
550
|
+
| A4 | two store tests: no new `pr-backlink #1`; stale ref carries forward (non-goal documented) | 3 |
|
|
551
|
+
| A5 | `npm run verify` (typecheck + tests + coverage gates + pack dry-run) | 4 |
|