@erclx/canon 4.71.0 → 4.72.1
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/draft-and-pick/REQUIREMENT.md +3 -1
- package/claude/skills/draft-and-pick/SKILL.md +10 -6
- package/claude/skills/git-ship/SKILL.md +17 -6
- package/claude/skills/markdown-propose/references/format.md +1 -1
- package/claude/skills/plan-groundwork/REQUIREMENT.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/plan-intake/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
- package/claude/skills/role-planner/SKILL.md +5 -0
- package/claude/skills/role-worker/SKILL.md +6 -0
- package/claude/skills/session-relay/REQUIREMENT.md +41 -0
- package/claude/skills/session-relay/SKILL.md +36 -0
- package/docs/agents/key-changes.md +5 -1
- package/docs/agents/tasks.md +33 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/commands/pr.ts +128 -6
- package/src/commands/tasks.ts +154 -0
- package/src/git-files.ts +69 -0
- package/src/pr/bijection.ts +59 -1
- package/src/tasks/reach.ts +383 -0
- package/standards/groundwork.md +1 -1
- package/standards/intake.md +1 -1
- package/standards/memory.md +2 -2
- package/standards/plan.md +3 -1
- package/standards/tasks.md +2 -0
package/src/commands/tasks.ts
CHANGED
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
planCitations,
|
|
15
15
|
} from '@/tasks/archive'
|
|
16
16
|
import { type LabelOutcome, nextLabel } from '@/tasks/label'
|
|
17
|
+
import {
|
|
18
|
+
type Claim,
|
|
19
|
+
type Holder,
|
|
20
|
+
planReach,
|
|
21
|
+
type ReachOutcome,
|
|
22
|
+
} from '@/tasks/reach'
|
|
17
23
|
import {
|
|
18
24
|
type CloseOutcome,
|
|
19
25
|
closeOutcomes,
|
|
@@ -84,6 +90,12 @@ interface BranchCommandOptions {
|
|
|
84
90
|
readonly root?: string
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
interface ReachCommandOptions {
|
|
94
|
+
readonly base?: string
|
|
95
|
+
readonly json?: boolean
|
|
96
|
+
readonly root?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
interface PullRequestCommandOptions {
|
|
88
100
|
readonly json?: boolean
|
|
89
101
|
readonly plan?: string
|
|
@@ -326,6 +338,45 @@ export function register(program: Command): void {
|
|
|
326
338
|
process.exitCode = await runBranch(plan, opts)
|
|
327
339
|
})
|
|
328
340
|
|
|
341
|
+
tasks
|
|
342
|
+
.command('plan-reach')
|
|
343
|
+
.description('Read a branch back against what was declared about it')
|
|
344
|
+
.argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
|
|
345
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
346
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
347
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
348
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
349
|
+
.addHelpText(
|
|
350
|
+
'after',
|
|
351
|
+
[
|
|
352
|
+
'',
|
|
353
|
+
'Exit codes:',
|
|
354
|
+
' 0 the reach was read and no other track claims a changed path',
|
|
355
|
+
' 1 refused as no-plan, archived, bad-input, no-base, or no-diff',
|
|
356
|
+
' 2 read, and another live plan or Run now row claims a path',
|
|
357
|
+
'',
|
|
358
|
+
'It reports claimed first and undeclared second. A claimed path is one',
|
|
359
|
+
'another track holds and is the half worth acting on; an undeclared one',
|
|
360
|
+
'is a path this plan never named, which the ship chain writes on nearly',
|
|
361
|
+
'every branch. It reports and never gates, so branch on the record',
|
|
362
|
+
'rather than on the exit code, which a shell function wrapping canon',
|
|
363
|
+
'can flatten to zero.',
|
|
364
|
+
'',
|
|
365
|
+
'The range is read at the current directory and the plans and board at',
|
|
366
|
+
'the board root, so a linked worktree reads its own branch against the',
|
|
367
|
+
'shared records. It reads only what is written down, so the plan and',
|
|
368
|
+
'row counts say how much was there to compare against.',
|
|
369
|
+
'',
|
|
370
|
+
'Examples:',
|
|
371
|
+
' canon tasks plan-reach dispatch-answer-gate',
|
|
372
|
+
' canon tasks plan-reach dispatch-answer-gate --base origin/main --json',
|
|
373
|
+
'',
|
|
374
|
+
].join('\n'),
|
|
375
|
+
)
|
|
376
|
+
.action(async (plan: string, opts: ReachCommandOptions) => {
|
|
377
|
+
process.exitCode = await runReach(plan, opts)
|
|
378
|
+
})
|
|
379
|
+
|
|
329
380
|
tasks
|
|
330
381
|
.command('pull-request')
|
|
331
382
|
.description('Record a pull request number on the task a branch closes')
|
|
@@ -961,6 +1012,109 @@ function reportBranch(
|
|
|
961
1012
|
return EXIT_FINDINGS
|
|
962
1013
|
}
|
|
963
1014
|
|
|
1015
|
+
async function runReach(
|
|
1016
|
+
plan: string,
|
|
1017
|
+
opts: ReachCommandOptions,
|
|
1018
|
+
): Promise<number> {
|
|
1019
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1020
|
+
// The range belongs to the checkout the caller stands in, which is the linked
|
|
1021
|
+
// worktree holding the branch on every dispatched run. The board root is the
|
|
1022
|
+
// main worktree, where reading a range measures the trunk against itself.
|
|
1023
|
+
const outcome = await planReach(root, plan, {
|
|
1024
|
+
repo: process.cwd(),
|
|
1025
|
+
ref: opts.base,
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
return reportReach(outcome, opts.json ?? false, root)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Names a holder, the declaration it matched on when that is wider than the
|
|
1033
|
+
* path, and whether a holding plan carries a dispatch row. A plan with no row
|
|
1034
|
+
* is the shape a plan nobody archived takes, which is what sends a reader to
|
|
1035
|
+
* check the holder rather than treating the claim as a live collision.
|
|
1036
|
+
*/
|
|
1037
|
+
function describeHolder(claim: Claim, holder: Holder): string {
|
|
1038
|
+
const under =
|
|
1039
|
+
holder.declaration === claim.path ? '' : `, under ${holder.declaration}`
|
|
1040
|
+
const rowed = holder.source === 'plan' && !holder.rowed ? ', no row' : ''
|
|
1041
|
+
|
|
1042
|
+
return `${holder.name} (${holder.source}${under}${rowed})`
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function describeReachClaim(claim: Claim): string {
|
|
1046
|
+
const holders = claim.holders
|
|
1047
|
+
.map((holder) => describeHolder(claim, holder))
|
|
1048
|
+
.join(' and ')
|
|
1049
|
+
|
|
1050
|
+
return `${claim.path} is held by ${holders}`
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Leads with the claimed list because it is the short one. Over the wave this
|
|
1055
|
+
* verb was filed against, undeclared paths ran 22 of 26 on one branch and 18 of
|
|
1056
|
+
* 25 on another, so a report opening on that list is noise a reader skips past
|
|
1057
|
+
* and the crossing underneath it goes with them.
|
|
1058
|
+
*/
|
|
1059
|
+
function reportReach(
|
|
1060
|
+
outcome: ReachOutcome,
|
|
1061
|
+
emitJson: boolean,
|
|
1062
|
+
root: string,
|
|
1063
|
+
): number {
|
|
1064
|
+
if (!outcome.ok) {
|
|
1065
|
+
if (emitJson) {
|
|
1066
|
+
process.stdout.write(
|
|
1067
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
1068
|
+
)
|
|
1069
|
+
return 1
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
intro('canon tasks plan-reach')
|
|
1073
|
+
logStep('Refused')
|
|
1074
|
+
logError(outcome.message)
|
|
1075
|
+
outro()
|
|
1076
|
+
return 1
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (emitJson) {
|
|
1080
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
1081
|
+
return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
intro('canon tasks plan-reach')
|
|
1085
|
+
|
|
1086
|
+
logStep(outcome.claimed.length === 0 ? 'Uncontested' : 'Claimed')
|
|
1087
|
+
if (outcome.claimed.length === 0) {
|
|
1088
|
+
logInfo('no other live plan or Run now row holds a path this branch wrote')
|
|
1089
|
+
} else {
|
|
1090
|
+
for (const claim of outcome.claimed) logWarn(describeReachClaim(claim))
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
logStep('Undeclared')
|
|
1094
|
+
if (outcome.undeclared.length === 0) {
|
|
1095
|
+
logInfo(
|
|
1096
|
+
`${outcome.plan} declared every one of the ${outcome.changed} paths`,
|
|
1097
|
+
)
|
|
1098
|
+
} else {
|
|
1099
|
+
for (const path of outcome.undeclared) logInfo(path)
|
|
1100
|
+
logInfo(
|
|
1101
|
+
`${outcome.undeclared.length} of ${outcome.changed} path(s) this plan did not name, which the ship chain's own steps account for on most branches`,
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// The counts are the report's own bound. A verb reading what is written down
|
|
1106
|
+
// sees no hand-launched track and no track without a plan, so a clear reading
|
|
1107
|
+
// over nothing compared against would otherwise read as a proof.
|
|
1108
|
+
logStep('Compared against')
|
|
1109
|
+
logInfo(
|
|
1110
|
+
`${outcome.plans} other live plan(s) and ${outcome.rows} Run now row(s)${outcome.board ? '' : ', with no board on disk to read'}`,
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
outro()
|
|
1114
|
+
|
|
1115
|
+
return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
|
|
1116
|
+
}
|
|
1117
|
+
|
|
964
1118
|
function reportValidation(
|
|
965
1119
|
outcome: ValidateOutcome,
|
|
966
1120
|
emitJson: boolean,
|
package/src/git-files.ts
CHANGED
|
@@ -68,6 +68,75 @@ export async function listChangedFiles(
|
|
|
68
68
|
return [...new Set(paths.filter(Boolean))].sort()
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** A file's old and new path across a git-detected rename. */
|
|
72
|
+
export interface RenamePair {
|
|
73
|
+
readonly from: string
|
|
74
|
+
readonly to: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every rename `base..working tree` carries, read through the same similarity
|
|
79
|
+
* detection `git diff -M` applies by default (50%).
|
|
80
|
+
*
|
|
81
|
+
* A move heavy enough on rewritten content to fall under that floor reports
|
|
82
|
+
* as a plain delete and add rather than a rename, which is not a defect this
|
|
83
|
+
* reads around: the diff genuinely does not carry the file a claim citing the
|
|
84
|
+
* old path describes.
|
|
85
|
+
*
|
|
86
|
+
* Returns undefined when git cannot answer, matching `listChangedFiles`.
|
|
87
|
+
*/
|
|
88
|
+
export async function listRenames(
|
|
89
|
+
root: string,
|
|
90
|
+
base: string,
|
|
91
|
+
): Promise<RenamePair[] | undefined> {
|
|
92
|
+
const output = await git(root, ['diff', '--name-status', '-M', base])
|
|
93
|
+
if (output === undefined) return undefined
|
|
94
|
+
|
|
95
|
+
const renames: RenamePair[] = []
|
|
96
|
+
for (const line of output.split('\n')) {
|
|
97
|
+
if (line === '') continue
|
|
98
|
+
const [status, from, to] = line.split('\t')
|
|
99
|
+
if (status === undefined || !status.startsWith('R')) continue
|
|
100
|
+
if (from === undefined || to === undefined) continue
|
|
101
|
+
renames.push({ from, to })
|
|
102
|
+
}
|
|
103
|
+
return renames
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Added, non-comment lines from a unified diff hunk touching `.gitignore`.
|
|
108
|
+
*
|
|
109
|
+
* Shared between a local `git diff` and a patch string GitHub returns inline
|
|
110
|
+
* for the same file, so the two evidence sources read one pattern the same
|
|
111
|
+
* way.
|
|
112
|
+
*/
|
|
113
|
+
export function parseIgnoreAdditions(diffText: string): string[] {
|
|
114
|
+
const patterns: string[] = []
|
|
115
|
+
for (const line of diffText.split('\n')) {
|
|
116
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue
|
|
117
|
+
const pattern = line.slice(1).trim()
|
|
118
|
+
if (pattern === '' || pattern.startsWith('#')) continue
|
|
119
|
+
patterns.push(pattern)
|
|
120
|
+
}
|
|
121
|
+
return patterns
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every pattern a branch added to `.gitignore` since `base`.
|
|
126
|
+
*
|
|
127
|
+
* `-U0` keeps the hunk to changed lines alone, which is all a claim needs.
|
|
128
|
+
*
|
|
129
|
+
* Returns undefined when git cannot answer, matching `listChangedFiles`.
|
|
130
|
+
*/
|
|
131
|
+
export async function listIgnoreAdditions(
|
|
132
|
+
root: string,
|
|
133
|
+
base: string,
|
|
134
|
+
): Promise<string[] | undefined> {
|
|
135
|
+
const output = await git(root, ['diff', '-U0', base, '--', '.gitignore'])
|
|
136
|
+
if (output === undefined) return undefined
|
|
137
|
+
return parseIgnoreAdditions(output)
|
|
138
|
+
}
|
|
139
|
+
|
|
71
140
|
/**
|
|
72
141
|
* Lists the files under `root`: tracked, plus untracked files git does not
|
|
73
142
|
* ignore. The untracked half is what keeps a file added on this branch in scope
|
package/src/pr/bijection.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RenamePair } from '@/git-files'
|
|
1
2
|
import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -53,6 +54,14 @@ export interface BijectionReport {
|
|
|
53
54
|
* branch author.
|
|
54
55
|
*/
|
|
55
56
|
readonly incidental: readonly string[]
|
|
57
|
+
/**
|
|
58
|
+
* True when the rename or `.gitignore`-addition evidence could not be read.
|
|
59
|
+
*
|
|
60
|
+
* A claim `unmet` would otherwise carry is downgraded to `unresolved`
|
|
61
|
+
* instead, since the unread evidence might have credited it and this
|
|
62
|
+
* comparison has no way to tell a stale claim from one it could not check.
|
|
63
|
+
*/
|
|
64
|
+
readonly evidenceUnread: boolean
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export type Bijection =
|
|
@@ -66,6 +75,20 @@ export interface BijectionInput {
|
|
|
66
75
|
readonly roots: ReadonlySet<string>
|
|
67
76
|
readonly head?: string
|
|
68
77
|
readonly title?: string
|
|
78
|
+
/** A rename's source path credits a claim naming it, without ever counting as a changed file. */
|
|
79
|
+
readonly renames?: readonly RenamePair[]
|
|
80
|
+
/** A pattern newly added to `.gitignore`, crediting a claim naming it as the `.gitignore` change it is. */
|
|
81
|
+
readonly ignoreAdditions?: readonly string[]
|
|
82
|
+
/**
|
|
83
|
+
* True when the caller could not read the rename or ignore-addition
|
|
84
|
+
* evidence, rather than reading it and finding neither.
|
|
85
|
+
*
|
|
86
|
+
* The two states produce the same empty `renames`/`ignoreAdditions`, so
|
|
87
|
+
* this is the only way `compareKeyChanges` can tell a claim that is
|
|
88
|
+
* genuinely stale from one the evidence might have credited had the read
|
|
89
|
+
* succeeded.
|
|
90
|
+
*/
|
|
91
|
+
readonly evidenceUnread?: boolean
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
/**
|
|
@@ -94,6 +117,28 @@ function owesNoBullet(path: string): boolean {
|
|
|
94
117
|
return INCIDENTAL.some((pattern) => pattern.test(path))
|
|
95
118
|
}
|
|
96
119
|
|
|
120
|
+
/** Drops a directory claim's trailing slash so it compares against a raw `.gitignore` pattern. */
|
|
121
|
+
function withoutTrailingSlash(path: string): string {
|
|
122
|
+
return path.endsWith('/') ? path.slice(0, -1) : path
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a claim names a pattern newly added to `.gitignore`.
|
|
127
|
+
*
|
|
128
|
+
* Compared with the trailing slash both sides may or may not carry stripped,
|
|
129
|
+
* since a bullet spells a folder pattern the way `.gitignore` itself does
|
|
130
|
+
* (`web/screenshots/`) and the diff line carries the identical text.
|
|
131
|
+
*/
|
|
132
|
+
function coversIgnoreAddition(
|
|
133
|
+
claim: PathClaim,
|
|
134
|
+
ignoreAdditions: readonly string[],
|
|
135
|
+
): boolean {
|
|
136
|
+
const named = withoutTrailingSlash(claim.path)
|
|
137
|
+
return ignoreAdditions.some(
|
|
138
|
+
(pattern) => withoutTrailingSlash(pattern) === named,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
/**
|
|
98
143
|
* Whether one changed path is the file, or a file under the folder, a claim
|
|
99
144
|
* names.
|
|
@@ -147,6 +192,10 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
147
192
|
return { kind: 'refused', reason: 'no-section' }
|
|
148
193
|
if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
|
|
149
194
|
|
|
195
|
+
const renames = input.renames ?? []
|
|
196
|
+
const ignoreAdditions = input.ignoreAdditions ?? []
|
|
197
|
+
const evidenceUnread = input.evidenceUnread ?? false
|
|
198
|
+
|
|
150
199
|
const unmet: PathClaim[] = []
|
|
151
200
|
const unresolved: PathClaim[] = []
|
|
152
201
|
const named = new Set<string>()
|
|
@@ -155,7 +204,15 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
155
204
|
const hits = input.changed.filter((path) => covers(claim, path))
|
|
156
205
|
for (const path of hits) named.add(path)
|
|
157
206
|
if (hits.length > 0) continue
|
|
158
|
-
|
|
207
|
+
|
|
208
|
+
if (renames.some((rename) => covers(claim, rename.from))) continue
|
|
209
|
+
|
|
210
|
+
if (coversIgnoreAddition(claim, ignoreAdditions)) {
|
|
211
|
+
named.add('.gitignore')
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (claim.anchored && claim.leading && !evidenceUnread) unmet.push(claim)
|
|
159
216
|
else unresolved.push(claim)
|
|
160
217
|
}
|
|
161
218
|
|
|
@@ -170,6 +227,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
170
227
|
unresolved,
|
|
171
228
|
unnamed: reached.filter((path) => !owesNoBullet(path)),
|
|
172
229
|
incidental: reached.filter(owesNoBullet),
|
|
230
|
+
evidenceUnread,
|
|
173
231
|
}
|
|
174
232
|
}
|
|
175
233
|
|