@erclx/canon 5.45.0 → 5.47.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +15 -31
- package/claude/skills/auto-ship/REQUIREMENT.md +14 -25
- package/claude/skills/backlog-triage/REQUIREMENT.md +12 -23
- package/claude/skills/build-in-slices/REQUIREMENT.md +15 -25
- package/claude/skills/canon-cli/REQUIREMENT.md +15 -27
- package/claude/skills/canon-feedback/REQUIREMENT.md +10 -18
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +10 -15
- package/claude/skills/canvas-board/REQUIREMENT.md +14 -28
- package/claude/skills/ci-workflow/REQUIREMENT.md +12 -21
- package/claude/skills/code-craft/REQUIREMENT.md +14 -28
- package/claude/skills/codebase-layout/REQUIREMENT.md +14 -31
- package/claude/skills/context-fold/REQUIREMENT.md +15 -39
- package/claude/skills/context-fold/references/context.md +3 -3
- package/claude/skills/create-rule/REQUIREMENT.md +10 -24
- package/claude/skills/create-rule/SKILL.md +3 -0
- package/claude/skills/create-skill/REQUIREMENT.md +10 -21
- package/claude/skills/decision-escalate/REQUIREMENT.md +12 -24
- package/claude/skills/deploy-app/REQUIREMENT.md +12 -22
- package/claude/skills/deprecation-migration/REQUIREMENT.md +14 -42
- package/claude/skills/design-identity/REQUIREMENT.md +14 -20
- package/claude/skills/design-taste/REQUIREMENT.md +16 -57
- package/claude/skills/docs-sync/REQUIREMENT.md +14 -23
- package/claude/skills/document-health/REQUIREMENT.md +15 -29
- package/claude/skills/document-health/SKILL.md +2 -2
- package/claude/skills/document-repair/REQUIREMENT.md +32 -0
- package/claude/skills/document-repair/SKILL.md +76 -0
- package/claude/skills/document-repair/references/batches.md +44 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -3
- package/claude/skills/draft-doc/REQUIREMENT.md +4 -3
- package/claude/skills/draft-figure/REQUIREMENT.md +4 -3
- package/claude/skills/draft-identity/REQUIREMENT.md +4 -3
- package/claude/skills/draft-ready/REQUIREMENT.md +4 -3
- package/claude/skills/draft-screencast/REQUIREMENT.md +4 -3
- package/claude/skills/draft-slides/REQUIREMENT.md +4 -3
- package/claude/skills/draw-figure/REQUIREMENT.md +15 -39
- package/claude/skills/git-branch/REQUIREMENT.md +12 -26
- package/claude/skills/git-commit/REQUIREMENT.md +11 -15
- package/claude/skills/git-followup/REQUIREMENT.md +11 -22
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-issue/REQUIREMENT.md +9 -15
- package/claude/skills/git-pr/REQUIREMENT.md +13 -32
- package/claude/skills/git-pr/SKILL.md +11 -2
- package/claude/skills/git-pr/references/closing-issues.md +47 -0
- package/claude/skills/git-pr/references/pr.md +62 -7
- package/claude/skills/git-ship/REQUIREMENT.md +13 -26
- package/claude/skills/git-split/REQUIREMENT.md +10 -17
- package/claude/skills/git-stage/REQUIREMENT.md +12 -18
- package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
- package/claude/skills/make-deck/REQUIREMENT.md +13 -21
- package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
- package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
- package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
- package/claude/skills/plan-groundwork/SKILL.md +5 -2
- package/claude/skills/plan-groundwork/references/output.md +47 -1
- package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
- package/claude/skills/plan-intake/references/intake.md +2 -2
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
- package/claude/skills/plan-intake-answer/SKILL.md +7 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
- package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
- package/claude/skills/read-frames/REQUIREMENT.md +3 -8
- package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
- package/claude/skills/review-address/REQUIREMENT.md +14 -38
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-craft/REQUIREMENT.md +14 -32
- package/claude/skills/review-pr/REQUIREMENT.md +15 -34
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/REQUIREMENT.md +14 -39
- package/claude/skills/review-ui/SKILL.md +1 -0
- package/claude/skills/review-ui/references/post.md +2 -0
- package/claude/skills/review-ui/references/tick.md +3 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
- package/claude/skills/role-reviewer/SKILL.md +8 -1
- package/claude/skills/role-worker/REQUIREMENT.md +2 -2
- package/claude/skills/role-worker/SKILL.md +3 -0
- package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
- package/claude/skills/search-craft/REQUIREMENT.md +12 -36
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/claude/skills/search-craft/references/adopted.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +13 -23
- package/claude/skills/session-map/REQUIREMENT.md +13 -29
- package/claude/skills/session-relay/REQUIREMENT.md +14 -58
- package/claude/skills/session-relay/SKILL.md +6 -0
- package/claude/skills/session-resume/REQUIREMENT.md +13 -27
- package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
- package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
- package/claude/skills/target-check/REQUIREMENT.md +16 -23
- package/claude/skills/target-setup/REQUIREMENT.md +11 -34
- package/claude/skills/task-board/REQUIREMENT.md +13 -22
- package/claude/skills/task-board/SKILL.md +7 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/REQUIREMENT.md +11 -24
- package/claude/skills/task-complete/references/archive.md +4 -2
- package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
- package/claude/skills/test-craft/REQUIREMENT.md +15 -46
- package/claude/skills/test-first/REQUIREMENT.md +14 -20
- package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
- package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
- package/claude/skills/video-craft/REQUIREMENT.md +14 -27
- package/claude/skills/write-doc/REQUIREMENT.md +14 -24
- package/claude/skills/write-human/REQUIREMENT.md +15 -27
- package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
- package/docs/agents/context-audit.md +19 -12
- package/docs/agents/markdown-audit.md +15 -10
- package/docs/target/projects.md +2 -0
- package/docs/workflow/skill-map.md +6 -5
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/governance/renames.toml +0 -2
- package/governance/rules/claude/output.md +5 -1
- package/governance/rules/code/routing.md +5 -1
- package/governance/stacks/base.toml +0 -1
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/claude/cases/workflow.ts +12 -0
- package/src/commands/context.ts +57 -3
- package/src/commands/labels.ts +149 -4
- package/src/commands/markdown.ts +188 -21
- package/src/commands/pr.ts +113 -4
- package/src/commands/sessions.ts +58 -11
- package/src/commands/tasks.ts +182 -0
- package/src/commands/worktrees.ts +2 -0
- package/src/context/coverage.ts +210 -0
- package/src/context/gate.ts +12 -3
- package/src/gate/stages.ts +1 -1
- package/src/init/steps.ts +45 -0
- package/src/intake/items.ts +14 -0
- package/src/labels/closing.ts +46 -0
- package/src/labels/density.ts +363 -0
- package/src/markdown/gated.ts +250 -0
- package/src/markdown/scope.ts +115 -2
- package/src/migrate/rename.ts +6 -0
- package/src/pr/evidence.ts +1 -1
- package/src/pr/rest.ts +2 -0
- package/src/pr/tick.ts +89 -0
- package/src/sessions/claim.ts +7 -0
- package/src/sessions/live.ts +79 -6
- package/src/sessions/registry.ts +11 -0
- package/src/sessions/resolve.ts +85 -7
- package/src/tasks/archive.ts +15 -4
- package/src/tasks/closes.ts +136 -0
- package/src/tasks/record.ts +1 -1
- package/src/worktrees/reclaim.ts +16 -1
- package/tooling/base/configs/scripts/verify.ts +1 -1
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
- package/tooling/claude/seeds/CLAUDE.md +2 -2
- package/governance/rules/canon/task-scope.md +0 -12
package/src/commands/pr.ts
CHANGED
|
@@ -93,7 +93,13 @@ import {
|
|
|
93
93
|
servesChange,
|
|
94
94
|
type WorkflowFile,
|
|
95
95
|
} from '@/pr/preview'
|
|
96
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
readUiPassHead,
|
|
98
|
+
type TickRefusal,
|
|
99
|
+
tickBoxes,
|
|
100
|
+
type UiPassSummary,
|
|
101
|
+
writeUiPassLine,
|
|
102
|
+
} from '@/pr/tick'
|
|
97
103
|
import {
|
|
98
104
|
commentRowOf,
|
|
99
105
|
identityOf,
|
|
@@ -157,9 +163,16 @@ interface TickOptions extends ReadOptions {
|
|
|
157
163
|
readonly head?: string
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
type TickReason =
|
|
166
|
+
type TickReason =
|
|
167
|
+
| TickRefusal
|
|
168
|
+
| 'stale-head'
|
|
169
|
+
| 'bad-boxes'
|
|
170
|
+
| 'no-comment'
|
|
171
|
+
| 'body-refused'
|
|
161
172
|
|
|
162
173
|
const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
|
|
174
|
+
'body-refused':
|
|
175
|
+
'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
|
|
163
176
|
'stale-head':
|
|
164
177
|
'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
|
|
165
178
|
'bad-boxes':
|
|
@@ -736,6 +749,11 @@ export function register(program: Command): void {
|
|
|
736
749
|
'comment. Together with --checklist the link and the checklist land in',
|
|
737
750
|
'one comment.',
|
|
738
751
|
'',
|
|
752
|
+
"A render at a new head also resets the pull request body's UI pass",
|
|
753
|
+
'line, the one `canon pr tick` writes under ## Testing, to unchecked',
|
|
754
|
+
'naming that head. It touches no other body text, leaves a line already',
|
|
755
|
+
'naming this head alone, and reports `uiPass` as reset or reset-refused.',
|
|
756
|
+
'',
|
|
739
757
|
'Both reasons carry what the marked comment already posted, each field',
|
|
740
758
|
'present only when that comment holds it:',
|
|
741
759
|
' preview the hosted **Preview:** address the comment carries',
|
|
@@ -1024,8 +1042,17 @@ export function register(program: Command): void {
|
|
|
1024
1042
|
'it. A later render at another head clears a tick whose stamp names',
|
|
1025
1043
|
'a different commit.',
|
|
1026
1044
|
'',
|
|
1045
|
+
"It also keeps one marked line in the pull request body's ## Testing,",
|
|
1046
|
+
'such as `- [x] UI pass, 17 of 17 boxes passed at <short sha>, 1 taste',
|
|
1047
|
+
'box left to the operator`. The line is ticked only when every box a',
|
|
1048
|
+
'driver can run is ticked, counts taste boxes apart, and is the only',
|
|
1049
|
+
'body text this verb writes, so hand edits elsewhere survive.',
|
|
1050
|
+
'',
|
|
1027
1051
|
'Read `reason` on the JSON record:',
|
|
1028
|
-
' ticked the boxes were ticked
|
|
1052
|
+
' ticked the boxes were ticked and the body line written, with the',
|
|
1053
|
+
' tip in `head`',
|
|
1054
|
+
' body-refused the comment is ticked but gh could not edit the body,',
|
|
1055
|
+
' exit 1 with `commentTicked`. Re-run to retry the body.',
|
|
1029
1056
|
' stale-head --head is not the remote branch tip, nothing written',
|
|
1030
1057
|
' taste-box a named box ends in (taste), nothing written',
|
|
1031
1058
|
' no-box a named number is past the checklist, nothing written',
|
|
@@ -2437,10 +2464,30 @@ async function runEvidence(
|
|
|
2437
2464
|
return 1
|
|
2438
2465
|
}
|
|
2439
2466
|
|
|
2467
|
+
// A UI pass line stamped at an earlier head describes a tree that is gone,
|
|
2468
|
+
// so a render at a new head puts it back to unchecked. A line already
|
|
2469
|
+
// naming this head is left alone, since each body edit reruns the label gate.
|
|
2470
|
+
const priorBody = identity.body ?? ''
|
|
2471
|
+
const stampedHead = readUiPassHead(priorBody)
|
|
2472
|
+
let uiPass: 'reset' | 'reset-refused' | undefined
|
|
2473
|
+
if (stampedHead !== undefined && !identity.head.startsWith(stampedHead)) {
|
|
2474
|
+
const reset = writeUiPassLine(priorBody, {
|
|
2475
|
+
kind: 'reset',
|
|
2476
|
+
head: identity.head,
|
|
2477
|
+
})
|
|
2478
|
+
uiPass = (await patchPullBody(root, identity.number, reset))
|
|
2479
|
+
? 'reset'
|
|
2480
|
+
: 'reset-refused'
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2440
2483
|
logStep('Scope')
|
|
2441
2484
|
logInfo(
|
|
2442
2485
|
`${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
|
|
2443
2486
|
)
|
|
2487
|
+
if (uiPass === 'reset') logInfo('UI pass line reset to the new head')
|
|
2488
|
+
if (uiPass === 'reset-refused') {
|
|
2489
|
+
logWarn('gh could not edit the body, so its UI pass line names an old head')
|
|
2490
|
+
}
|
|
2444
2491
|
if (sized.unread.length > 0) {
|
|
2445
2492
|
logWarn(
|
|
2446
2493
|
`width unread for ${plural(sized.unread.length, 'image')}, rendered at 1000: ${sized.unread.join(', ')}`,
|
|
@@ -2469,6 +2516,7 @@ async function runEvidence(
|
|
|
2469
2516
|
body,
|
|
2470
2517
|
states,
|
|
2471
2518
|
...(sized.unread.length > 0 && { widthUnread: sized.unread }),
|
|
2519
|
+
...(uiPass !== undefined && { uiPass }),
|
|
2472
2520
|
...(commentId !== undefined && { commentId }),
|
|
2473
2521
|
...carried,
|
|
2474
2522
|
})}\n`,
|
|
@@ -2941,9 +2989,53 @@ async function runTick(
|
|
|
2941
2989
|
if (patched === null) return refuseTick('gh-failed')
|
|
2942
2990
|
}
|
|
2943
2991
|
|
|
2992
|
+
const boxes = readChecklistBoxes(
|
|
2993
|
+
findEvidenceChecklist([{ body: result.body }]) ?? '',
|
|
2994
|
+
)
|
|
2995
|
+
const driven = boxes.filter((box) => !box.isTaste)
|
|
2996
|
+
const summary: UiPassSummary = {
|
|
2997
|
+
kind: 'passed',
|
|
2998
|
+
// A tick counts as this pass's only when stamped at the tip, since an
|
|
2999
|
+
// unstamped one is the author's claim and an older stamp names another head.
|
|
3000
|
+
passed: driven.filter(
|
|
3001
|
+
(box) =>
|
|
3002
|
+
box.isTicked &&
|
|
3003
|
+
box.stamp !== undefined &&
|
|
3004
|
+
reading.tip.startsWith(box.stamp),
|
|
3005
|
+
).length,
|
|
3006
|
+
total: driven.length,
|
|
3007
|
+
taste: boxes.length - driven.length,
|
|
3008
|
+
head: reading.tip,
|
|
3009
|
+
}
|
|
3010
|
+
const priorBody = identity.body ?? ''
|
|
3011
|
+
const nextBody = writeUiPassLine(priorBody, summary)
|
|
3012
|
+
if (
|
|
3013
|
+
nextBody !== priorBody &&
|
|
3014
|
+
!(await patchPullBody(root, identity.number, nextBody))
|
|
3015
|
+
) {
|
|
3016
|
+
logStep('Partly ticked')
|
|
3017
|
+
logWarn(TICK_REFUSALS['body-refused'])
|
|
3018
|
+
outro()
|
|
3019
|
+
if (emitJson) {
|
|
3020
|
+
process.stdout.write(
|
|
3021
|
+
`${JSON.stringify({
|
|
3022
|
+
root,
|
|
3023
|
+
number: identity.number,
|
|
3024
|
+
reason: 'body-refused',
|
|
3025
|
+
message: TICK_REFUSALS['body-refused'],
|
|
3026
|
+
commentTicked: true,
|
|
3027
|
+
head: reading.tip,
|
|
3028
|
+
boxes: numbers.map(Number),
|
|
3029
|
+
commentId,
|
|
3030
|
+
})}\n`,
|
|
3031
|
+
)
|
|
3032
|
+
}
|
|
3033
|
+
return 1
|
|
3034
|
+
}
|
|
3035
|
+
|
|
2944
3036
|
logStep('Ticked')
|
|
2945
3037
|
logInfo(
|
|
2946
|
-
`${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}`,
|
|
3038
|
+
`${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}, UI pass line on the body`,
|
|
2947
3039
|
)
|
|
2948
3040
|
outro()
|
|
2949
3041
|
if (emitJson) {
|
|
@@ -2961,6 +3053,23 @@ async function runTick(
|
|
|
2961
3053
|
return 0
|
|
2962
3054
|
}
|
|
2963
3055
|
|
|
3056
|
+
/** Replaces a pull request's body, which `gh` takes through the REST pulls endpoint a cloud proxy serves. */
|
|
3057
|
+
async function patchPullBody(
|
|
3058
|
+
root: string,
|
|
3059
|
+
number: number,
|
|
3060
|
+
body: string,
|
|
3061
|
+
): Promise<boolean> {
|
|
3062
|
+
const patched = await gh(root, [
|
|
3063
|
+
'api',
|
|
3064
|
+
'-X',
|
|
3065
|
+
'PATCH',
|
|
3066
|
+
`repos/{owner}/{repo}/pulls/${number}`,
|
|
3067
|
+
'-f',
|
|
3068
|
+
`body=${body}`,
|
|
3069
|
+
])
|
|
3070
|
+
return patched !== null
|
|
3071
|
+
}
|
|
3072
|
+
|
|
2964
3073
|
/** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
|
|
2965
3074
|
type ApiCall =
|
|
2966
3075
|
| { readonly ok: true; readonly data: unknown }
|
package/src/commands/sessions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Command } from 'commander'
|
|
|
3
3
|
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
4
4
|
import {
|
|
5
5
|
callerIdentity,
|
|
6
|
+
confidenceOf,
|
|
6
7
|
repositoryOf,
|
|
7
8
|
type ResolvedSession,
|
|
8
9
|
resolveSessions,
|
|
@@ -134,9 +135,23 @@ export function register(program: Command): void {
|
|
|
134
135
|
'',
|
|
135
136
|
'The confidence field says how the liveness of a row was decided.',
|
|
136
137
|
'"confirmed" matched the running process against the start time the',
|
|
137
|
-
'record stamped. "unverified" means only that the
|
|
138
|
-
'cannot rule out a pid handed to an unrelated
|
|
139
|
-
'reported that way is a candidate list rather than
|
|
138
|
+
'record stamped, and reads "via": "pid". "unverified" means only that the',
|
|
139
|
+
'pid answers, which cannot rule out a pid handed to an unrelated',
|
|
140
|
+
'process, so a roster reported that way is a candidate list rather than',
|
|
141
|
+
'an identity. Where the start time cannot be read, a row whose messaging',
|
|
142
|
+
'socket accepts a connection is confirmed instead and reads "via":',
|
|
143
|
+
'"listener". The probe connects and closes without writing. Each row',
|
|
144
|
+
'carries its own "confidence", and the top-level field is the weakest of',
|
|
145
|
+
'the rows listed, so --branch is not weakened by a row it dropped.',
|
|
146
|
+
'',
|
|
147
|
+
'A reader in another pid namespace, such as a sandboxed shell, cannot',
|
|
148
|
+
'see the pid of a record stamped elsewhere. That row is judged on its',
|
|
149
|
+
'messaging socket instead: it stays on the roster as "unverified" with',
|
|
150
|
+
'"via": "socket", since a crashed session leaves its socket behind. A',
|
|
151
|
+
'foreign record whose socket accepts a connection is confirmed as',
|
|
152
|
+
'"listener". One whose socket is gone is left off the roster and',
|
|
153
|
+
'counted in "foreignDead", so an empty roster beside a count above zero',
|
|
154
|
+
'is one this reader could not check, not one whose sessions ended.',
|
|
140
155
|
'',
|
|
141
156
|
'The JSON record also carries "statusUpdatedAt" (the stamp the client',
|
|
142
157
|
'wrote beside "status", or null where its record carries none) and',
|
|
@@ -463,8 +478,14 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
463
478
|
: null
|
|
464
479
|
|
|
465
480
|
intro('canon sessions list')
|
|
466
|
-
reportConfidence(report)
|
|
467
|
-
reportSessions(
|
|
481
|
+
reportConfidence(shown, report)
|
|
482
|
+
reportSessions(
|
|
483
|
+
shown,
|
|
484
|
+
opts.branch,
|
|
485
|
+
repository,
|
|
486
|
+
own !== null,
|
|
487
|
+
report.foreignDead,
|
|
488
|
+
)
|
|
468
489
|
if (claim) reportClaim(claim)
|
|
469
490
|
outro()
|
|
470
491
|
|
|
@@ -472,7 +493,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
472
493
|
process.stdout.write(
|
|
473
494
|
`${JSON.stringify({
|
|
474
495
|
dir: report.dir,
|
|
475
|
-
|
|
496
|
+
// Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
|
|
497
|
+
confidence: confidenceOf(shown),
|
|
498
|
+
foreignDead: report.foreignDead,
|
|
476
499
|
branch: opts.branch ?? null,
|
|
477
500
|
repository,
|
|
478
501
|
worktree: claim?.worktree ?? null,
|
|
@@ -513,6 +536,7 @@ function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
|
|
|
513
536
|
* addresses a session directly and the second opens by asking to be corrected.
|
|
514
537
|
*/
|
|
515
538
|
function reportConfidence(
|
|
539
|
+
shown: readonly ResolvedSession[],
|
|
516
540
|
report: Extract<SessionReport, { kind: 'resolved' }>,
|
|
517
541
|
): void {
|
|
518
542
|
logStep('Liveness')
|
|
@@ -520,16 +544,30 @@ function reportConfidence(
|
|
|
520
544
|
// A roster of none decided nothing, so the confirmed line would claim a check
|
|
521
545
|
// over rows that do not exist. The registry is never pruned and holds a record
|
|
522
546
|
// per session ever run, which is what makes the empty result worth stating.
|
|
523
|
-
if (
|
|
547
|
+
if (shown.length === 0) {
|
|
524
548
|
logInfo(
|
|
525
|
-
|
|
549
|
+
report.foreignDead > 0 || report.sessions.length > 0
|
|
550
|
+
? 'No row to decide.'
|
|
551
|
+
: 'No row to decide. Every record belongs to a session that has ended.',
|
|
526
552
|
)
|
|
527
553
|
return
|
|
528
554
|
}
|
|
529
555
|
|
|
530
|
-
if (
|
|
556
|
+
if (confidenceOf(shown) === 'confirmed') {
|
|
557
|
+
const listened = shown.filter(
|
|
558
|
+
(session) => session.via === 'listener',
|
|
559
|
+
).length
|
|
531
560
|
logInfo(
|
|
532
|
-
|
|
561
|
+
listened === 0
|
|
562
|
+
? 'Every row matched a running process against the start time its record stamped.'
|
|
563
|
+
: `Every row is confirmed, ${listened} of them because their messaging socket accepted a connection and the rest by start time.`,
|
|
564
|
+
)
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (shown.some((session) => session.via === 'socket')) {
|
|
569
|
+
logWarn(
|
|
570
|
+
'This reader sits in another pid namespace than some records, so those rows rest on a messaging socket existing alone. A crashed session leaves its socket behind, so treat the mapping as inferred and open by asking to be corrected.',
|
|
533
571
|
)
|
|
534
572
|
return
|
|
535
573
|
}
|
|
@@ -544,6 +582,7 @@ function reportSessions(
|
|
|
544
582
|
branch: string | undefined,
|
|
545
583
|
repository: string | null,
|
|
546
584
|
scoped: boolean,
|
|
585
|
+
foreignDead: number,
|
|
547
586
|
): void {
|
|
548
587
|
logStep('Sessions')
|
|
549
588
|
|
|
@@ -567,7 +606,9 @@ function reportSessions(
|
|
|
567
606
|
}
|
|
568
607
|
|
|
569
608
|
logInfo(
|
|
570
|
-
|
|
609
|
+
foreignDead > 0
|
|
610
|
+
? `No live session could be confirmed. ${foreignDead} of the records come from another pid namespace and no socket answers for them, so this reader could not check whether they ended.`
|
|
611
|
+
: 'No live session. Every record in the registry belongs to a session that has ended.',
|
|
571
612
|
)
|
|
572
613
|
return
|
|
573
614
|
}
|
|
@@ -630,6 +671,12 @@ function reportClaim(claim: ClaimReport): void {
|
|
|
630
671
|
)
|
|
631
672
|
}
|
|
632
673
|
|
|
674
|
+
if (claim.foreignDead > 0) {
|
|
675
|
+
logWarn(
|
|
676
|
+
`${claim.foreignDead} records from another pid namespace have no answering socket, so a session holding this branch may be missing. Treat it as unverified rather than clear.`,
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
633
680
|
if (!claim.refsReadable) {
|
|
634
681
|
logWarn(
|
|
635
682
|
'The ref read failed, so this cannot say whether the branch already exists. Treat it as unverified rather than clear.',
|
package/src/commands/tasks.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
claimLabel,
|
|
34
34
|
nextLabel,
|
|
35
35
|
} from '@/tasks/label'
|
|
36
|
+
import { type ClosureOutcome, readClosure } from '@/tasks/closes'
|
|
36
37
|
import { listTasks } from '@/tasks/list'
|
|
37
38
|
import {
|
|
38
39
|
type Claim,
|
|
@@ -142,6 +143,13 @@ interface OutcomeCommandOptions {
|
|
|
142
143
|
readonly root?: string
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
interface ClosesCommandOptions {
|
|
147
|
+
readonly branch?: string
|
|
148
|
+
readonly json?: boolean
|
|
149
|
+
readonly plan?: string
|
|
150
|
+
readonly root?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
interface ListCommandOptions {
|
|
146
154
|
readonly json?: boolean
|
|
147
155
|
readonly root?: string
|
|
@@ -635,6 +643,57 @@ export function register(program: Command): void {
|
|
|
635
643
|
process.exitCode = await runOutcome(task, opts)
|
|
636
644
|
})
|
|
637
645
|
|
|
646
|
+
tasks
|
|
647
|
+
.command('closes')
|
|
648
|
+
.description('Name the issues the branch pull request closes')
|
|
649
|
+
.argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
650
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
651
|
+
.option('--plan <slug>', 'Select the task whose Plan line names this plan')
|
|
652
|
+
.option(
|
|
653
|
+
'--branch <name>',
|
|
654
|
+
'Branch to treat as this pull request, defaulting to the checked-out one',
|
|
655
|
+
)
|
|
656
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
657
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
658
|
+
.addHelpText(
|
|
659
|
+
'after',
|
|
660
|
+
[
|
|
661
|
+
'',
|
|
662
|
+
'Refusals, read off `reason`:',
|
|
663
|
+
' no-board the repository holds no `.canon/tasks/` board',
|
|
664
|
+
' no-match no task matches the selector',
|
|
665
|
+
' ambiguous more than one task matches the selector',
|
|
666
|
+
' bad-input a task and a plan both named, or neither',
|
|
667
|
+
'',
|
|
668
|
+
'Exit codes:',
|
|
669
|
+
' 0 the task is read, whether or not it closes',
|
|
670
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
671
|
+
'',
|
|
672
|
+
'Reads the `Issue:` lines, each `#NNN` or a comma list, and reports',
|
|
673
|
+
'`issues` and `closing`. The pull request closes them only when it is',
|
|
674
|
+
"the task's last slice, the condition the merge-time archive applies:",
|
|
675
|
+
'no open outcome and no other branch on `Pending branch:`. The branch',
|
|
676
|
+
'and the names it carried before a rename count as this one. Read off',
|
|
677
|
+
'`reason` when `closing` is false:',
|
|
678
|
+
' no-issues the task carries no `Issue:` number',
|
|
679
|
+
' earlier-slice this branch has a pull request on the task that is not',
|
|
680
|
+
' the last listed, so a later number closes the issue',
|
|
681
|
+
' lookup-failed the pull request lookup failed while the task already',
|
|
682
|
+
' lists a pull request, so an earlier slice cannot be ruled out',
|
|
683
|
+
' open-outcomes an outcome is still open, so a later slice closes it',
|
|
684
|
+
' pending-branch another branch is on `Pending branch:`',
|
|
685
|
+
' no-outcomes the task carries no outcomes',
|
|
686
|
+
'',
|
|
687
|
+
'Examples:',
|
|
688
|
+
' canon tasks closes --plan closing-issue-references --json',
|
|
689
|
+
' canon tasks closes v28.1-trigger-escalation --branch feat/x',
|
|
690
|
+
'',
|
|
691
|
+
].join('\n'),
|
|
692
|
+
)
|
|
693
|
+
.action(async (task: string | undefined, opts: ClosesCommandOptions) => {
|
|
694
|
+
process.exitCode = await runCloses(task, opts)
|
|
695
|
+
})
|
|
696
|
+
|
|
638
697
|
tasks
|
|
639
698
|
.command('next-label')
|
|
640
699
|
.description(
|
|
@@ -1196,6 +1255,67 @@ async function branchPullNumbers(
|
|
|
1196
1255
|
}
|
|
1197
1256
|
}
|
|
1198
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* The pull request numbers whose head is `branch`, read over REST because a
|
|
1260
|
+
* cloud session's GitHub proxy refuses the GraphQL `gh pr list` runs on.
|
|
1261
|
+
* Unlike `branchPullNumbers`, a failed or unreadable answer is null rather
|
|
1262
|
+
* than an empty list, since the closing decision must not read a failure as
|
|
1263
|
+
* a branch with no pull request.
|
|
1264
|
+
*/
|
|
1265
|
+
async function branchPullNumbersRest(
|
|
1266
|
+
branch: string | undefined,
|
|
1267
|
+
): Promise<readonly number[] | null> {
|
|
1268
|
+
if (branch === undefined) return []
|
|
1269
|
+
|
|
1270
|
+
const owner = await execa(
|
|
1271
|
+
'gh',
|
|
1272
|
+
['api', 'repos/{owner}/{repo}', '--jq', '.owner.login'],
|
|
1273
|
+
{
|
|
1274
|
+
cwd: process.cwd(),
|
|
1275
|
+
reject: false,
|
|
1276
|
+
timeout: GH_TIMEOUT_MS,
|
|
1277
|
+
env: gitEnv(),
|
|
1278
|
+
extendEnv: false,
|
|
1279
|
+
},
|
|
1280
|
+
)
|
|
1281
|
+
if (owner.exitCode !== 0 || owner.stdout.trim() === '') return null
|
|
1282
|
+
|
|
1283
|
+
const result = await execa(
|
|
1284
|
+
'gh',
|
|
1285
|
+
[
|
|
1286
|
+
'api',
|
|
1287
|
+
'-X',
|
|
1288
|
+
'GET',
|
|
1289
|
+
'repos/{owner}/{repo}/pulls',
|
|
1290
|
+
'-f',
|
|
1291
|
+
`head=${owner.stdout.trim()}:${branch}`,
|
|
1292
|
+
'-f',
|
|
1293
|
+
'state=all',
|
|
1294
|
+
'--jq',
|
|
1295
|
+
'[.[].number]',
|
|
1296
|
+
],
|
|
1297
|
+
{
|
|
1298
|
+
cwd: process.cwd(),
|
|
1299
|
+
reject: false,
|
|
1300
|
+
timeout: GH_TIMEOUT_MS,
|
|
1301
|
+
env: gitEnv(),
|
|
1302
|
+
extendEnv: false,
|
|
1303
|
+
},
|
|
1304
|
+
)
|
|
1305
|
+
if (result.exitCode !== 0) return null
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
const numbers: unknown = JSON.parse(result.stdout)
|
|
1309
|
+
if (!Array.isArray(numbers)) return null
|
|
1310
|
+
|
|
1311
|
+
return numbers.every((number) => Number.isInteger(number))
|
|
1312
|
+
? (numbers as number[])
|
|
1313
|
+
: null
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1199
1319
|
async function runPlanLink(
|
|
1200
1320
|
task: string,
|
|
1201
1321
|
plan: string,
|
|
@@ -1381,6 +1501,66 @@ function reportPlanLink(
|
|
|
1381
1501
|
return 0
|
|
1382
1502
|
}
|
|
1383
1503
|
|
|
1504
|
+
async function runCloses(
|
|
1505
|
+
task: string | undefined,
|
|
1506
|
+
opts: ClosesCommandOptions,
|
|
1507
|
+
): Promise<number> {
|
|
1508
|
+
const emitJson = opts.json ?? false
|
|
1509
|
+
const selector = selectorFor(task, opts.plan)
|
|
1510
|
+
|
|
1511
|
+
if ('ok' in selector) {
|
|
1512
|
+
return reportRecord('canon tasks closes', selector, emitJson, process.cwd())
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1516
|
+
const branch = opts.branch ?? (await workingBranch())
|
|
1517
|
+
const branches = branch ? [branch, ...(await formerNames(branch))] : []
|
|
1518
|
+
const outcome = await readClosure(
|
|
1519
|
+
root,
|
|
1520
|
+
selector,
|
|
1521
|
+
branches,
|
|
1522
|
+
await branchPullNumbersRest(branch),
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
return reportCloses(outcome, emitJson, root)
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function reportCloses(
|
|
1529
|
+
outcome: ClosureOutcome,
|
|
1530
|
+
emitJson: boolean,
|
|
1531
|
+
root: string,
|
|
1532
|
+
): number {
|
|
1533
|
+
if (!outcome.ok) {
|
|
1534
|
+
return reportRecord('canon tasks closes', outcome, emitJson, root)
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (emitJson) {
|
|
1538
|
+
process.stdout.write(
|
|
1539
|
+
`${JSON.stringify({
|
|
1540
|
+
ok: true,
|
|
1541
|
+
root,
|
|
1542
|
+
task: outcome.stem,
|
|
1543
|
+
path: relative(root, outcome.path),
|
|
1544
|
+
issues: outcome.issues,
|
|
1545
|
+
closing: outcome.closing,
|
|
1546
|
+
reason: outcome.reason,
|
|
1547
|
+
})}\n`,
|
|
1548
|
+
)
|
|
1549
|
+
return 0
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
intro('canon tasks closes')
|
|
1553
|
+
logStep(outcome.closing ? 'Closes' : 'Closes nothing')
|
|
1554
|
+
logInfo(
|
|
1555
|
+
outcome.issues.length > 0
|
|
1556
|
+
? `${outcome.stem} names ${outcome.issues.map((n) => `#${n}`).join(', ')}, ${outcome.reason}`
|
|
1557
|
+
: `${outcome.stem} names no issue`,
|
|
1558
|
+
)
|
|
1559
|
+
outro()
|
|
1560
|
+
|
|
1561
|
+
return 0
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1384
1564
|
function reportOutcome(
|
|
1385
1565
|
outcome: CloseOutcome,
|
|
1386
1566
|
emitJson: boolean,
|
|
@@ -2015,6 +2195,7 @@ function report(
|
|
|
2015
2195
|
logInfo('retargeted the Ready: line')
|
|
2016
2196
|
}
|
|
2017
2197
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
2198
|
+
if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
|
|
2018
2199
|
logRelinked(outcome.relinked, outcome.relinkFailed, root)
|
|
2019
2200
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
2020
2201
|
if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
|
|
@@ -2097,6 +2278,7 @@ function recordFor(
|
|
|
2097
2278
|
from: relative(root, outcome.from),
|
|
2098
2279
|
to: relative(root, outcome.to),
|
|
2099
2280
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
2281
|
+
backlogRowRemoved: outcome.backlogRowRemoved,
|
|
2100
2282
|
indexRegenerated: outcome.indexRegenerated,
|
|
2101
2283
|
plans: planMovesFor(outcome.plans, root),
|
|
2102
2284
|
ready: outcome.ready
|
|
@@ -109,6 +109,8 @@ const UNREADABLE: Record<Unreadable, string> = {
|
|
|
109
109
|
'The pull request read failed, so every worktree would report as unmerged whatever its real state. Authenticate gh and run this again.',
|
|
110
110
|
'sessions-unreadable':
|
|
111
111
|
'The session roster could not be read, so nothing was read about which worktrees are still held.',
|
|
112
|
+
'sessions-unchecked':
|
|
113
|
+
'Some session records come from another pid namespace and no socket answers for them, so a live holder may be missing from the roster. Run this from the namespace the sessions run in.',
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
const CONDITIONS = [
|