@erclx/canon 4.117.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-operator/SKILL.md +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-ready/references/assembly.md +8 -0
- 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/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +8 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +4 -2
- 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/target-check/references/domains.md +1 -1
- 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 +3 -3
- package/docs/agents/context-audit-checks.md +2 -28
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +28 -17
- package/docs/agents/tasks.md +4 -4
- package/docs/target-projects.md +2 -2
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/renames.toml +13 -0
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/085-worktrees.md +2 -8
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +10 -0
- package/src/commands/context.ts +11 -111
- package/src/commands/sync.ts +11 -3
- package/src/commands/tasks.ts +9 -8
- package/src/context/audit.ts +3 -145
- 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/measures.ts +37 -0
- package/src/gate/stages.ts +13 -4
- package/src/gov/adapter.ts +59 -7
- package/src/gov/install.ts +13 -3
- package/src/gov/renames.ts +76 -0
- package/src/gov/stacks.ts +5 -1
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +25 -4
- package/src/sync/engine.ts +154 -10
- package/src/sync/stamp.ts +44 -0
- package/src/tasks/archive.ts +28 -7
- package/src/tasks/record.ts +16 -9
- package/src/tasks/validate.ts +3 -1
- package/standards/plan.md +24 -1
- package/standards/tasks.md +5 -3
- package/governance/rules/core/091-channel.md +0 -13
- package/src/context/narration.ts +0 -99
package/src/sync/engine.ts
CHANGED
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
type DomainHashes,
|
|
9
9
|
hashFile,
|
|
10
10
|
readStamp,
|
|
11
|
+
type Stamp,
|
|
12
|
+
type StampDomain,
|
|
11
13
|
type StampSource,
|
|
12
14
|
stampedHashes,
|
|
15
|
+
stampedVersion,
|
|
13
16
|
toStampKey,
|
|
14
17
|
writeStamp,
|
|
15
18
|
} from '@/sync/stamp'
|
|
@@ -26,6 +29,8 @@ import {
|
|
|
26
29
|
palette,
|
|
27
30
|
select,
|
|
28
31
|
} from '@/ui'
|
|
32
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
33
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* One installed file in three path flavours: absolute, relative to the
|
|
@@ -58,10 +63,17 @@ export type SyncChange =
|
|
|
58
63
|
* who moved it. `stale` and `customized` need the stamp to tell apart, so
|
|
59
64
|
* `drifted` stays the verdict for a difference no stamp covers.
|
|
60
65
|
*
|
|
61
|
-
* `orphaned` and `stranded`
|
|
62
|
-
* separate because they need
|
|
63
|
-
* orphaned and stays that way forever. A
|
|
64
|
-
*
|
|
66
|
+
* `orphaned`, `retired`, and `stranded` all mean the walk found no source, and
|
|
67
|
+
* they are separate because they need different treatment. A project-authored
|
|
68
|
+
* file is orphaned and stays that way forever. A file under a root the toolkit
|
|
69
|
+
* owns outright is retired, since only the toolkit ever wrote there, and a
|
|
70
|
+
* sync deletes it. A stamped file the toolkit no longer installs to is
|
|
71
|
+
* stranded, which is a relocation waiting on a decision.
|
|
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.
|
|
65
77
|
*
|
|
66
78
|
* `missing` is the one state the walk cannot produce on its own, since the
|
|
67
79
|
* walk only iterates files that exist. It comes from `collectMissing`
|
|
@@ -73,6 +85,8 @@ export type EntryState =
|
|
|
73
85
|
| 'customized'
|
|
74
86
|
| 'drifted'
|
|
75
87
|
| 'orphaned'
|
|
88
|
+
| 'retired'
|
|
89
|
+
| 'renamed'
|
|
76
90
|
| 'stranded'
|
|
77
91
|
| 'missing'
|
|
78
92
|
|
|
@@ -152,6 +166,25 @@ export interface SyncAdapter {
|
|
|
152
166
|
* inference orphans a file sitting anywhere else.
|
|
153
167
|
*/
|
|
154
168
|
readonly projectSubdir?: string
|
|
169
|
+
/**
|
|
170
|
+
* Every file under `installedRoot` is the toolkit's, so a walked file with
|
|
171
|
+
* no source is a leftover of a rule the toolkit retired or renamed rather
|
|
172
|
+
* than something a project wrote. Setting it turns that file into a
|
|
173
|
+
* `retired` entry and queues its delete, edited or not, whatever the stamp
|
|
174
|
+
* holds, or a `renamed` one when `locateSuccessor` names where it ships
|
|
175
|
+
* now. Unset, the file stays `orphaned` and untouched.
|
|
176
|
+
*/
|
|
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
|
|
155
188
|
/** Defaults to applying. */
|
|
156
189
|
readonly nonInteractive?: NonInteractivePolicy
|
|
157
190
|
/** Runs on a completed sync, including one with no changes. */
|
|
@@ -195,16 +228,20 @@ export function listInstalled(
|
|
|
195
228
|
|
|
196
229
|
/**
|
|
197
230
|
* Classifies every installed file against its source without writing anything.
|
|
198
|
-
* A file with no source is
|
|
199
|
-
*
|
|
231
|
+
* A file with no source is deleted only when the adapter owns its installed
|
|
232
|
+
* root outright, and left alone otherwise, which is what keeps a
|
|
233
|
+
* project-authored file alive across a sync of a root the project shares.
|
|
200
234
|
*/
|
|
201
235
|
export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
202
236
|
const entries: ScanEntry[] = []
|
|
203
237
|
const changes: SyncChange[] = []
|
|
204
238
|
const unattributed: UnattributedFile[] = []
|
|
205
239
|
|
|
206
|
-
const
|
|
240
|
+
const stamp = readStamp(target)
|
|
241
|
+
const hashes = stampedHashes(stamp, adapter.stamp?.domain)
|
|
242
|
+
const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
|
|
207
243
|
const walked = new Set<string>()
|
|
244
|
+
const pendingCopies = new Set<string>()
|
|
208
245
|
|
|
209
246
|
for (const file of listInstalled(
|
|
210
247
|
adapter.installedRoot(target),
|
|
@@ -222,7 +259,46 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
222
259
|
const source = adapter.locateSource(file)
|
|
223
260
|
|
|
224
261
|
if (source === undefined || !existsSync(source)) {
|
|
225
|
-
|
|
262
|
+
if (adapter.ownsInstalledRoot !== true) {
|
|
263
|
+
entries.push(misplacedOrphan(adapter, target, file))
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (newerInstall !== undefined) {
|
|
268
|
+
entries.push(heldForNewerInstall(file, newerInstall))
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const successor = adapter.locateSuccessor?.(file, target)
|
|
273
|
+
if (successor === undefined) {
|
|
274
|
+
entries.push({
|
|
275
|
+
state: 'retired',
|
|
276
|
+
rel: file.rel,
|
|
277
|
+
notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
|
|
278
|
+
})
|
|
279
|
+
changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
|
|
280
|
+
continue
|
|
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 })
|
|
226
302
|
continue
|
|
227
303
|
}
|
|
228
304
|
|
|
@@ -438,6 +514,10 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
438
514
|
)
|
|
439
515
|
else if (entry.state === 'customized')
|
|
440
516
|
logWarn(`${entry.rel} (locally customized)`)
|
|
517
|
+
else if (entry.state === 'retired')
|
|
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)`)
|
|
441
521
|
else if (entry.state === 'stranded')
|
|
442
522
|
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
443
523
|
else if (entry.state === 'missing')
|
|
@@ -528,6 +608,10 @@ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
|
|
|
528
608
|
* Naming the destination is all this does. Moving the file rewrites a path the
|
|
529
609
|
* project's own rules, skills, and docs may cite, so the sync leaves it where
|
|
530
610
|
* it is.
|
|
611
|
+
*
|
|
612
|
+
* An adapter declaring `ownsInstalledRoot` never reaches this, since owning
|
|
613
|
+
* the root answers the question neither the name nor the stamp can, and its
|
|
614
|
+
* sourceless file is retired instead.
|
|
531
615
|
*/
|
|
532
616
|
function misplacedOrphan(
|
|
533
617
|
adapter: SyncAdapter,
|
|
@@ -549,6 +633,65 @@ function misplacedOrphan(
|
|
|
549
633
|
}
|
|
550
634
|
}
|
|
551
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The release that last wrote this domain, when it is newer than the one
|
|
638
|
+
* running. A binary older than that release cannot tell a rule it retired
|
|
639
|
+
* from one added after it, so it holds every sourceless file rather than
|
|
640
|
+
* deleting rules the newer release installed. An unparseable side holds too,
|
|
641
|
+
* since deleting on a comparison that never ran is the failure this guards.
|
|
642
|
+
* A stamp with no version predates the field and is never newer.
|
|
643
|
+
*/
|
|
644
|
+
function newerInstallingVersion(
|
|
645
|
+
stamp: Stamp | undefined,
|
|
646
|
+
domain: StampDomain | undefined,
|
|
647
|
+
): { readonly stamped: string; readonly running: string } | undefined {
|
|
648
|
+
if (domain === undefined) return undefined
|
|
649
|
+
|
|
650
|
+
const stamped = stampedVersion(stamp, domain)
|
|
651
|
+
if (stamped === undefined) return undefined
|
|
652
|
+
|
|
653
|
+
const running = readInstalled().version ?? UNKNOWN_LABEL
|
|
654
|
+
const parsedStamped = parseVersion(stamped)
|
|
655
|
+
const parsedRunning = parseVersion(running)
|
|
656
|
+
|
|
657
|
+
if (
|
|
658
|
+
parsedStamped !== undefined &&
|
|
659
|
+
parsedRunning !== undefined &&
|
|
660
|
+
compareVersions(parsedStamped, parsedRunning) <= 0
|
|
661
|
+
) {
|
|
662
|
+
return undefined
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return { stamped, running }
|
|
666
|
+
}
|
|
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
|
+
|
|
684
|
+
function heldForNewerInstall(
|
|
685
|
+
file: InstalledFile,
|
|
686
|
+
versions: { readonly stamped: string; readonly running: string },
|
|
687
|
+
): ScanEntry {
|
|
688
|
+
return {
|
|
689
|
+
state: 'orphaned',
|
|
690
|
+
rel: file.rel,
|
|
691
|
+
notice: `${file.rel} (installed by canon ${versions.stamped}, newer than this ${versions.running}. Upgrade canon to sync it.)`,
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
552
695
|
function isInside(target: string, path: string): boolean {
|
|
553
696
|
const rel = relative(target, path)
|
|
554
697
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
@@ -567,8 +710,9 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
|
|
|
567
710
|
/**
|
|
568
711
|
* Records what the toolkit placed, after the copies land, so a partial apply
|
|
569
712
|
* that throws leaves the previous stamp rather than a claim the target does not
|
|
570
|
-
* meet. A file with no source,
|
|
571
|
-
*
|
|
713
|
+
* meet. A file with no source stays out, whether it is project-authored or a
|
|
714
|
+
* retired leftover a declined sync did not delete, and so does one orphaned by
|
|
715
|
+
* location.
|
|
572
716
|
*
|
|
573
717
|
* Reads the installed tree rather than the caller's file list, so a partial
|
|
574
718
|
* install still stamps the domain's whole installed set.
|
package/src/sync/stamp.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { dirname, join, sep } from 'node:path'
|
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
import { resolveExisting } from '@/legacy-path'
|
|
7
7
|
import { recordTarget } from '@/targets/registry'
|
|
8
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
9
|
+
import { readInstalled } from '@/version/installed'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Domains the stamp can record. Governance and design both attribute file by
|
|
@@ -38,6 +40,14 @@ export type DomainHashes = Readonly<Record<string, string>>
|
|
|
38
40
|
export interface DomainStamp {
|
|
39
41
|
/** Toolkit revision this domain last synced from. Absent outside a git clone. */
|
|
40
42
|
readonly commit?: string
|
|
43
|
+
/**
|
|
44
|
+
* Newest canon release that has written this domain. A sync deletes an
|
|
45
|
+
* installed file its toolkit does not ship, and a binary older than the one
|
|
46
|
+
* that installed the file cannot tell a retired file from one added after
|
|
47
|
+
* its own release, so this is what it reads before deleting anything.
|
|
48
|
+
* Absent on a stamp written before the field existed.
|
|
49
|
+
*/
|
|
50
|
+
readonly version?: string
|
|
41
51
|
readonly syncedAt: string
|
|
42
52
|
readonly files: DomainHashes
|
|
43
53
|
/**
|
|
@@ -178,6 +188,14 @@ function readStampFile(path: string): Stamp | undefined {
|
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
|
|
191
|
+
/** The newest canon release that has written this domain, when one was recorded. */
|
|
192
|
+
export function stampedVersion(
|
|
193
|
+
stamp: Stamp | undefined,
|
|
194
|
+
domain: StampDomain,
|
|
195
|
+
): string | undefined {
|
|
196
|
+
return stamp?.domains[domain]?.version
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
/** The revision this domain last synced from, which bounds its upstream range. */
|
|
182
200
|
export function stampedCommit(
|
|
183
201
|
stamp: Stamp | undefined,
|
|
@@ -245,9 +263,11 @@ async function putDomain(
|
|
|
245
263
|
const previous = readStamp(target)
|
|
246
264
|
const previousRecord = previous?.domains[source.domain]
|
|
247
265
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
266
|
+
const version = newerVersion(readInstalled().version, previousRecord?.version)
|
|
248
267
|
|
|
249
268
|
const record: DomainStamp = {
|
|
250
269
|
...(commit === undefined ? {} : { commit }),
|
|
270
|
+
...(version === undefined ? {} : { version }),
|
|
251
271
|
syncedAt: now.toISOString(),
|
|
252
272
|
files: payload.files ?? previousRecord?.files ?? {},
|
|
253
273
|
...resolveChainField(payload.chain, previousRecord?.chain),
|
|
@@ -304,6 +324,27 @@ async function readCommit(root: string): Promise<string | undefined> {
|
|
|
304
324
|
: undefined
|
|
305
325
|
}
|
|
306
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Keeps the newer of the running release and the one already recorded, so an
|
|
329
|
+
* older binary syncing after a newer one cannot lower the mark and let a later
|
|
330
|
+
* run delete a rule the newer release installed. An unparseable side loses to
|
|
331
|
+
* a parseable one, and a recorded value survives an unreadable manifest.
|
|
332
|
+
*/
|
|
333
|
+
function newerVersion(
|
|
334
|
+
running: string | undefined,
|
|
335
|
+
recorded: string | undefined,
|
|
336
|
+
): string | undefined {
|
|
337
|
+
if (running === undefined) return recorded
|
|
338
|
+
if (recorded === undefined) return running
|
|
339
|
+
|
|
340
|
+
const parsedRunning = parseVersion(running)
|
|
341
|
+
const parsedRecorded = parseVersion(recorded)
|
|
342
|
+
if (parsedRecorded === undefined) return running
|
|
343
|
+
if (parsedRunning === undefined) return recorded
|
|
344
|
+
|
|
345
|
+
return compareVersions(parsedRecorded, parsedRunning) > 0 ? recorded : running
|
|
346
|
+
}
|
|
347
|
+
|
|
307
348
|
/**
|
|
308
349
|
* A write naming no chain keeps the domain's previous one rather than dropping
|
|
309
350
|
* it, since `writeStamp` and `writeChainStamp` each touch one half of a
|
|
@@ -365,6 +406,9 @@ function isDomainStamp(value: unknown): value is DomainStamp {
|
|
|
365
406
|
if (value.commit !== undefined && typeof value.commit !== 'string') {
|
|
366
407
|
return false
|
|
367
408
|
}
|
|
409
|
+
if (value.version !== undefined && typeof value.version !== 'string') {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
368
412
|
if (value.chain !== undefined && !isStringArray(value.chain)) return false
|
|
369
413
|
|
|
370
414
|
return Object.values(value.files).every((hash) => typeof hash === '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/plan.md
CHANGED
|
@@ -31,6 +31,7 @@ A plan works when a session holding none of the conversation that produced it ca
|
|
|
31
31
|
- Which decisions are already settled, and which are still open?
|
|
32
32
|
- For each open decision, what happens when nobody answers it?
|
|
33
33
|
- What is likely to go wrong, and where does the work collide with something already in flight?
|
|
34
|
+
- How is each outcome proven, and which input would break it?
|
|
34
35
|
|
|
35
36
|
A plan failing these is non-conforming even when it satisfies every shape rule below.
|
|
36
37
|
|
|
@@ -53,10 +54,12 @@ The document opens with `# Feature: <short title>` and one paragraph stating wha
|
|
|
53
54
|
| `## Summary` | three to five one-line bullets: the goal, the deliverables, the trade-off | Always |
|
|
54
55
|
| `**Constraints:**` | durable rules the work must respect | When supplied |
|
|
55
56
|
| `**Files to touch:**` | each file with a one-line reason | Always |
|
|
57
|
+
| `**Verification:**` | the test or command that proves each outcome | Always |
|
|
56
58
|
| `**Risks:**` | conflicts, coupling, and the tricky spots | Always |
|
|
59
|
+
| `**Review focus:**` | the inputs or states that would break the change, for the reviewer | Always |
|
|
57
60
|
| `**Questions:**` | numbered open decisions, each with a suggestion and an answer slot | Always |
|
|
58
61
|
|
|
59
|
-
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the
|
|
62
|
+
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the six that follow are labels over lists rather than sections of prose.
|
|
60
63
|
- A section written in the other spelling is still that section. `## Risks` and `**Risks:**` name one thing, and a plan carrying either has stated its risks, so neither reads as an omission. Write the table's form in a new plan and leave an existing plan's spelling alone.
|
|
61
64
|
- Both spellings break a depth run, so `canon markdown audit` reads either as the seam it is and reports a plan for a section that genuinely runs long rather than for its whole body. The markdown standard states the rule.
|
|
62
65
|
- Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
|
|
@@ -87,11 +90,23 @@ A dead constraint fails silently in the expensive direction. A session honoring
|
|
|
87
90
|
|
|
88
91
|
A constraint naming a `.canon/ready/` folder is a third shape beside the two above. It makes that folder's files the verbatim source for the paths this plan's `**Files to touch:**` lists, per `ready.md`, so the executing session copies those paths rather than authoring them.
|
|
89
92
|
|
|
93
|
+
### Verification
|
|
94
|
+
|
|
95
|
+
- Name, for each outcome, the test or the command that proves it, as one bullet leading with the outcome and ending on the command in backticks.
|
|
96
|
+
- Write a command a later session can run as given, from the project root. A description of what would be checked leaves the executing session to invent the check.
|
|
97
|
+
- Name the visual check where no test reaches, and say it is visual, so a reader can tell a proof from a look.
|
|
98
|
+
|
|
90
99
|
### Risks
|
|
91
100
|
|
|
92
101
|
- Name the collision rather than the category. A risk a reader cannot act on is padding.
|
|
93
102
|
- Where the work establishes a resource with more than one consumer, list the consumers and mark each one read or write. A policy stated over that resource has to hold for the writers and not only for the consumer that prompted it.
|
|
94
103
|
|
|
104
|
+
### Review focus
|
|
105
|
+
|
|
106
|
+
- Name each input or state that would break the change if the implementation got it wrong, one bullet each, with what the change must do there.
|
|
107
|
+
- Write it for the reviewer rather than the builder. The session writing the plan understood the risk before any diff existed, and this section is where that understanding reaches the review pass.
|
|
108
|
+
- Keep it apart from `**Risks:**`. A risk is something the work might collide with, and a focus item is something the finished diff must be shown to handle, so a reviewer confirms the second and the executing session plans around the first.
|
|
109
|
+
|
|
95
110
|
## The suggested-and-answer contract
|
|
96
111
|
|
|
97
112
|
Every question carries a `- Suggested:` line and an empty `- Answer:` slot below it.
|
|
@@ -165,10 +180,18 @@ This contract inverts the one an intake folder keeps, where an empty slot means
|
|
|
165
180
|
|
|
166
181
|
- `<path/to/file>`: <reason>
|
|
167
182
|
|
|
183
|
+
**Verification:**
|
|
184
|
+
|
|
185
|
+
- <outcome>: `<test or command that proves it>`
|
|
186
|
+
|
|
168
187
|
**Risks:**
|
|
169
188
|
|
|
170
189
|
- <conflict, coupling, or tricky spot>
|
|
171
190
|
|
|
191
|
+
**Review focus:**
|
|
192
|
+
|
|
193
|
+
- <input or state that would break the change>: <what the change must do there>
|
|
194
|
+
|
|
172
195
|
**Questions:**
|
|
173
196
|
|
|
174
197
|
1. <question>
|
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.
|