@erclx/canon 5.39.0 → 5.40.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/README.md +5 -5
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canvas-board/SKILL.md +1 -1
- package/claude/skills/draft-slides/SKILL.md +2 -3
- package/claude/skills/draft-slides/references/deck.md +183 -0
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-pr/references/final-command.md +1 -1
- package/claude/skills/git-pr/references/labels.md +1 -1
- package/claude/skills/git-pr/references/publish.md +1 -1
- package/claude/skills/review-ui/SKILL.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/task-board/references/board.md +1 -1
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/docs/agents/audits.md +61 -60
- package/docs/agents/context-audit.md +40 -58
- package/docs/agents/index.md +5 -51
- package/docs/agents/install-and-sync.md +1 -0
- package/docs/agents/markdown-audit.md +53 -99
- package/docs/agents/records.md +37 -79
- package/docs/agents/sessions-transfer.md +26 -70
- package/docs/target/index.md +0 -1
- package/docs/target/migrations.md +57 -66
- package/docs/target/projects.md +36 -78
- package/docs/target/scenarios.md +9 -19
- package/docs/target/sync.md +27 -68
- package/docs/workflow/ai-workflow.md +22 -68
- package/docs/workflow/operating-model.md +32 -186
- package/docs/workflow/parallel-features.md +29 -66
- package/docs/workflow/skill-map-on-request.md +22 -24
- package/docs/workflow/skill-map.md +72 -76
- package/docs/workflow/visual-design-workflow.md +25 -118
- package/docs/workflow/zshrc-aliases.md +18 -37
- package/package.json +1 -1
- package/src/canvas/client/inspector/fields.css +4 -0
- package/src/canvas/index.html +6 -0
- package/src/claude/skills-reach.ts +2 -2
- package/src/cli/ui.ts +3 -1
- package/src/commands/canvas.ts +23 -0
- package/src/commands/capture.ts +14 -0
- package/src/commands/claude.ts +13 -0
- package/src/commands/demo.ts +22 -0
- package/src/commands/deps.ts +6 -0
- package/src/commands/driver.ts +28 -2
- package/src/commands/gov.ts +52 -18
- package/src/commands/intake.ts +17 -0
- package/src/commands/labels.ts +38 -5
- package/src/commands/pr.ts +160 -43
- package/src/commands/records.ts +8 -0
- package/src/commands/secrets.ts +6 -0
- package/src/commands/sessions.ts +8 -0
- package/src/commands/tasks.ts +52 -0
- package/src/commands/teach.ts +13 -0
- package/src/commands/upstream.ts +25 -1
- package/src/commands/worktrees.ts +5 -0
- package/src/context/audit.ts +22 -4
- package/src/context/folders.ts +1 -1
- package/src/context/shape-gated.ts +4 -2
- package/src/counts/scan.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/tokens.ts +1 -1
- package/src/gate/boundaries.ts +4 -1
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +7 -7
- package/src/git/env.ts +6 -0
- package/src/markdown/scope.ts +5 -17
- package/src/pr/lift.ts +153 -19
- package/src/pr/painted.ts +87 -0
- package/src/roots/record.ts +18 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/headless/run.ts +3 -2
- package/src/sandbox/headless/watch.ts +3 -4
- package/src/slides/convert/master.ts +3 -0
- package/src/sync/format.ts +46 -0
- package/src/sync/stamp.ts +6 -0
- package/tooling/astro/reference.md +2 -2
- package/tooling/base/reference.md +1 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/nextjs/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +1 -1
- package/tooling/web/reference.md +1 -1
- package/docs/agents/canvas.md +0 -86
- package/docs/agents/capture.md +0 -65
- package/docs/agents/census.md +0 -23
- package/docs/agents/comments.md +0 -34
- package/docs/agents/context-audit-checks.md +0 -108
- package/docs/agents/context-classify.md +0 -101
- package/docs/agents/counts.md +0 -74
- package/docs/agents/demo.md +0 -140
- package/docs/agents/driver.md +0 -103
- package/docs/agents/gate.md +0 -90
- package/docs/agents/hooks.md +0 -78
- package/docs/agents/intake.md +0 -81
- package/docs/agents/key-changes.md +0 -116
- package/docs/agents/label-coverage.md +0 -86
- package/docs/agents/markdown-audit-density.md +0 -39
- package/docs/agents/markdown-audit-weight.md +0 -83
- package/docs/agents/pr-evidence-layout.md +0 -70
- package/docs/agents/pr-evidence.md +0 -273
- package/docs/agents/pr-frames.md +0 -131
- package/docs/agents/pr-lift.md +0 -132
- package/docs/agents/pr-local.md +0 -111
- package/docs/agents/pr-preview.md +0 -155
- package/docs/agents/pr-reads.md +0 -111
- package/docs/agents/pr-tick.md +0 -72
- package/docs/agents/records-push.md +0 -61
- package/docs/agents/records-stale.md +0 -40
- package/docs/agents/records-validate.md +0 -57
- package/docs/agents/restated.md +0 -83
- package/docs/agents/routing.md +0 -61
- package/docs/agents/rule-citations.md +0 -90
- package/docs/agents/sandbox.md +0 -126
- package/docs/agents/sessions.md +0 -128
- package/docs/agents/skills-audit.md +0 -120
- package/docs/agents/skills-reach.md +0 -64
- package/docs/agents/slides.md +0 -176
- package/docs/agents/state-scoped-risk.md +0 -105
- package/docs/agents/superseded.md +0 -85
- package/docs/agents/tasks-archive.md +0 -83
- package/docs/agents/tasks-plans.md +0 -152
- package/docs/agents/tasks-validate.md +0 -133
- package/docs/agents/tasks.md +0 -123
- package/docs/agents/teach-authoring.md +0 -118
- package/docs/agents/teach.md +0 -157
- package/docs/agents/test-order.md +0 -58
- package/docs/agents/upstream.md +0 -94
- package/docs/agents/worktrees.md +0 -118
- package/docs/target/standards-moved.md +0 -34
package/src/design/document.ts
CHANGED
|
@@ -95,7 +95,7 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
|
|
|
95
95
|
*/
|
|
96
96
|
const LEAD = [
|
|
97
97
|
'Authoring guidance: `claude/skills/canvas-board/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
|
|
98
|
-
'Why each value was chosen lives in `canon/context/design.md`.',
|
|
98
|
+
'Why each value was chosen lives in `canon/context/features/design.md`.',
|
|
99
99
|
].join(' ')
|
|
100
100
|
|
|
101
101
|
function bullets(rules: readonly string[]): string {
|
package/src/design/tokens.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface BorderToken {
|
|
|
59
59
|
/**
|
|
60
60
|
* Every prose slot is a list of one-line rules rather than a string, so a
|
|
61
61
|
* bullet is the unit the renderer emits and the prose budget counts, and a
|
|
62
|
-
* value needing a paragraph of why goes to `canon/context/design.md`.
|
|
62
|
+
* value needing a paragraph of why goes to `canon/context/features/design.md`.
|
|
63
63
|
*/
|
|
64
64
|
export interface DesignTokens {
|
|
65
65
|
readonly personality: string
|
package/src/gate/boundaries.ts
CHANGED
|
@@ -211,7 +211,7 @@ function collectCommands(value: unknown, into: string[] = []): string[] {
|
|
|
211
211
|
/**
|
|
212
212
|
* A hook, a workflow, or a husky script reaching one side of the seed or config
|
|
213
213
|
* boundary and not the other is a capability withheld with no recorded reason,
|
|
214
|
-
* per the criterion in `canon/context/tooling
|
|
214
|
+
* per the criterion in `canon/context/surfaces/tooling.md`.
|
|
215
215
|
*
|
|
216
216
|
* Presence on either side clears a name, and a `canon-no-seed:` comment in the
|
|
217
217
|
* file is the recorded reason. The same marker on a destination clears a
|
|
@@ -434,6 +434,9 @@ function grepLines(
|
|
|
434
434
|
* seed itself installs, so every other spelling reports. No seed root, or roots
|
|
435
435
|
* carrying no markdown, is unmeasured rather than a pass, which agrees with
|
|
436
436
|
* `seedStandards` on the no-root case.
|
|
437
|
+
*
|
|
438
|
+
* The token matches as a substring, so `canonical` reports as `canon` does. The
|
|
439
|
+
* walk covers the seed roots alone, so a shipped rule citing the CLI passes.
|
|
437
440
|
*/
|
|
438
441
|
export const seedIndependence: Measure = async (ctx) => {
|
|
439
442
|
if (!isDirectory(join(ctx.root, 'tooling'))) {
|
package/src/gate/measures.ts
CHANGED
|
@@ -1401,7 +1401,7 @@ export const pluginManifests: Measure = async (ctx) => {
|
|
|
1401
1401
|
return {
|
|
1402
1402
|
emissions: [],
|
|
1403
1403
|
unmeasured:
|
|
1404
|
-
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml,
|
|
1404
|
+
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, then test the raised pin as `canon/context/development/ci.md` states.',
|
|
1405
1405
|
}
|
|
1406
1406
|
}
|
|
1407
1407
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -92,12 +92,12 @@ export interface Stage {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* Corpora a `src/` test asserts over from outside `src
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
95
|
+
* Corpora a `src/` test asserts over from outside `src/`. Nothing compares
|
|
96
|
+
* this list to the tests reading those paths, so a test that starts reading a
|
|
97
|
+
* new corpus joins it here in the same change, or a branch touching only that
|
|
98
|
+
* corpus skips the Tests stage. The first four are directory prefixes because
|
|
99
|
+
* their tests walk the tree whole, which is what reaches a rule or a skill a
|
|
100
|
+
* branch adds rather than edits.
|
|
101
101
|
*/
|
|
102
102
|
export const TEST_CORPORA_PATTERNS = [
|
|
103
103
|
'^claude/skills/',
|
|
@@ -302,7 +302,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
302
302
|
{
|
|
303
303
|
// A hook, a workflow, or a husky script reaching one side of the seed or
|
|
304
304
|
// config boundary and not the other is a capability withheld with no
|
|
305
|
-
// recorded reason, per the criterion in canon/context/tooling
|
|
305
|
+
// recorded reason, per the criterion in canon/context/surfaces/tooling.md.
|
|
306
306
|
id: 'capability-seeding',
|
|
307
307
|
label: 'Capability seeding',
|
|
308
308
|
checks: [{ kind: 'measure', measure: capabilitySeeding }],
|
package/src/git/env.ts
CHANGED
|
@@ -26,6 +26,12 @@ const RESOLUTION_VARS = [
|
|
|
26
26
|
* Read per call rather than captured at module load. A long-lived process can
|
|
27
27
|
* have these set after import, and a snapshot would then hand git the very
|
|
28
28
|
* variables this exists to strip.
|
|
29
|
+
*
|
|
30
|
+
* Hooks export `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and `GIT_PREFIX`,
|
|
31
|
+
* which beat `-C`. Under pre-push an inherited `GIT_DIR` made a `-C src` read
|
|
32
|
+
* resolve against the whole repository, and in a fixture it let a config write
|
|
33
|
+
* overwrite the real repository's committer identity, so route every shelled
|
|
34
|
+
* call through this, fixtures included.
|
|
29
35
|
*/
|
|
30
36
|
export function gitEnv(): Record<string, string> {
|
|
31
37
|
return Object.fromEntries(
|
package/src/markdown/scope.ts
CHANGED
|
@@ -35,16 +35,11 @@ const DENSITY_SCOPE = [
|
|
|
35
35
|
* corpus, which is why the audit reads it only inside a canon checkout.
|
|
36
36
|
*/
|
|
37
37
|
export const GATED_PATHS: readonly string[] = [
|
|
38
|
-
'canon/context/
|
|
39
|
-
'canon/context/
|
|
38
|
+
'canon/context/surfaces/',
|
|
39
|
+
'canon/context/workflow/',
|
|
40
|
+
'canon/context/development/',
|
|
40
41
|
'canon/context/features/',
|
|
41
|
-
'canon/context/cli/',
|
|
42
|
-
'canon/context/sandbox/',
|
|
43
|
-
'canon/context/governance/',
|
|
44
|
-
'canon/context/tooling/',
|
|
45
|
-
'canon/context/web.md',
|
|
46
42
|
'canon/context/context-model.md',
|
|
47
|
-
'canon/context/design.md',
|
|
48
43
|
'canon/context/index.md',
|
|
49
44
|
'claude/skills/api-design/SKILL.md',
|
|
50
45
|
'claude/skills/auto-ship/SKILL.md',
|
|
@@ -76,6 +71,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
76
71
|
'claude/skills/draft-screencast/SKILL.md',
|
|
77
72
|
'claude/skills/draft-screencast/references/beats-template.md',
|
|
78
73
|
'claude/skills/draft-slides/SKILL.md',
|
|
74
|
+
'claude/skills/draft-slides/references/deck.md',
|
|
79
75
|
'claude/skills/git-branch/SKILL.md',
|
|
80
76
|
'claude/skills/git-commit/SKILL.md',
|
|
81
77
|
'claude/skills/git-followup/SKILL.md',
|
|
@@ -130,16 +126,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
130
126
|
'claude/skills/video-craft/SKILL.md',
|
|
131
127
|
'claude/skills/write-human/SKILL.md',
|
|
132
128
|
'claude/skills/youtube-transcripts/SKILL.md',
|
|
133
|
-
'docs/agents/indexes.md',
|
|
134
|
-
'docs/agents/install-and-sync.md',
|
|
135
|
-
'docs/agents/migrate.md',
|
|
136
|
-
'docs/agents/output-shape.md',
|
|
137
|
-
'docs/agents/overview.md',
|
|
138
|
-
'docs/agents/targets.md',
|
|
139
|
-
'canon/context/development/',
|
|
140
|
-
'canon/context/ci/',
|
|
141
|
-
'canon/context/scripts/',
|
|
142
129
|
'.claude/skills/internal-',
|
|
130
|
+
'docs/',
|
|
143
131
|
]
|
|
144
132
|
|
|
145
133
|
/**
|
package/src/pr/lift.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resolveReviewScope,
|
|
6
6
|
resolveUiScope,
|
|
7
7
|
} from '@/pr/review-scope'
|
|
8
|
+
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
8
9
|
|
|
9
10
|
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
10
11
|
export const DEFAULT_WAIT_SECONDS = 1200
|
|
@@ -42,6 +43,17 @@ export interface GateResult {
|
|
|
42
43
|
readonly isOk: boolean
|
|
43
44
|
/** Absent when the gate passed. */
|
|
44
45
|
readonly reason?: LiftRefusal
|
|
46
|
+
/** The commit a UI close was carried forward from, when it covered an earlier head. */
|
|
47
|
+
readonly carriedFrom?: string
|
|
48
|
+
/** The painted paths that kept a UI close from carrying forward. */
|
|
49
|
+
readonly paintedPaths?: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How an earlier commit sits against the head, and what changed since. */
|
|
53
|
+
export interface ChangedRead {
|
|
54
|
+
/** False when the commit is rewritten away or does not resolve. */
|
|
55
|
+
readonly isAncestor: boolean
|
|
56
|
+
readonly paths: readonly string[]
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
/** How the head sits against the base the pull request targets. */
|
|
@@ -85,6 +97,10 @@ export interface LiftApi {
|
|
|
85
97
|
readChecks(tip: string): Promise<ChecksGate | undefined>
|
|
86
98
|
/** The one GraphQL call the verb makes. */
|
|
87
99
|
markReady(nodeId: string): Promise<boolean>
|
|
100
|
+
/** The paths changed from an earlier commit to the head, undefined when the read failed. */
|
|
101
|
+
readChanged(from: string, to: string): Promise<ChangedRead | undefined>
|
|
102
|
+
/** The one GraphQL call that returns a ready pull request to draft. */
|
|
103
|
+
markDraft(nodeId: string): Promise<boolean>
|
|
88
104
|
sleep(ms: number): Promise<void>
|
|
89
105
|
now(): number
|
|
90
106
|
}
|
|
@@ -93,10 +109,20 @@ export interface LiftOptions {
|
|
|
93
109
|
readonly isCheck: boolean
|
|
94
110
|
readonly waitSeconds: number
|
|
95
111
|
readonly pollMs?: number
|
|
112
|
+
readonly painted?: PaintedPatterns
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
export interface LiftRecord {
|
|
99
|
-
readonly reason:
|
|
116
|
+
readonly reason:
|
|
117
|
+
| 'lifted'
|
|
118
|
+
| 'already-ready'
|
|
119
|
+
| 'redrafted'
|
|
120
|
+
| 'clear'
|
|
121
|
+
| LiftRefusal
|
|
122
|
+
/** The gate that read not clear when a ready pull request went back to draft. */
|
|
123
|
+
readonly failed?: LiftRefusal
|
|
124
|
+
readonly carriedFrom?: string
|
|
125
|
+
readonly paintedPaths?: readonly string[]
|
|
100
126
|
readonly isLifted: boolean
|
|
101
127
|
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
102
128
|
readonly isOk: boolean
|
|
@@ -129,22 +155,51 @@ function reviewGate(reviews: readonly ReviewRow[], head: string): GateResult {
|
|
|
129
155
|
return pass('review')
|
|
130
156
|
}
|
|
131
157
|
|
|
132
|
-
/**
|
|
133
|
-
function
|
|
158
|
+
/** What the UI gate says about a close carried across commits, for a record. */
|
|
159
|
+
function carried(gates: readonly GateResult[]): {
|
|
160
|
+
carriedFrom?: string
|
|
161
|
+
paintedPaths?: readonly string[]
|
|
162
|
+
} {
|
|
163
|
+
const ui = gates.find((gate) => gate.gate === 'ui')
|
|
164
|
+
return {
|
|
165
|
+
...(ui?.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
|
|
166
|
+
...(ui?.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Binds only where the evidence record carries a checklist to drive.
|
|
172
|
+
*
|
|
173
|
+
* A close behind the head still holds when it is an ancestor of the head and
|
|
174
|
+
* no painted path changed since, since a push that paints nothing leaves the
|
|
175
|
+
* checklist the reviewer drove as true as it was. A close that is not an
|
|
176
|
+
* ancestor, which a force-push or a rebase leaves, never carries.
|
|
177
|
+
*/
|
|
178
|
+
async function uiGate(
|
|
179
|
+
api: LiftApi,
|
|
180
|
+
painted: PaintedPatterns,
|
|
134
181
|
reviews: readonly ReviewRow[],
|
|
135
182
|
head: string,
|
|
136
183
|
hasChecklist: boolean,
|
|
137
|
-
): GateResult {
|
|
184
|
+
): Promise<GateResult> {
|
|
138
185
|
if (!hasChecklist) return pass('ui')
|
|
139
186
|
|
|
140
187
|
const scope = resolveUiScope({ reviews })
|
|
141
188
|
if (scope.state === 'none') return fail('ui', 'ui-missing')
|
|
142
189
|
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
143
190
|
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
144
|
-
if (scope.commit === undefined
|
|
145
|
-
|
|
191
|
+
if (scope.commit === undefined) return fail('ui', 'ui-behind')
|
|
192
|
+
if (coversHead(scope.commit, head)) return pass('ui')
|
|
193
|
+
|
|
194
|
+
const changed = await api.readChanged(scope.commit, head)
|
|
195
|
+
if (changed === undefined) return fail('ui', 'gh-failed')
|
|
196
|
+
if (!changed.isAncestor) return fail('ui', 'ui-behind')
|
|
197
|
+
|
|
198
|
+
const paintedPaths = changed.paths.filter((path) => isPainted(path, painted))
|
|
199
|
+
if (paintedPaths.length > 0) {
|
|
200
|
+
return { ...fail('ui', 'ui-behind'), paintedPaths }
|
|
146
201
|
}
|
|
147
|
-
return pass('ui')
|
|
202
|
+
return { ...pass('ui'), carriedFrom: scope.commit }
|
|
148
203
|
}
|
|
149
204
|
|
|
150
205
|
function standingGate(standing: Standing): GateResult {
|
|
@@ -207,6 +262,56 @@ function refused(
|
|
|
207
262
|
...(head !== undefined && { head }),
|
|
208
263
|
gates,
|
|
209
264
|
waitedSeconds,
|
|
265
|
+
...carried(gates),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The gates whose failure sends a ready pull request back to draft. */
|
|
270
|
+
const REDRAFT_GATES: readonly GateName[] = ['review', 'ui', 'evidence']
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Reads a ready pull request against the gates a person's ready mark vouched
|
|
274
|
+
* for. A failing review, UI, or evidence gate returns it to draft, since the
|
|
275
|
+
* mark no longer holds. Standing and CI are reported and never written on, as
|
|
276
|
+
* either would flap a merge-ready pull request on a run still going.
|
|
277
|
+
*/
|
|
278
|
+
async function settleReady(
|
|
279
|
+
api: LiftApi,
|
|
280
|
+
opts: LiftOptions,
|
|
281
|
+
pull: PullState,
|
|
282
|
+
tip: string,
|
|
283
|
+
gates: readonly GateResult[],
|
|
284
|
+
): Promise<LiftRecord> {
|
|
285
|
+
const blocked = gates.find(
|
|
286
|
+
(gate) => REDRAFT_GATES.includes(gate.gate) && !gate.isOk,
|
|
287
|
+
)
|
|
288
|
+
if (blocked?.reason === undefined) {
|
|
289
|
+
return {
|
|
290
|
+
reason: 'already-ready',
|
|
291
|
+
isLifted: false,
|
|
292
|
+
isOk: true,
|
|
293
|
+
head: tip,
|
|
294
|
+
gates,
|
|
295
|
+
waitedSeconds: 0,
|
|
296
|
+
...carried(gates),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (opts.isCheck || blocked.reason === 'gh-failed') {
|
|
300
|
+
return refused(blocked.reason, gates, tip)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const isDrafted = await api.markDraft(pull.nodeId)
|
|
304
|
+
if (!isDrafted) return refused('gh-failed', gates, tip)
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
reason: 'redrafted',
|
|
308
|
+
failed: blocked.reason,
|
|
309
|
+
isLifted: false,
|
|
310
|
+
isOk: false,
|
|
311
|
+
head: tip,
|
|
312
|
+
gates,
|
|
313
|
+
waitedSeconds: 0,
|
|
314
|
+
...carried(gates),
|
|
210
315
|
}
|
|
211
316
|
}
|
|
212
317
|
|
|
@@ -224,20 +329,11 @@ export async function lift(
|
|
|
224
329
|
opts: LiftOptions,
|
|
225
330
|
): Promise<LiftRecord> {
|
|
226
331
|
const pollMs = opts.pollMs ?? CHECKS_POLL_MS
|
|
332
|
+
const painted = opts.painted ?? DEFAULT_PAINTED
|
|
227
333
|
|
|
228
334
|
const pull = await api.readPull()
|
|
229
335
|
if (pull === undefined) return refused('gh-failed', [], undefined)
|
|
230
336
|
if (!pull.isOpen) return refused('pull-closed', [], pull.head)
|
|
231
|
-
if (!pull.isDraft) {
|
|
232
|
-
return {
|
|
233
|
-
reason: 'already-ready',
|
|
234
|
-
isLifted: false,
|
|
235
|
-
isOk: true,
|
|
236
|
-
head: pull.head,
|
|
237
|
-
gates: [],
|
|
238
|
-
waitedSeconds: 0,
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
337
|
|
|
242
338
|
// The tip, not the object's head, since the object lags the ref by up to a
|
|
243
339
|
// minute after a push and a review marker names the commit it read.
|
|
@@ -255,10 +351,12 @@ export async function lift(
|
|
|
255
351
|
|
|
256
352
|
const gates: GateResult[] = [
|
|
257
353
|
reviewGate(reviews, tip),
|
|
258
|
-
uiGate(reviews, tip, evidence.hasChecklist),
|
|
354
|
+
await uiGate(api, painted, reviews, tip, evidence.hasChecklist),
|
|
259
355
|
evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
|
|
260
356
|
standingGate(standing),
|
|
261
357
|
]
|
|
358
|
+
if (!pull.isDraft) return settleReady(api, opts, pull, tip, gates)
|
|
359
|
+
|
|
262
360
|
const blocked = gates.find((gate) => !gate.isOk)
|
|
263
361
|
|
|
264
362
|
if (opts.isCheck) {
|
|
@@ -284,6 +382,7 @@ export async function lift(
|
|
|
284
382
|
head: tip,
|
|
285
383
|
gates: all,
|
|
286
384
|
waitedSeconds: 0,
|
|
385
|
+
...carried(all),
|
|
287
386
|
}
|
|
288
387
|
}
|
|
289
388
|
|
|
@@ -316,13 +415,14 @@ export async function lift(
|
|
|
316
415
|
head: nowTip,
|
|
317
416
|
gates: cleared,
|
|
318
417
|
waitedSeconds: ci.waited,
|
|
418
|
+
...carried(cleared),
|
|
319
419
|
}
|
|
320
420
|
}
|
|
321
421
|
if (nowTip !== tip) return refused('head-moved', cleared, nowTip, ci.waited)
|
|
322
422
|
|
|
323
423
|
const reread = [
|
|
324
424
|
reviewGate(nowReviews, nowTip),
|
|
325
|
-
uiGate(nowReviews, nowTip, evidence.hasChecklist),
|
|
425
|
+
await uiGate(api, painted, nowReviews, nowTip, evidence.hasChecklist),
|
|
326
426
|
].find((gate) => !gate.isOk)
|
|
327
427
|
if (reread?.reason !== undefined) {
|
|
328
428
|
return refused(reread.reason, cleared, nowTip, ci.waited)
|
|
@@ -345,6 +445,7 @@ export async function lift(
|
|
|
345
445
|
head: nowTip,
|
|
346
446
|
gates: cleared,
|
|
347
447
|
waitedSeconds: ci.waited,
|
|
448
|
+
...carried(cleared),
|
|
348
449
|
}
|
|
349
450
|
}
|
|
350
451
|
|
|
@@ -416,3 +517,36 @@ export async function readBaseStanding(
|
|
|
416
517
|
|
|
417
518
|
return nonEmptyLines(moved).length > 0 ? 'stale' : 'fresh'
|
|
418
519
|
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Reads what changed between a close commit and the head. A commit that does
|
|
523
|
+
* not resolve, or that the head does not descend from, reads as not an
|
|
524
|
+
* ancestor and carries nothing. A head that does not resolve, or a diff that
|
|
525
|
+
* fails, is a read that failed, so the gate refuses rather than passes.
|
|
526
|
+
*
|
|
527
|
+
* The abbreviated marker resolves first, so the ancestry test runs on the full
|
|
528
|
+
* sha. Renames are split into both sides so a move out of a painted path
|
|
529
|
+
* still reads as painted.
|
|
530
|
+
*/
|
|
531
|
+
export async function readChangedPaths(
|
|
532
|
+
git: GitRunner,
|
|
533
|
+
from: string,
|
|
534
|
+
to: string,
|
|
535
|
+
): Promise<ChangedRead | undefined> {
|
|
536
|
+
if ((await git(['cat-file', '-e', `${to}^{commit}`])) === undefined) {
|
|
537
|
+
return undefined
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const full = (
|
|
541
|
+
await git(['rev-parse', '--verify', '-q', `${from}^{commit}`])
|
|
542
|
+
)?.trim()
|
|
543
|
+
if (full === undefined || full === '') return { isAncestor: false, paths: [] }
|
|
544
|
+
|
|
545
|
+
const base = (await git(['merge-base', full, to]))?.trim()
|
|
546
|
+
if (base !== full) return { isAncestor: false, paths: [] }
|
|
547
|
+
|
|
548
|
+
const diff = await git(['diff', '--name-only', '--no-renames', full, to])
|
|
549
|
+
if (diff === undefined) return undefined
|
|
550
|
+
|
|
551
|
+
return { isAncestor: true, paths: nonEmptyLines(diff) }
|
|
552
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Where a project adds to or trims the default list of painted paths. */
|
|
5
|
+
export const PAINTED_CONFIG_REL = join('canon', 'config', 'painted.json')
|
|
6
|
+
|
|
7
|
+
export interface PaintedPatterns {
|
|
8
|
+
/** A path matching any of these is painted unless `unpainted` also matches. */
|
|
9
|
+
readonly painted: readonly string[]
|
|
10
|
+
readonly unpainted: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RENDERED_EXTENSIONS = [
|
|
14
|
+
'tsx',
|
|
15
|
+
'jsx',
|
|
16
|
+
'vue',
|
|
17
|
+
'svelte',
|
|
18
|
+
'astro',
|
|
19
|
+
'html',
|
|
20
|
+
'css',
|
|
21
|
+
'scss',
|
|
22
|
+
'svg',
|
|
23
|
+
'png',
|
|
24
|
+
'jpg',
|
|
25
|
+
'jpeg',
|
|
26
|
+
'gif',
|
|
27
|
+
'webp',
|
|
28
|
+
'avif',
|
|
29
|
+
'ico',
|
|
30
|
+
'woff',
|
|
31
|
+
'woff2',
|
|
32
|
+
'ttf',
|
|
33
|
+
'otf',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a browser renders, minus the tests and stories beside it. A test-only
|
|
38
|
+
* push touches nothing a reviewer drove, which is the case the carry-forward
|
|
39
|
+
* exists for.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_PAINTED: PaintedPatterns = {
|
|
42
|
+
painted: [`**/*.{${RENDERED_EXTENSIONS.join(',')}}`],
|
|
43
|
+
unpainted: [
|
|
44
|
+
'**/*.{test,spec,stories}.*',
|
|
45
|
+
'**/__tests__/**',
|
|
46
|
+
'**/__snapshots__/**',
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function strings(value: unknown): string[] {
|
|
51
|
+
return Array.isArray(value)
|
|
52
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
53
|
+
: []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The default list plus the project's own, read from `canon/config/painted.json`
|
|
58
|
+
* as `{ "painted": [...], "unpainted": [...] }`. A file that is absent or does
|
|
59
|
+
* not parse leaves the default, since a typo there must not make every push
|
|
60
|
+
* read as unpainted.
|
|
61
|
+
*/
|
|
62
|
+
export function loadPaintedPatterns(root: string): PaintedPatterns {
|
|
63
|
+
const path = join(root, PAINTED_CONFIG_REL)
|
|
64
|
+
if (!existsSync(path)) return DEFAULT_PAINTED
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
68
|
+
if (typeof parsed !== 'object' || parsed === null) return DEFAULT_PAINTED
|
|
69
|
+
const { painted, unpainted } = parsed as Record<string, unknown>
|
|
70
|
+
return {
|
|
71
|
+
painted: [...DEFAULT_PAINTED.painted, ...strings(painted)],
|
|
72
|
+
unpainted: [...DEFAULT_PAINTED.unpainted, ...strings(unpainted)],
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
return DEFAULT_PAINTED
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchesAny(path: string, globs: readonly string[]): boolean {
|
|
80
|
+
return globs.some((glob) => new Bun.Glob(glob).match(path))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isPainted(path: string, patterns: PaintedPatterns): boolean {
|
|
84
|
+
return (
|
|
85
|
+
matchesAny(path, patterns.painted) && !matchesAny(path, patterns.unpainted)
|
|
86
|
+
)
|
|
87
|
+
}
|
package/src/roots/record.ts
CHANGED
|
@@ -177,6 +177,16 @@ export function creationRel(
|
|
|
177
177
|
return join(at, spell(at, folder), ...rest)
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Whether a root holds the backup history or a record folder, at its spelling. */
|
|
181
|
+
function holdsRecords(root: string, candidate: RecordRoot): boolean {
|
|
182
|
+
return RECORD_ENTRIES.some(
|
|
183
|
+
(entry) =>
|
|
184
|
+
entry !== 'README.md' &&
|
|
185
|
+
entry !== SCRATCH &&
|
|
186
|
+
existsSync(join(root, candidate, spell(candidate, entry))),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
/**
|
|
181
191
|
* The record root itself, for a caller whose subject is the root rather than a
|
|
182
192
|
* folder inside it.
|
|
@@ -186,11 +196,18 @@ export function creationRel(
|
|
|
186
196
|
* one answer. Splitting them would let a push resolve a history at one root and
|
|
187
197
|
* stage a work tree at the other, which stages the deletion of every folder the
|
|
188
198
|
* move relocated.
|
|
199
|
+
*
|
|
200
|
+
* A root counts when it holds a `.records.git` or a record folder. The bare
|
|
201
|
+
* directory does not, because `.claude/` exists in every installed project for
|
|
202
|
+
* its hooks and rules, so testing it pinned a fresh clone to the retired root.
|
|
203
|
+
* `README.md` and the scratch folder are left out: a records pull writes the
|
|
204
|
+
* first back and the second is deletable, so a root holding only them has
|
|
205
|
+
* nothing to protect.
|
|
189
206
|
*/
|
|
190
207
|
export function recordRoot(root: string): string {
|
|
191
208
|
return join(
|
|
192
209
|
root,
|
|
193
|
-
RECORD_ROOTS.find((candidate) =>
|
|
210
|
+
RECORD_ROOTS.find((candidate) => holdsRecords(root, candidate)) ??
|
|
194
211
|
CREATION_ROOT,
|
|
195
212
|
)
|
|
196
213
|
}
|
package/src/sandbox/census.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
|
|
|
69
69
|
/**
|
|
70
70
|
* Maps a scenario to the skill it drives, trying two spellings in order.
|
|
71
71
|
*
|
|
72
|
-
* `<category>-<command>` is the rule `canon/context/sandbox
|
|
72
|
+
* `<category>-<command>` is the rule `canon/context/development/sandbox.md` states, and it
|
|
73
73
|
* alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
|
|
74
74
|
* rest: `claude/target-setup.ts` drives the `target-setup` skill, not a `claude-target-setup` that
|
|
75
75
|
* does not exist. Stating one spelling and shipping two is what let the audit
|
|
@@ -417,8 +417,9 @@ async function drive(
|
|
|
417
417
|
|
|
418
418
|
// Taken after provisioning, so a scenario that stages a live session record
|
|
419
419
|
// of its own is already in the before snapshot rather than reported as a
|
|
420
|
-
// dispatch this run made.
|
|
421
|
-
//
|
|
420
|
+
// dispatch this run made. A scenario that fakes a live session
|
|
421
|
+
// writes to the real registry, since provisioning and the driven run share
|
|
422
|
+
// no environment to repoint it.
|
|
422
423
|
const registry = sessionsDir()
|
|
423
424
|
const sessionsBefore = snapshotSessions(registry)
|
|
424
425
|
|
|
@@ -65,8 +65,8 @@ export function writesBetween(before: Manifest, after: Manifest): string[] {
|
|
|
65
65
|
*
|
|
66
66
|
* It makes an escape visible rather than impossible. A session can write to a
|
|
67
67
|
* home directory, to a sibling worktree, or to any path outside these roots, and
|
|
68
|
-
* none of that is watched.
|
|
69
|
-
*
|
|
68
|
+
* none of that is watched. `canon/context/development/sandbox.md` names the four
|
|
69
|
+
* directories the watch reads.
|
|
70
70
|
*
|
|
71
71
|
* A linked worktree is the normal place to develop this repository, and the
|
|
72
72
|
* rule an escaping session follows sends scratch to the main root rather than to
|
|
@@ -108,8 +108,7 @@ export function escapeRoots(projectRoot: string): string[] {
|
|
|
108
108
|
* `dispatch.ts`, so the key defined against this file-write watch keeps one
|
|
109
109
|
* meaning. An arm whose skill is meant to reach past the sandbox tree declares
|
|
110
110
|
* `escape_scope` in its `expect.toml`, which bounds a legitimate write rather
|
|
111
|
-
* than the run. Read `canon/context/sandbox
|
|
112
|
-
* `canon/context/sandbox/coverage/workflow-arms.md` before writing a claim past
|
|
111
|
+
* than the run. Read `canon/context/development/sandbox.md` before writing a claim past
|
|
113
112
|
* that.
|
|
114
113
|
*/
|
|
115
114
|
export const ESCAPE_SCRATCH_DIRS = [
|
|
@@ -157,6 +157,9 @@ export function slideNumberProps(theme: DeckTheme): PptxGenJS.SlideNumberProps {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// An SVG mark keeps the broken PNG fallback pptxgenjs writes, because the
|
|
161
|
+
// screenshot patch in export.ts reaches slides and never layouts, so the mark
|
|
162
|
+
// is a raster file.
|
|
160
163
|
function markImage(path: string, size: MarkSize): PptxGenJS.ImageProps {
|
|
161
164
|
const scale = Math.min(MARK_BOX.w / size.width, MARK_BOX.h / size.height)
|
|
162
165
|
const w = Math.round(size.width * scale * 1000) / 1000
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { extname, join, resolve } from 'node:path'
|
|
3
|
+
import { execa } from 'execa'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolved in order, so a project carrying both formats with the repo's own.
|
|
7
|
+
* Prettier prints to stdout unless told to write, where oxfmt rewrites in place.
|
|
8
|
+
*/
|
|
9
|
+
const FORMATTERS = [
|
|
10
|
+
{ name: 'oxfmt', flags: [] },
|
|
11
|
+
{ name: 'prettier', flags: ['--write'] },
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
/** What a formatter reads. A shell script or a binary seed is left to its own tool. */
|
|
15
|
+
const FORMATTABLE = new Set(['.json', '.jsonc', '.md', '.yml', '.yaml'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs the project's installed formatter over the files a sync wrote, so a
|
|
19
|
+
* write the formatter would reshape does not leave a whitespace diff on a file
|
|
20
|
+
* nobody edited. Only the named paths are passed, never the project, which
|
|
21
|
+
* keeps a dirty tree unformatted. A path is absolute or relative to `target`.
|
|
22
|
+
*
|
|
23
|
+
* Best effort: no installed formatter, or one that fails, leaves the output as
|
|
24
|
+
* written and the sync unaffected. Never pass a file the sync hashes against
|
|
25
|
+
* its source, since the bytes on disk would stop matching the record.
|
|
26
|
+
*/
|
|
27
|
+
export async function formatWritten(
|
|
28
|
+
target: string,
|
|
29
|
+
paths: readonly string[],
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const files = paths
|
|
32
|
+
.filter((path) => FORMATTABLE.has(extname(path)))
|
|
33
|
+
.map((path) => resolve(target, path))
|
|
34
|
+
if (files.length === 0) return
|
|
35
|
+
|
|
36
|
+
const formatter = FORMATTERS.map((candidate) => ({
|
|
37
|
+
...candidate,
|
|
38
|
+
path: join(target, 'node_modules', '.bin', candidate.name),
|
|
39
|
+
})).find((candidate) => existsSync(candidate.path))
|
|
40
|
+
if (formatter === undefined) return
|
|
41
|
+
|
|
42
|
+
await execa(formatter.path, [...formatter.flags, ...files], {
|
|
43
|
+
cwd: target,
|
|
44
|
+
reject: false,
|
|
45
|
+
})
|
|
46
|
+
}
|
package/src/sync/stamp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
4
4
|
import { dirname, join, sep } from 'node:path'
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
import { resolveExisting } from '@/roots/legacy'
|
|
7
|
+
import { formatWritten } from '@/sync/format'
|
|
7
8
|
import { recordTarget } from '@/targets/registry'
|
|
8
9
|
import { compareVersions, parseVersion } from '@/version/compare'
|
|
9
10
|
import { readInstalled } from '@/version/installed'
|
|
@@ -287,6 +288,11 @@ async function putDomain(
|
|
|
287
288
|
await mkdir(dirname(path), { recursive: true })
|
|
288
289
|
await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
|
|
289
290
|
|
|
291
|
+
// The stamp hashes every file but itself, so reshaping it cannot read as
|
|
292
|
+
// drift. Seeds and goldens stay as copied for the opposite reason: their
|
|
293
|
+
// hashes are taken against the source bytes.
|
|
294
|
+
await formatWritten(target, [path])
|
|
295
|
+
|
|
290
296
|
// Every install and sync that stamps a target passes through here, which is
|
|
291
297
|
// what makes this the one place the machine-level index can be kept without
|
|
292
298
|
// each command remembering to. Its outcome is dropped rather than reported:
|