@erclx/canon 4.118.0 → 4.119.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/canon-rollout/SKILL.md +2 -2
- package/claude/skills/design-extract/SKILL.md +1 -1
- package/claude/skills/docs-fold/SKILL.md +2 -2
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +5 -3
- package/claude/skills/memory-capture/SKILL.md +3 -3
- package/claude/skills/memory-review/SKILL.md +3 -3
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +10 -41
- package/claude/skills/seed-sync/SKILL.md +1 -1
- package/claude/skills/session-compact/SKILL.md +1 -1
- package/claude/skills/session-map/SKILL.md +1 -1
- package/claude/skills/session-relay/REQUIREMENT.md +7 -1
- package/claude/skills/session-relay/SKILL.md +4 -7
- package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
- package/claude/skills/session-worktree/SKILL.md +7 -1
- package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
- package/claude/skills/task-board/SKILL.md +4 -3
- package/claude/skills/teach-workspace/SKILL.md +4 -4
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/claude/skills/ux-walkthrough/references/relay.md +1 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/install-and-sync.md +11 -6
- package/docs/agents/tasks.md +4 -4
- package/docs/target-projects.md +1 -1
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/renames.toml +13 -0
- package/governance/rules/core/085-worktrees.md +2 -8
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +10 -0
- package/src/commands/sync.ts +11 -3
- package/src/commands/tasks.ts +9 -8
- package/src/design/base.css +19 -19
- package/src/design/board.ts +18 -7
- package/src/design/neutral.ts +127 -0
- package/src/design/regen.ts +6 -4
- package/src/design/tokens.ts +5 -2
- package/src/gate/stages.ts +4 -4
- package/src/gov/adapter.ts +51 -1
- package/src/gov/install.ts +13 -3
- package/src/gov/renames.ts +76 -0
- package/src/gov/stacks.ts +5 -1
- package/src/sync/check.ts +19 -4
- package/src/sync/engine.ts +67 -3
- package/src/tasks/archive.ts +28 -7
- package/src/tasks/record.ts +16 -9
- package/src/tasks/validate.ts +3 -1
- package/standards/tasks.md +5 -3
- package/governance/rules/core/091-channel.md +0 -13
package/src/gov/adapter.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
|
-
import { canonRulesDir } from '@/gov/install'
|
|
3
|
+
import { canonRulesDir, ruleSubdir } from '@/gov/install'
|
|
4
|
+
import { loadRenames, resolveSuccessor } from '@/gov/renames'
|
|
4
5
|
import { resolveMissingRules } from '@/gov/stacks'
|
|
5
6
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
6
7
|
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
@@ -49,11 +50,14 @@ export function indexSourceRules(root: string): Map<string, string> {
|
|
|
49
50
|
* `.claude/rules/canon/` leaves outside the walk, so everything the walk does
|
|
50
51
|
* reach is the toolkit's. A rule there with no source is one the toolkit
|
|
51
52
|
* retired or renamed, and the sync deletes it rather than leaving it loaded.
|
|
53
|
+
* A rename `governance/renames.toml` declares also installs the rule under its
|
|
54
|
+
* new name.
|
|
52
55
|
* A `projectSubdir` would compute a stale `canon/project/` destination, which
|
|
53
56
|
* is wrong rather than merely redundant.
|
|
54
57
|
*/
|
|
55
58
|
export function createGovAdapter(root: string): SyncAdapter {
|
|
56
59
|
const index = indexSourceRules(root)
|
|
60
|
+
const renames = loadRenames(root)
|
|
57
61
|
|
|
58
62
|
return {
|
|
59
63
|
banner: 'canon gov sync',
|
|
@@ -67,10 +71,56 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
67
71
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
68
72
|
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
69
73
|
ownsInstalledRoot: true,
|
|
74
|
+
locateSuccessor: (file: InstalledFile, target: string) => {
|
|
75
|
+
const successor = resolveSuccessor(
|
|
76
|
+
renames,
|
|
77
|
+
index,
|
|
78
|
+
basename(file.path, '.md'),
|
|
79
|
+
)
|
|
80
|
+
if (successor === undefined) return undefined
|
|
81
|
+
|
|
82
|
+
const source = index.get(successor)
|
|
83
|
+
if (source === undefined) return undefined
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
source,
|
|
87
|
+
dest:
|
|
88
|
+
heldRulePath(target, successor) ??
|
|
89
|
+
join(
|
|
90
|
+
canonRulesDir(target),
|
|
91
|
+
ruleSubdir(source, rulesSourceDir(root)),
|
|
92
|
+
`${successor}.md`,
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
},
|
|
70
96
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
71
97
|
}
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Where the target already holds a rule, in whichever band folder. A
|
|
102
|
+
* successor held outside the source's band stays where it is, the same way
|
|
103
|
+
* `locateSource` syncs a moved rule in place, rather than gaining a second copy.
|
|
104
|
+
*
|
|
105
|
+
* Holding the predecessor is the whole entitlement test, so no stamped chain
|
|
106
|
+
* is read here. That covers a rule `--add` layered on and a target installed
|
|
107
|
+
* before chains were stamped alike.
|
|
108
|
+
*/
|
|
109
|
+
function heldRulePath(target: string, rule: string): string | undefined {
|
|
110
|
+
const dir = canonRulesDir(target)
|
|
111
|
+
if (!existsSync(dir)) return undefined
|
|
112
|
+
|
|
113
|
+
for (const rel of new Bun.Glob(`**/${rule}.md`).scanSync({
|
|
114
|
+
cwd: dir,
|
|
115
|
+
onlyFiles: true,
|
|
116
|
+
dot: true,
|
|
117
|
+
})) {
|
|
118
|
+
return join(dir, rel)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
74
124
|
/**
|
|
75
125
|
* Rules the target's recorded chain entitles it to and its tree does not
|
|
76
126
|
* hold. Reports as `notice` text through the same shape `collectRetired`
|
package/src/gov/install.ts
CHANGED
|
@@ -38,8 +38,14 @@ export function installedInternalRulesDir(target: string): string {
|
|
|
38
38
|
* Rule names a target already holds, read off the installed `canon/` tree by
|
|
39
39
|
* basename rather than off a recorded stack, since a target may hold rules
|
|
40
40
|
* `--add` layered on that no stack lists.
|
|
41
|
+
*
|
|
42
|
+
* `successorOf` adds the name a held rule was renamed to, since the next sync
|
|
43
|
+
* installs it and a reader treating it as missing would name it twice.
|
|
41
44
|
*/
|
|
42
|
-
export function installedRuleNames(
|
|
45
|
+
export function installedRuleNames(
|
|
46
|
+
target: string,
|
|
47
|
+
successorOf?: (name: string) => string | undefined,
|
|
48
|
+
): Set<string> {
|
|
43
49
|
const dir = canonRulesDir(target)
|
|
44
50
|
const names = new Set<string>()
|
|
45
51
|
if (!existsSync(dir)) return names
|
|
@@ -49,7 +55,11 @@ export function installedRuleNames(target: string): Set<string> {
|
|
|
49
55
|
onlyFiles: true,
|
|
50
56
|
dot: true,
|
|
51
57
|
})) {
|
|
52
|
-
|
|
58
|
+
const name = basename(rel, '.md')
|
|
59
|
+
names.add(name)
|
|
60
|
+
|
|
61
|
+
const successor = successorOf?.(name)
|
|
62
|
+
if (successor !== undefined) names.add(successor)
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
return names
|
|
@@ -84,7 +94,7 @@ export function listRuleSourcePaths(root: string): string[] {
|
|
|
84
94
|
* in two subdirectories resolves deterministically rather than by whichever
|
|
85
95
|
* entry the filesystem yielded first.
|
|
86
96
|
*/
|
|
87
|
-
function indexRuleSources(root: string): Map<string, string> {
|
|
97
|
+
export function indexRuleSources(root: string): Map<string, string> {
|
|
88
98
|
const rulesRoot = rulesSourceDir(root)
|
|
89
99
|
const byName = new Map<string, string>()
|
|
90
100
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { indexRuleSources } from '@/gov/install'
|
|
4
|
+
|
|
5
|
+
/** Old rule basename to the basename the same rule ships under now. */
|
|
6
|
+
export type Renames = ReadonlyMap<string, string>
|
|
7
|
+
|
|
8
|
+
function renamesPath(root: string): string {
|
|
9
|
+
return join(root, 'governance', 'renames.toml')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads the shipped rename ledger. A toolkit without one declares no renames,
|
|
14
|
+
* which leaves every sourceless rule on the retire path it took before the
|
|
15
|
+
* ledger existed.
|
|
16
|
+
*/
|
|
17
|
+
export function loadRenames(root: string): Renames {
|
|
18
|
+
const path = renamesPath(root)
|
|
19
|
+
if (!existsSync(path)) return new Map()
|
|
20
|
+
|
|
21
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
22
|
+
string,
|
|
23
|
+
unknown
|
|
24
|
+
>
|
|
25
|
+
const table = parsed.renamed
|
|
26
|
+
if (typeof table !== 'object' || table === null) return new Map()
|
|
27
|
+
|
|
28
|
+
return new Map(
|
|
29
|
+
Object.entries(table).filter(
|
|
30
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Follows a declared rename to the name the toolkit ships the rule under now,
|
|
37
|
+
* through every later rename of that name. The ledger is never pruned, so a
|
|
38
|
+
* target several releases behind still reaches the current name.
|
|
39
|
+
*
|
|
40
|
+
* Returns nothing when the chain ends on a name with no source, or loops,
|
|
41
|
+
* since installing a guess is worse than the retire the caller falls back to.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveSuccessor(
|
|
44
|
+
renames: Renames,
|
|
45
|
+
index: ReadonlyMap<string, string>,
|
|
46
|
+
name: string,
|
|
47
|
+
): string | undefined {
|
|
48
|
+
const visited = new Set([name])
|
|
49
|
+
let current = renames.get(name)
|
|
50
|
+
|
|
51
|
+
while (current !== undefined) {
|
|
52
|
+
if (visited.has(current)) return undefined
|
|
53
|
+
visited.add(current)
|
|
54
|
+
|
|
55
|
+
const next = renames.get(current)
|
|
56
|
+
if (next === undefined) break
|
|
57
|
+
current = next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (current === undefined || !index.has(current)) return undefined
|
|
61
|
+
return current
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Binds the ledger and the source tree at `root` once, for a caller asking
|
|
66
|
+
* about many held names.
|
|
67
|
+
*/
|
|
68
|
+
export function successorResolver(
|
|
69
|
+
root: string,
|
|
70
|
+
): (name: string) => string | undefined {
|
|
71
|
+
const renames = loadRenames(root)
|
|
72
|
+
if (renames.size === 0) return () => undefined
|
|
73
|
+
|
|
74
|
+
const index = indexRuleSources(root)
|
|
75
|
+
return (name) => resolveSuccessor(renames, index, name)
|
|
76
|
+
}
|
package/src/gov/stacks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type RuleSource,
|
|
8
8
|
rulesSourceDir,
|
|
9
9
|
} from '@/gov/install'
|
|
10
|
+
import { successorResolver } from '@/gov/renames'
|
|
10
11
|
|
|
11
12
|
export interface GovStack {
|
|
12
13
|
readonly name: string
|
|
@@ -163,6 +164,9 @@ export function unreferencedRules(root: string): string[] {
|
|
|
163
164
|
* so reading its leaf entry is enough; no second walk resolves the chain
|
|
164
165
|
* itself. A stack the toolkit no longer ships resolves to nothing rather than
|
|
165
166
|
* throwing, the same way `readNewRules`'s band fallback already treats it.
|
|
167
|
+
*
|
|
168
|
+
* A held rule's declared successor counts as held, since the sync that
|
|
169
|
+
* renames the old file installs it.
|
|
166
170
|
*/
|
|
167
171
|
export function resolveMissingRules(
|
|
168
172
|
root: string,
|
|
@@ -176,7 +180,7 @@ export function resolveMissingRules(
|
|
|
176
180
|
if (!resolution.ok) return []
|
|
177
181
|
|
|
178
182
|
const { found } = lookupRules(root, resolution.rules)
|
|
179
|
-
const held = installedRuleNames(target)
|
|
183
|
+
const held = installedRuleNames(target, successorResolver(root))
|
|
180
184
|
|
|
181
185
|
return found
|
|
182
186
|
.filter((source) => !held.has(source.rule))
|
package/src/sync/check.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { execa } from 'execa'
|
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
5
|
import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
|
|
6
6
|
import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
|
|
7
|
+
import { successorResolver } from '@/gov/renames'
|
|
7
8
|
import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
|
|
8
9
|
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
9
10
|
import {
|
|
@@ -80,6 +81,7 @@ export interface StateCounts {
|
|
|
80
81
|
readonly stranded: number
|
|
81
82
|
readonly missing: number
|
|
82
83
|
readonly retired: number
|
|
84
|
+
readonly renamed: number
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export interface DomainReport {
|
|
@@ -316,6 +318,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
316
318
|
stranded: count(entries, 'stranded'),
|
|
317
319
|
missing: count(entries, 'missing'),
|
|
318
320
|
retired: count(entries, 'retired'),
|
|
321
|
+
renamed: count(entries, 'renamed'),
|
|
319
322
|
}
|
|
320
323
|
}
|
|
321
324
|
|
|
@@ -347,6 +350,8 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
347
350
|
*
|
|
348
351
|
* `retired` counts, since a sync deletes it mechanically. A target's CI goes
|
|
349
352
|
* red the day a release retires a rule it holds, and one sync clears it.
|
|
353
|
+
* `renamed` counts on the same grounds, since the sync that moves it is
|
|
354
|
+
* equally mechanical.
|
|
350
355
|
*/
|
|
351
356
|
export function hasDrift(report: CheckReport): boolean {
|
|
352
357
|
if (report.unmigrated.length > 0) return true
|
|
@@ -357,7 +362,8 @@ export function hasDrift(report: CheckReport): boolean {
|
|
|
357
362
|
domain.counts.customized +
|
|
358
363
|
domain.counts.drifted +
|
|
359
364
|
domain.counts.stranded +
|
|
360
|
-
domain.counts.retired
|
|
365
|
+
domain.counts.retired +
|
|
366
|
+
domain.counts.renamed >
|
|
361
367
|
0,
|
|
362
368
|
)
|
|
363
369
|
}
|
|
@@ -488,8 +494,14 @@ export function parseNewSkills(paths: string): string[] {
|
|
|
488
494
|
* chain a target consumed survives nowhere else. `installRules` copies each
|
|
489
495
|
* rule into the subdirectory it was authored in, which is what makes the band
|
|
490
496
|
* folders legible as evidence.
|
|
497
|
+
*
|
|
498
|
+
* `successorOf` adds the name a held rule was renamed to, which the next sync
|
|
499
|
+
* installs, so the fallback does not report it as new.
|
|
491
500
|
*/
|
|
492
|
-
export function readInstalledRules(
|
|
501
|
+
export function readInstalledRules(
|
|
502
|
+
target: string,
|
|
503
|
+
successorOf?: (name: string) => string | undefined,
|
|
504
|
+
): {
|
|
493
505
|
held: Set<string>
|
|
494
506
|
bands: Set<string>
|
|
495
507
|
} {
|
|
@@ -506,7 +518,10 @@ export function readInstalledRules(target: string): {
|
|
|
506
518
|
const posix = rel.split(sep).join('/')
|
|
507
519
|
const boundary = posix.indexOf('/')
|
|
508
520
|
|
|
509
|
-
|
|
521
|
+
const name = basename(posix, '.md')
|
|
522
|
+
held.add(name)
|
|
523
|
+
const successor = successorOf?.(name)
|
|
524
|
+
if (successor !== undefined) held.add(successor)
|
|
510
525
|
if (boundary > 0) bands.add(posix.slice(0, boundary))
|
|
511
526
|
}
|
|
512
527
|
|
|
@@ -640,7 +655,7 @@ export async function readNewRules(
|
|
|
640
655
|
'governance/rules/',
|
|
641
656
|
])
|
|
642
657
|
|
|
643
|
-
const { held, bands } = readInstalledRules(target)
|
|
658
|
+
const { held, bands } = readInstalledRules(target, successorResolver(root))
|
|
644
659
|
return selectNewRules(paths, held, bands.union(baseBands(root)))
|
|
645
660
|
}
|
|
646
661
|
|
package/src/sync/engine.ts
CHANGED
|
@@ -70,6 +70,11 @@ export type SyncChange =
|
|
|
70
70
|
* sync deletes it. A stamped file the toolkit no longer installs to is
|
|
71
71
|
* stranded, which is a relocation waiting on a decision.
|
|
72
72
|
*
|
|
73
|
+
* `renamed` is the fourth sourceless state and the only one that installs.
|
|
74
|
+
* It is a retired file whose rule the adapter declares still ships under a
|
|
75
|
+
* new name, so the sync deletes the old file and copies the rule to the new
|
|
76
|
+
* one.
|
|
77
|
+
*
|
|
73
78
|
* `missing` is the one state the walk cannot produce on its own, since the
|
|
74
79
|
* walk only iterates files that exist. It comes from `collectMissing`
|
|
75
80
|
* instead, an adapter naming an entitled file the target does not hold.
|
|
@@ -81,6 +86,7 @@ export type EntryState =
|
|
|
81
86
|
| 'drifted'
|
|
82
87
|
| 'orphaned'
|
|
83
88
|
| 'retired'
|
|
89
|
+
| 'renamed'
|
|
84
90
|
| 'stranded'
|
|
85
91
|
| 'missing'
|
|
86
92
|
|
|
@@ -165,9 +171,20 @@ export interface SyncAdapter {
|
|
|
165
171
|
* no source is a leftover of a rule the toolkit retired or renamed rather
|
|
166
172
|
* than something a project wrote. Setting it turns that file into a
|
|
167
173
|
* `retired` entry and queues its delete, edited or not, whatever the stamp
|
|
168
|
-
* holds
|
|
174
|
+
* holds, or a `renamed` one when `locateSuccessor` names where it ships
|
|
175
|
+
* now. Unset, the file stays `orphaned` and untouched.
|
|
169
176
|
*/
|
|
170
177
|
readonly ownsInstalledRoot?: boolean
|
|
178
|
+
/**
|
|
179
|
+
* Where a sourceless file's rule ships now, when the toolkit declares it
|
|
180
|
+
* renamed rather than retired. Reached only on a root the adapter owns and
|
|
181
|
+
* never past the newer-install guard. Unset, or returning nothing, leaves
|
|
182
|
+
* the file `retired`.
|
|
183
|
+
*/
|
|
184
|
+
locateSuccessor?(
|
|
185
|
+
file: InstalledFile,
|
|
186
|
+
target: string,
|
|
187
|
+
): { readonly source: string; readonly dest: string } | undefined
|
|
171
188
|
/** Defaults to applying. */
|
|
172
189
|
readonly nonInteractive?: NonInteractivePolicy
|
|
173
190
|
/** Runs on a completed sync, including one with no changes. */
|
|
@@ -224,6 +241,7 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
224
241
|
const hashes = stampedHashes(stamp, adapter.stamp?.domain)
|
|
225
242
|
const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
|
|
226
243
|
const walked = new Set<string>()
|
|
244
|
+
const pendingCopies = new Set<string>()
|
|
227
245
|
|
|
228
246
|
for (const file of listInstalled(
|
|
229
247
|
adapter.installedRoot(target),
|
|
@@ -243,16 +261,44 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
243
261
|
if (source === undefined || !existsSync(source)) {
|
|
244
262
|
if (adapter.ownsInstalledRoot !== true) {
|
|
245
263
|
entries.push(misplacedOrphan(adapter, target, file))
|
|
246
|
-
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (newerInstall !== undefined) {
|
|
247
268
|
entries.push(heldForNewerInstall(file, newerInstall))
|
|
248
|
-
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const successor = adapter.locateSuccessor?.(file, target)
|
|
273
|
+
if (successor === undefined) {
|
|
249
274
|
entries.push({
|
|
250
275
|
state: 'retired',
|
|
251
276
|
rel: file.rel,
|
|
252
277
|
notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
|
|
253
278
|
})
|
|
254
279
|
changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
|
|
280
|
+
continue
|
|
255
281
|
}
|
|
282
|
+
|
|
283
|
+
const destRel = relative(target, successor.dest)
|
|
284
|
+
entries.push({
|
|
285
|
+
state: 'renamed',
|
|
286
|
+
rel: file.rel,
|
|
287
|
+
notice: renamedNotice(file, destRel, attribute(hashes, file)),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// A second predecessor of one successor, or a successor the target
|
|
291
|
+
// already holds, still loses its old file but must not overwrite.
|
|
292
|
+
if (!existsSync(successor.dest) && !pendingCopies.has(successor.dest)) {
|
|
293
|
+
pendingCopies.add(successor.dest)
|
|
294
|
+
changes.push({
|
|
295
|
+
kind: 'copy',
|
|
296
|
+
source: successor.source,
|
|
297
|
+
dest: successor.dest,
|
|
298
|
+
rel: destRel,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
|
|
256
302
|
continue
|
|
257
303
|
}
|
|
258
304
|
|
|
@@ -470,6 +516,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
470
516
|
logWarn(`${entry.rel} (locally customized)`)
|
|
471
517
|
else if (entry.state === 'retired')
|
|
472
518
|
logWarn(entry.notice ?? `${entry.rel} (no longer shipped by the toolkit)`)
|
|
519
|
+
else if (entry.state === 'renamed')
|
|
520
|
+
logWarn(entry.notice ?? `${entry.rel} (renamed by the toolkit)`)
|
|
473
521
|
else if (entry.state === 'stranded')
|
|
474
522
|
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
475
523
|
else if (entry.state === 'missing')
|
|
@@ -617,6 +665,22 @@ function newerInstallingVersion(
|
|
|
617
665
|
return { stamped, running }
|
|
618
666
|
}
|
|
619
667
|
|
|
668
|
+
/**
|
|
669
|
+
* The new name gets the toolkit's source rather than the old file's content,
|
|
670
|
+
* so a local edit is dropped the way a retire drops one. With no stamp the
|
|
671
|
+
* edit cannot be proven either way, so the line hedges rather than asserts.
|
|
672
|
+
*/
|
|
673
|
+
function renamedNotice(
|
|
674
|
+
file: InstalledFile,
|
|
675
|
+
destRel: string,
|
|
676
|
+
state: EntryState,
|
|
677
|
+
): string {
|
|
678
|
+
const base = `${file.rel} (renamed to ${destRel} by the toolkit, moving`
|
|
679
|
+
if (state === 'customized') return `${base}. Local edits were not carried.)`
|
|
680
|
+
if (state === 'drifted') return `${base}. Any local edits were not carried.)`
|
|
681
|
+
return `${base})`
|
|
682
|
+
}
|
|
683
|
+
|
|
620
684
|
function heldForNewerInstall(
|
|
621
685
|
file: InstalledFile,
|
|
622
686
|
versions: { readonly stamped: string; readonly running: string },
|
package/src/tasks/archive.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const ARCHIVE_REFUSALS = [
|
|
|
46
46
|
'ambiguous',
|
|
47
47
|
'no-outcomes',
|
|
48
48
|
'open-outcomes',
|
|
49
|
+
'earlier-slice',
|
|
49
50
|
'bad-input',
|
|
50
51
|
] as const
|
|
51
52
|
|
|
@@ -235,12 +236,19 @@ export function readOutcomes(text: string): TaskOutcomes {
|
|
|
235
236
|
|
|
236
237
|
/**
|
|
237
238
|
* Reads the `Pull request:` line `git-pr` writes when a pull request opens.
|
|
238
|
-
*
|
|
239
|
-
* a full URL would write the remote into a gitignored file.
|
|
239
|
+
* Each number is a bare `#NNN` the way `Issue:` is, since neither is a path and
|
|
240
|
+
* a full URL would write the remote into a gitignored file. A task shipped in
|
|
241
|
+
* slices lists one per slice, oldest first, so the line reads as a list. A line
|
|
242
|
+
* carrying anything besides that list reads as naming none, rather than as the
|
|
243
|
+
* numbers that happened to parse.
|
|
240
244
|
*/
|
|
241
|
-
export function readPullRequest(text: string): number
|
|
242
|
-
const match = /^Pull request
|
|
243
|
-
|
|
245
|
+
export function readPullRequest(text: string): readonly number[] {
|
|
246
|
+
const match = /^Pull request:[ \t]*(#\d+(?:[ \t]*,[ \t]*#\d+)*)[ \t]*$/m.exec(
|
|
247
|
+
text,
|
|
248
|
+
)
|
|
249
|
+
if (!match) return []
|
|
250
|
+
|
|
251
|
+
return match[1].split(',').map((entry) => Number(entry.trim().slice(1)))
|
|
244
252
|
}
|
|
245
253
|
|
|
246
254
|
/**
|
|
@@ -628,11 +636,13 @@ async function matchByPullRequest(
|
|
|
628
636
|
const read = await Promise.all(
|
|
629
637
|
stems.map(async (stem) => ({
|
|
630
638
|
stem,
|
|
631
|
-
|
|
639
|
+
numbers: readPullRequest(await readFile(join(dir, `${stem}.md`), 'utf8')),
|
|
632
640
|
})),
|
|
633
641
|
)
|
|
634
642
|
|
|
635
|
-
return read
|
|
643
|
+
return read
|
|
644
|
+
.filter((entry) => entry.numbers.includes(number))
|
|
645
|
+
.map(({ stem }) => stem)
|
|
636
646
|
}
|
|
637
647
|
|
|
638
648
|
/**
|
|
@@ -748,6 +758,17 @@ export async function archiveTask(
|
|
|
748
758
|
const from = join(dir, `${stem}.md`)
|
|
749
759
|
const text = await readFile(from, 'utf8')
|
|
750
760
|
|
|
761
|
+
// `docs-fold` ticks outcomes at ship time, before the merge, so an earlier
|
|
762
|
+
// slice merging after the last slice shipped would find every box ticked
|
|
763
|
+
// while the last slice is still open. Only the last number closes the task.
|
|
764
|
+
const last = readPullRequest(text).at(-1)
|
|
765
|
+
if (selector.kind === 'pull-request' && last !== selector.number) {
|
|
766
|
+
return refuse(
|
|
767
|
+
'earlier-slice',
|
|
768
|
+
`${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
|
|
751
772
|
const { open, closed, cut } = readOutcomes(text)
|
|
752
773
|
|
|
753
774
|
if (open.length > 0) {
|
package/src/tasks/record.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
OUTCOME_PATTERN,
|
|
11
11
|
planLine,
|
|
12
12
|
readPlanTarget,
|
|
13
|
+
readPullRequest,
|
|
13
14
|
tasksDir,
|
|
14
15
|
} from '@/tasks/archive'
|
|
15
16
|
|
|
@@ -49,7 +50,7 @@ export type RecordSelector =
|
|
|
49
50
|
| { readonly kind: 'stem'; readonly stem: string }
|
|
50
51
|
| { readonly kind: 'plan'; readonly plan: string }
|
|
51
52
|
|
|
52
|
-
export type LineAction = 'added' | 'corrected' | 'unchanged'
|
|
53
|
+
export type LineAction = 'added' | 'appended' | 'corrected' | 'unchanged'
|
|
53
54
|
|
|
54
55
|
export interface PullRequestRecorded {
|
|
55
56
|
readonly ok: true
|
|
@@ -101,9 +102,10 @@ function planKey(reference: string): string {
|
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
104
|
* Places the `Pull request:` line under the origin lines the task already
|
|
104
|
-
* carries
|
|
105
|
-
*
|
|
106
|
-
*
|
|
105
|
+
* carries. A task shipped in slices lists every pull request, so a new number
|
|
106
|
+
* appends to an existing line rather than replacing it, and a line that does
|
|
107
|
+
* not read as a list is replaced whole. A task with no origin line takes it
|
|
108
|
+
* under the H1, which is the only other anchor the board format guarantees.
|
|
107
109
|
*/
|
|
108
110
|
export function writePullRequestLine(
|
|
109
111
|
text: string,
|
|
@@ -114,9 +116,14 @@ export function writePullRequestLine(
|
|
|
114
116
|
const existing = lines.findIndex((entry) => entry.startsWith('Pull request:'))
|
|
115
117
|
|
|
116
118
|
if (existing !== -1) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
const listed = readPullRequest(lines[existing])
|
|
120
|
+
if (listed.includes(number)) return { text, action: 'unchanged' }
|
|
121
|
+
if (listed.length === 0) {
|
|
122
|
+
lines[existing] = line
|
|
123
|
+
return { text: lines.join('\n'), action: 'corrected' }
|
|
124
|
+
}
|
|
125
|
+
lines[existing] = `${lines[existing].trimEnd()}, #${number}`
|
|
126
|
+
return { text: lines.join('\n'), action: 'appended' }
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
const origin = lastOriginLine(lines)
|
|
@@ -143,8 +150,8 @@ function lastOriginLine(lines: readonly string[]): number | undefined {
|
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
/**
|
|
146
|
-
* Places the `Plan:` line right after the H1,
|
|
147
|
-
*
|
|
153
|
+
* Places the `Plan:` line right after the H1, and corrects the target in place
|
|
154
|
+
* when the line exists, since a task runs under one plan. `Plan:` is the first
|
|
148
155
|
* origin line a task carries, so it anchors on the heading itself rather than
|
|
149
156
|
* on the last origin line above it.
|
|
150
157
|
*/
|
package/src/tasks/validate.ts
CHANGED
|
@@ -1071,7 +1071,9 @@ async function checkCitedTask(
|
|
|
1071
1071
|
const { open, closed } = readOutcomes(text)
|
|
1072
1072
|
if (open.length > 0 || closed.length === 0) return nothing()
|
|
1073
1073
|
|
|
1074
|
-
|
|
1074
|
+
// The last number is the newest slice, and an earlier one closed without
|
|
1075
|
+
// merging never lands, so only the last says whether the work reached trunk.
|
|
1076
|
+
const pullRequest = readPullRequest(text).at(-1)
|
|
1075
1077
|
if (pullRequest === undefined) {
|
|
1076
1078
|
return untestedRow(
|
|
1077
1079
|
group,
|
package/standards/tasks.md
CHANGED
|
@@ -173,6 +173,8 @@ Add no third heading. Status stays inline on an outcome rather than becoming an
|
|
|
173
173
|
|
|
174
174
|
Size the outcomes so one pull request closes all of them. A task whose outcomes span two pull requests ships the first half and leaves the rest open, with nothing recording which outcomes the merged work covered, so the board reads as in-progress work that no branch is carrying. Split the task before handing it off rather than after. This is what `## Archiving` below depends on, since a task closes whole or not at all.
|
|
175
175
|
|
|
176
|
+
A sliced task is the one allowed exception: a task the operator decided to ship across several pull requests on purpose, one slice each. Its `Pull request:` line lists every slice, so the board records which merged work covered which outcomes, and it closes whole when the last slice's merge finds every outcome ticked.
|
|
177
|
+
|
|
176
178
|
Prefix the H1 with the `vX.Y:` phase label, then a short title whose form depends on the task type:
|
|
177
179
|
|
|
178
180
|
- Feature: an outcome describing what the user can now do
|
|
@@ -227,15 +229,15 @@ A project that archived plans before the folder nested under `.canon/plans/` hol
|
|
|
227
229
|
|
|
228
230
|
One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
|
|
229
231
|
|
|
230
|
-
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line
|
|
232
|
+
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line. `plan-feature` calls it right after the plan file lands, when an existing task names the feature, so the line is a mechanical write rather than hand-edited markdown.
|
|
231
233
|
|
|
232
234
|
`Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
|
|
233
235
|
|
|
234
236
|
`Intake:` points at `../intake/<slug>/`, the folder an intake pass fills. Use it rather than `Groundwork:`, because a groundwork track measures one question in depth while an intake dispositions many across a tree, and one key covering both loses which kind of pass produced the task. The line names the folder rather than an item inside it. A task routinely promotes several items at once, so an anchored line would name one and drop the rest, and the item numbers belong in that task's `## Findings`.
|
|
235
237
|
|
|
236
|
-
`Pull request:` records which pull
|
|
238
|
+
`Pull request:` records which pull requests carry the task's work, each as a bare `#NNN` the way `Issue:` does. It lists every pull request that shipped part of the task, oldest first and separated by commas, as in `Pull request: #NNN, #NNN`, and a task shipped whole lists one. It is not an origin, so a task without one is well-formed. `git-pr` writes it when a pull request opens, which is the one step that always runs whether the chain drives it or a person does, and appends to the line rather than replacing it.
|
|
237
239
|
|
|
238
|
-
The line is what lets a merge close its own task. Every merge on `main` is a squash carrying the number in its subject, so the number survives where a branch name does not, and `canon tasks archive --pull-request <n>`
|
|
240
|
+
The line is what lets a merge close its own task. Every merge on `main` is a squash carrying the number in its subject, so the number survives where a branch name does not, and `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last, since outcomes are ticked at ship time and an earlier slice can merge after the last one shipped. Without the line the board can only be swept blind, and a blind sweep cannot tell a shipped task from an abandoned one. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving.
|
|
239
241
|
|
|
240
242
|
## What goes in
|
|
241
243
|
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: The addressing and timing a session holding neither role-worker nor role-planner owes when it must message whoever dispatched it
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# Channel standards
|
|
6
|
-
|
|
7
|
-
## A session holding neither role
|
|
8
|
-
|
|
9
|
-
- Resolve the addressee at send time through `canon sessions list --json`, keyed on the id or branch the dispatcher named at launch, never by name prefix. A name is derived from what a session turns out to be doing and goes stale before a build finishes.
|
|
10
|
-
- Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so an answer relayed afterwards arrives under the open question and changes nothing.
|
|
11
|
-
- Leave the message content, and which transition earns one, to whatever dispatched this session. Both are task-specific and already stated where they apply.
|
|
12
|
-
- A session holding `canon:role-worker` or `canon:role-planner` follows that body's own addressee ladder instead, stated in its own `## The channel` section. This rule states what neither role leaves stated elsewhere, not a replacement for either ladder.
|
|
13
|
-
- Report it rather than proceeding silently when neither skill resolves to check against. Both ship with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have them.
|