@erclx/canon 5.47.0 → 5.49.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +18 -15
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +7 -14
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +36 -3
- package/claude/skills/review-ui/references/tick.md +4 -1
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +5 -3
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +2 -1
- package/docs/agents/migrate.md +2 -2
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/parallel-features.md +2 -2
- package/package.json +1 -1
- package/src/commands/claude.ts +4 -0
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +712 -58
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +21 -1
- package/src/hooks/post-merge.ts +11 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/lift.ts +19 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +21 -1
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +154 -8
- package/src/tasks/board.ts +68 -13
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +43 -4
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/commands/records.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
RECORD_KINDS,
|
|
32
32
|
type RecordKind,
|
|
33
33
|
type ValidateOutcome,
|
|
34
|
+
type StructureFinding,
|
|
34
35
|
validateRecords,
|
|
35
36
|
} from '@/records/validate'
|
|
36
37
|
import {
|
|
@@ -89,6 +90,10 @@ export function register(program: Command): void {
|
|
|
89
90
|
.command('validate')
|
|
90
91
|
.description('Report where a file and the standard governing it disagree')
|
|
91
92
|
.argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
|
|
93
|
+
.argument(
|
|
94
|
+
'[record]',
|
|
95
|
+
'One plan stem or folder slug, narrowing both findings and structure to it',
|
|
96
|
+
)
|
|
92
97
|
.helpOption('-h, --help', 'Show this help message')
|
|
93
98
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
94
99
|
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
@@ -101,12 +106,18 @@ export function register(program: Command): void {
|
|
|
101
106
|
' groundwork README and current-state files, numbering, dating, and a half-closed track',
|
|
102
107
|
' intake overview file, numbering, dating, and the four bullets every item carries',
|
|
103
108
|
'',
|
|
109
|
+
'Structure, under `structure` and never in the exit:',
|
|
110
|
+
" S1 to S5 from the plan standard, read on plans, a track's README and",
|
|
111
|
+
' numbered files, and intake clusters, plus intake-spill for content under',
|
|
112
|
+
' a field. A record written before the criteria misses S1, so it reports alone.',
|
|
113
|
+
'',
|
|
104
114
|
'Refusals, read off `reason`:',
|
|
105
115
|
" no-folder none of the kind's directories exist at either record root",
|
|
116
|
+
' no-record the folder holds no record by the name given',
|
|
106
117
|
' unknown-kind the argument names no published kind',
|
|
107
118
|
'',
|
|
108
119
|
'Exit codes:',
|
|
109
|
-
' 0
|
|
120
|
+
' 0 no record carries a finding, whatever structure reports',
|
|
110
121
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
111
122
|
' 2 at least one record carries a finding',
|
|
112
123
|
'',
|
|
@@ -115,14 +126,21 @@ export function register(program: Command): void {
|
|
|
115
126
|
'',
|
|
116
127
|
'Examples:',
|
|
117
128
|
' canon records validate plans',
|
|
129
|
+
' canon records validate plans feature-records-by-heading',
|
|
118
130
|
' canon records validate teach',
|
|
119
131
|
' canon records validate intake --json',
|
|
120
132
|
'',
|
|
121
133
|
].join('\n'),
|
|
122
134
|
)
|
|
123
|
-
.action(
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
.action(
|
|
136
|
+
async (
|
|
137
|
+
kind: string,
|
|
138
|
+
record: string | undefined,
|
|
139
|
+
opts: ValidateCommandOptions,
|
|
140
|
+
) => {
|
|
141
|
+
process.exitCode = await runValidate(kind, record, opts)
|
|
142
|
+
},
|
|
143
|
+
)
|
|
126
144
|
|
|
127
145
|
records
|
|
128
146
|
.command('ordinal')
|
|
@@ -721,6 +739,7 @@ function reportRefusal(
|
|
|
721
739
|
|
|
722
740
|
async function runValidate(
|
|
723
741
|
kind: string,
|
|
742
|
+
record: string | undefined,
|
|
724
743
|
opts: ValidateCommandOptions,
|
|
725
744
|
): Promise<number> {
|
|
726
745
|
const emitJson = opts.json ?? false
|
|
@@ -739,7 +758,11 @@ async function runValidate(
|
|
|
739
758
|
|
|
740
759
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
741
760
|
|
|
742
|
-
|
|
761
|
+
// A plan named with its extension and without it is one record, so the walk
|
|
762
|
+
// and the cross-plan filter below both read the bare stem.
|
|
763
|
+
const stem = record?.replace(/\.md$/, '')
|
|
764
|
+
|
|
765
|
+
return report(await validateWithStacks(root, kind, stem), emitJson, root)
|
|
743
766
|
}
|
|
744
767
|
|
|
745
768
|
/**
|
|
@@ -749,18 +772,21 @@ async function runValidate(
|
|
|
749
772
|
async function validateWithStacks(
|
|
750
773
|
root: string,
|
|
751
774
|
kind: RecordKind,
|
|
775
|
+
record: string | undefined,
|
|
752
776
|
): Promise<ValidateOutcome> {
|
|
753
|
-
const outcome = await validateRecords(root, kind)
|
|
777
|
+
const outcome = await validateRecords(root, kind, record)
|
|
754
778
|
if (!outcome.ok || kind !== 'plans') return outcome
|
|
755
779
|
|
|
756
780
|
const live = await readLivePlans(root)
|
|
781
|
+
const across = [...stackFindings(live), ...fanInFindings(live)]
|
|
757
782
|
|
|
758
783
|
return {
|
|
759
784
|
...outcome,
|
|
760
785
|
findings: [
|
|
761
786
|
...outcome.findings,
|
|
762
|
-
...
|
|
763
|
-
|
|
787
|
+
...(record === undefined
|
|
788
|
+
? across
|
|
789
|
+
: across.filter((found) => found.record === `${record}.md`)),
|
|
764
790
|
],
|
|
765
791
|
}
|
|
766
792
|
}
|
|
@@ -801,6 +827,7 @@ function report(
|
|
|
801
827
|
kind: outcome.kind,
|
|
802
828
|
records: outcome.records,
|
|
803
829
|
findings: outcome.findings,
|
|
830
|
+
structure: outcome.structure,
|
|
804
831
|
})}\n`,
|
|
805
832
|
)
|
|
806
833
|
} else {
|
|
@@ -814,6 +841,11 @@ function report(
|
|
|
814
841
|
} else {
|
|
815
842
|
for (const found of outcome.findings) logWarn(describe(found))
|
|
816
843
|
}
|
|
844
|
+
|
|
845
|
+
if (outcome.structure.length > 0) {
|
|
846
|
+
logStep('Structure, reported and never in the exit')
|
|
847
|
+
for (const entry of outcome.structure) logInfo(describeStructure(entry))
|
|
848
|
+
}
|
|
817
849
|
outro()
|
|
818
850
|
}
|
|
819
851
|
|
|
@@ -825,6 +857,11 @@ function describe(found: Finding): string {
|
|
|
825
857
|
return `${scope}${found.subject} ${found.message}`
|
|
826
858
|
}
|
|
827
859
|
|
|
860
|
+
function describeStructure(entry: StructureFinding): string {
|
|
861
|
+
const scope = entry.record === entry.subject ? '' : `${entry.record}/`
|
|
862
|
+
return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`
|
|
863
|
+
}
|
|
864
|
+
|
|
828
865
|
async function runOrdinal(
|
|
829
866
|
kind: string,
|
|
830
867
|
slug: string,
|
package/src/commands/tasks.ts
CHANGED
|
@@ -222,6 +222,11 @@ export function register(program: Command): void {
|
|
|
222
222
|
'listed, and pending-branch while a Pending branch: line names a branch',
|
|
223
223
|
'whose own pull request is not recorded yet. A stem skips both.',
|
|
224
224
|
'',
|
|
225
|
+
'On an earlier-slice or open-outcomes refusal from --pull-request, the',
|
|
226
|
+
'slice plan whose derived branch is the pull request head, read through',
|
|
227
|
+
'gh, moves to the plans archive and the task stays open. The record',
|
|
228
|
+
'names it under `slice`.',
|
|
229
|
+
'',
|
|
225
230
|
'Refusals, read off `reason`:',
|
|
226
231
|
' no-board the repository holds no `.canon/tasks/` board',
|
|
227
232
|
' no-match no task matches the selector',
|
|
@@ -680,7 +685,9 @@ export function register(program: Command): void {
|
|
|
680
685
|
' the last listed, so a later number closes the issue',
|
|
681
686
|
' lookup-failed the pull request lookup failed while the task already',
|
|
682
687
|
' lists a pull request, so an earlier slice cannot be ruled out',
|
|
683
|
-
' open-outcomes an outcome is still open, so a later slice closes it',
|
|
688
|
+
' open-outcomes an outcome is still open, so a later slice closes it.',
|
|
689
|
+
' A `- [ ] Operator check: <outcome>` line holds nothing open and',
|
|
690
|
+
' is listed under `operatorChecks`',
|
|
684
691
|
' pending-branch another branch is on `Pending branch:`',
|
|
685
692
|
' no-outcomes the task carries no outcomes',
|
|
686
693
|
'',
|
|
@@ -1544,6 +1551,7 @@ function reportCloses(
|
|
|
1544
1551
|
issues: outcome.issues,
|
|
1545
1552
|
closing: outcome.closing,
|
|
1546
1553
|
reason: outcome.reason,
|
|
1554
|
+
operatorChecks: outcome.operatorChecks ?? [],
|
|
1547
1555
|
})}\n`,
|
|
1548
1556
|
)
|
|
1549
1557
|
return 0
|
|
@@ -1556,6 +1564,9 @@ function reportCloses(
|
|
|
1556
1564
|
? `${outcome.stem} names ${outcome.issues.map((n) => `#${n}`).join(', ')}, ${outcome.reason}`
|
|
1557
1565
|
: `${outcome.stem} names no issue`,
|
|
1558
1566
|
)
|
|
1567
|
+
for (const check of outcome.operatorChecks ?? []) {
|
|
1568
|
+
logInfo(`operator check: ${check}`)
|
|
1569
|
+
}
|
|
1559
1570
|
outro()
|
|
1560
1571
|
|
|
1561
1572
|
return 0
|
|
@@ -2181,6 +2192,10 @@ function report(
|
|
|
2181
2192
|
logStep('Refused')
|
|
2182
2193
|
logError(outcome.message)
|
|
2183
2194
|
if (outcome.detail.length > 0) pipeOutput(outcome.detail.join('\n'))
|
|
2195
|
+
if (outcome.slice) {
|
|
2196
|
+
logStep('Slice plan archived')
|
|
2197
|
+
logPlanMoves([outcome.slice], root)
|
|
2198
|
+
}
|
|
2184
2199
|
outro()
|
|
2185
2200
|
return 1
|
|
2186
2201
|
}
|
|
@@ -2199,6 +2214,7 @@ function report(
|
|
|
2199
2214
|
logRelinked(outcome.relinked, outcome.relinkFailed, root)
|
|
2200
2215
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
2201
2216
|
if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
|
|
2217
|
+
for (const check of outcome.operatorChecks) logInfo(check)
|
|
2202
2218
|
outro()
|
|
2203
2219
|
|
|
2204
2220
|
return 0
|
|
@@ -2269,6 +2285,9 @@ function recordFor(
|
|
|
2269
2285
|
reason: outcome.reason,
|
|
2270
2286
|
message: outcome.message,
|
|
2271
2287
|
detail: outcome.detail,
|
|
2288
|
+
...(outcome.slice
|
|
2289
|
+
? { slice: planMovesFor([outcome.slice], root)[0] }
|
|
2290
|
+
: {}),
|
|
2272
2291
|
}
|
|
2273
2292
|
}
|
|
2274
2293
|
|
|
@@ -2289,6 +2308,7 @@ function recordFor(
|
|
|
2289
2308
|
: null,
|
|
2290
2309
|
closed: outcome.closed,
|
|
2291
2310
|
cut: outcome.cut,
|
|
2311
|
+
operatorChecks: outcome.operatorChecks,
|
|
2292
2312
|
relinked: relinkedFor(outcome.relinked, root),
|
|
2293
2313
|
relinkFailed: relinkFailedFor(outcome.relinkFailed, root),
|
|
2294
2314
|
}
|
package/src/hooks/post-merge.ts
CHANGED
|
@@ -161,8 +161,19 @@ function archiveLines(
|
|
|
161
161
|
if (reason === undefined || reason === 'no-match' || reason === 'no-board') {
|
|
162
162
|
return undefined
|
|
163
163
|
}
|
|
164
|
+
// A merged slice moves its own plan while the task stays open, so the line
|
|
165
|
+
// names the plan that dropped off the board row.
|
|
166
|
+
const slice =
|
|
167
|
+
fields.slice !== null && typeof fields.slice === 'object'
|
|
168
|
+
? text(fields.slice as Fields, 'from')
|
|
169
|
+
: undefined
|
|
170
|
+
const moved =
|
|
171
|
+
slice === undefined
|
|
172
|
+
? ''
|
|
173
|
+
: `🗂️ Archived the slice plan ${slice}, and the task stays open.\n`
|
|
164
174
|
return (
|
|
165
175
|
`\n📋 Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
|
|
176
|
+
moved +
|
|
166
177
|
`Run /task-complete archive #${number} to resolve it by hand.\n\n`
|
|
167
178
|
)
|
|
168
179
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Adds the session defaults the seeded `.claude/settings.json` carries to a
|
|
3
|
-
* target that was seeded before them
|
|
3
|
+
* target that was seeded before them: the subagent deny, the memory default, and
|
|
4
|
+
* the merge deny entries.
|
|
4
5
|
*
|
|
5
6
|
* `canon claude init` copies a seed only when the file is absent, so a target
|
|
6
7
|
* holding its own settings never sees a later seed change. This verb adds each
|
|
@@ -21,6 +22,12 @@ import {
|
|
|
21
22
|
/** Both names, since `Task` is the tool's name before Claude Code renamed it. */
|
|
22
23
|
export const DENIED_TOOLS: readonly string[] = ['Agent', 'Task']
|
|
23
24
|
|
|
25
|
+
/** The merge entries the seeded deny list carries, so a session cannot merge. */
|
|
26
|
+
export const MERGE_DENIED: readonly string[] = [
|
|
27
|
+
'Bash(canon pr merge:*)',
|
|
28
|
+
'Bash(gh pr merge:*)',
|
|
29
|
+
]
|
|
30
|
+
|
|
24
31
|
export interface SessionDefaultsPlan {
|
|
25
32
|
readonly keys: readonly SettingsKey[]
|
|
26
33
|
readonly next: SettingsRecord
|
|
@@ -31,6 +38,8 @@ export interface SessionDefaultsPlan {
|
|
|
31
38
|
* on purpose, which is how an operator opts back into subagents.
|
|
32
39
|
*/
|
|
33
40
|
readonly predates: boolean
|
|
41
|
+
/** True when the deny list lacks any merge entry, whatever else it holds. */
|
|
42
|
+
readonly lacksMergeDeny: boolean
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export type SessionDefaultsRead =
|
|
@@ -52,8 +61,8 @@ function isRecord(value: unknown): value is SettingsRecord {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/**
|
|
55
|
-
* Appends the denied tools a list lacks, keeping the list's
|
|
56
|
-
* diff a person reads is the added entries alone.
|
|
64
|
+
* Appends the denied tools and merge entries a list lacks, keeping the list's
|
|
65
|
+
* own order, so the diff a person reads is the added entries alone.
|
|
57
66
|
*/
|
|
58
67
|
export function planSessionDefaults(
|
|
59
68
|
current: SettingsRecord,
|
|
@@ -62,7 +71,9 @@ export function planSessionDefaults(
|
|
|
62
71
|
const permissions = isRecord(current.permissions) ? current.permissions : {}
|
|
63
72
|
const deny = Array.isArray(permissions.deny) ? permissions.deny : []
|
|
64
73
|
|
|
65
|
-
const
|
|
74
|
+
const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
|
|
75
|
+
const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
|
|
76
|
+
const missing = [...subagentsMissing, ...mergeMissing]
|
|
66
77
|
if (missing.length > 0) {
|
|
67
78
|
next.permissions = { ...permissions, deny: [...deny, ...missing] }
|
|
68
79
|
}
|
|
@@ -78,7 +89,11 @@ export function planSessionDefaults(
|
|
|
78
89
|
keys,
|
|
79
90
|
next,
|
|
80
91
|
changed: keys.some((key) => key.changed),
|
|
81
|
-
predates:
|
|
92
|
+
predates:
|
|
93
|
+
memoryAbsent &&
|
|
94
|
+
subagentsMissing.length === DENIED_TOOLS.length &&
|
|
95
|
+
mergeMissing.length === MERGE_DENIED.length,
|
|
96
|
+
lacksMergeDeny: mergeMissing.length > 0,
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { cpSync, existsSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
6
|
+
|
|
7
|
+
/** Where a project names the folders its running preview saves into. */
|
|
8
|
+
export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
|
|
9
|
+
|
|
10
|
+
/** The variable a preview server reads for the copy root when the project names none. */
|
|
11
|
+
export const DEFAULT_PREVIEW_ENV = 'CANON_PREVIEW_ROOT'
|
|
12
|
+
|
|
13
|
+
/** The type vocabulary of `git-branch`'s branch standard, which the slug transform drops. */
|
|
14
|
+
const BRANCH_TYPES = new Set([
|
|
15
|
+
'feat',
|
|
16
|
+
'fix',
|
|
17
|
+
'refactor',
|
|
18
|
+
'docs',
|
|
19
|
+
'chore',
|
|
20
|
+
'perf',
|
|
21
|
+
'test',
|
|
22
|
+
'style',
|
|
23
|
+
'build',
|
|
24
|
+
'ci',
|
|
25
|
+
'revert',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
export interface PreviewConfig {
|
|
29
|
+
readonly writes: readonly string[]
|
|
30
|
+
readonly env: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PreviewConfigRead =
|
|
34
|
+
| { readonly kind: 'absent' }
|
|
35
|
+
| { readonly kind: 'read'; readonly config: PreviewConfig }
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: 'refused'
|
|
38
|
+
readonly reason: 'bad-config' | 'unsafe-path'
|
|
39
|
+
readonly detail: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StatusEntry {
|
|
43
|
+
readonly path: string
|
|
44
|
+
readonly status: string
|
|
45
|
+
/** The content digest, or null where no regular file stands at the path. */
|
|
46
|
+
readonly digest: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The branch-to-slug transform every branch-derived name shares: a leading
|
|
51
|
+
* type segment comes off when a further segment follows it, and each
|
|
52
|
+
* remaining `/` becomes `-`. The worker reads its branch off the worktree and
|
|
53
|
+
* the reviewer off the pull request's head ref, so this one function is what
|
|
54
|
+
* lands both on the same folder.
|
|
55
|
+
*/
|
|
56
|
+
export function branchSlug(branch: string): string {
|
|
57
|
+
const segments = branch.split('/')
|
|
58
|
+
const rest =
|
|
59
|
+
segments.length > 1 && BRANCH_TYPES.has(segments[0] ?? '')
|
|
60
|
+
? segments.slice(1)
|
|
61
|
+
: segments
|
|
62
|
+
return rest.join('-')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isContained(path: string): boolean {
|
|
66
|
+
return (
|
|
67
|
+
path.length > 0 && !isAbsolute(path) && !path.split(/[\\/]/).includes('..')
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads `canon/config/preview.json` as `{ "writes": [...], "env": "<NAME>" }`.
|
|
73
|
+
* A file that does not parse refuses rather than reading as absent, since a
|
|
74
|
+
* typo there would otherwise drive the real folders while the pass reports a
|
|
75
|
+
* copy. A `writes` path that is absolute or climbs with `..` refuses, so the
|
|
76
|
+
* copy never reads or writes outside its two roots.
|
|
77
|
+
*/
|
|
78
|
+
export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
79
|
+
const path = join(worktree, PREVIEW_CONFIG_REL)
|
|
80
|
+
if (!existsSync(path)) return { kind: 'absent' }
|
|
81
|
+
|
|
82
|
+
let parsed: unknown
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
85
|
+
} catch {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'refused',
|
|
88
|
+
reason: 'bad-config',
|
|
89
|
+
detail: `${PREVIEW_CONFIG_REL} does not parse as JSON.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
93
|
+
return {
|
|
94
|
+
kind: 'refused',
|
|
95
|
+
reason: 'bad-config',
|
|
96
|
+
detail: `${PREVIEW_CONFIG_REL} is not an object.`,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { writes, env } = parsed as Record<string, unknown>
|
|
101
|
+
if (
|
|
102
|
+
!Array.isArray(writes) ||
|
|
103
|
+
!writes.every((item): item is string => typeof item === 'string')
|
|
104
|
+
) {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'refused',
|
|
107
|
+
reason: 'bad-config',
|
|
108
|
+
detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (env !== undefined && (typeof env !== 'string' || env === '')) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'refused',
|
|
114
|
+
reason: 'bad-config',
|
|
115
|
+
detail: `${PREVIEW_CONFIG_REL} names an "env" that is not a variable name.`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const unsafe = writes.find((item) => !isContained(item))
|
|
120
|
+
if (unsafe !== undefined) {
|
|
121
|
+
return {
|
|
122
|
+
kind: 'refused',
|
|
123
|
+
reason: 'unsafe-path',
|
|
124
|
+
detail: `${PREVIEW_CONFIG_REL} names "${unsafe}", which leaves the worktree.`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'read',
|
|
130
|
+
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Replaces the copy root with a fresh copy of each declared path, so the last
|
|
136
|
+
* pass's drives never reach the next one. A path the worktree does not hold
|
|
137
|
+
* is named rather than failing the copy.
|
|
138
|
+
*/
|
|
139
|
+
export function buildDriveCopy(
|
|
140
|
+
worktree: string,
|
|
141
|
+
copyRoot: string,
|
|
142
|
+
writes: readonly string[],
|
|
143
|
+
): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
|
|
144
|
+
rmSync(copyRoot, { recursive: true, force: true })
|
|
145
|
+
const copied: string[] = []
|
|
146
|
+
const missing: string[] = []
|
|
147
|
+
for (const path of writes) {
|
|
148
|
+
const source = join(worktree, path)
|
|
149
|
+
if (!existsSync(source)) {
|
|
150
|
+
missing.push(path)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
cpSync(source, join(copyRoot, path), { recursive: true })
|
|
154
|
+
copied.push(path)
|
|
155
|
+
}
|
|
156
|
+
return { copied, missing }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function digestOf(path: string): string | null {
|
|
160
|
+
try {
|
|
161
|
+
if (!statSync(path).isFile()) return null
|
|
162
|
+
return createHash('sha1').update(readFileSync(path)).digest('hex')
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every entry `git status` reports, untracked files listed one by one so a
|
|
170
|
+
* file a drive adds inside a tracked folder names itself, each with a content
|
|
171
|
+
* digest so a file already dirty at the baseline still reads as changed when
|
|
172
|
+
* a drive writes it again. Undefined when git cannot read the tree.
|
|
173
|
+
*/
|
|
174
|
+
export async function readWorktreeStatus(
|
|
175
|
+
worktree: string,
|
|
176
|
+
): Promise<readonly StatusEntry[] | undefined> {
|
|
177
|
+
const result =
|
|
178
|
+
await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
|
|
179
|
+
.env(gitEnv())
|
|
180
|
+
.quiet()
|
|
181
|
+
.nothrow()
|
|
182
|
+
if (result.exitCode !== 0) return undefined
|
|
183
|
+
|
|
184
|
+
const tokens = result.stdout.toString().split('\0')
|
|
185
|
+
const entries: StatusEntry[] = []
|
|
186
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
187
|
+
const token = tokens[index] ?? ''
|
|
188
|
+
if (token.length < 4) continue
|
|
189
|
+
const status = token.slice(0, 2)
|
|
190
|
+
const path = token.slice(3)
|
|
191
|
+
// A rename or copy carries its source as the next token.
|
|
192
|
+
if (status.includes('R') || status.includes('C')) index++
|
|
193
|
+
entries.push({ path, status, digest: digestOf(join(worktree, path)) })
|
|
194
|
+
}
|
|
195
|
+
return entries
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The paths whose status or content differs from the baseline, in the order
|
|
200
|
+
* git reports them. An entry gone since the baseline is not reported, since a
|
|
201
|
+
* worker committing its own edit mid-pass clears it the same way.
|
|
202
|
+
*/
|
|
203
|
+
export function changedSince(
|
|
204
|
+
baseline: readonly StatusEntry[],
|
|
205
|
+
current: readonly StatusEntry[],
|
|
206
|
+
): readonly string[] {
|
|
207
|
+
const before = new Map(baseline.map((entry) => [entry.path, entry]))
|
|
208
|
+
return current
|
|
209
|
+
.filter((entry) => {
|
|
210
|
+
const prior = before.get(entry.path)
|
|
211
|
+
return (
|
|
212
|
+
prior === undefined ||
|
|
213
|
+
prior.status !== entry.status ||
|
|
214
|
+
prior.digest !== entry.digest
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
.map((entry) => entry.path)
|
|
218
|
+
}
|
package/src/pr/lift.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ReviewRow,
|
|
5
5
|
resolveReviewScope,
|
|
6
6
|
resolveUiScope,
|
|
7
|
+
type UiCounts,
|
|
7
8
|
} from '@/pr/review-scope'
|
|
8
9
|
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
9
10
|
|
|
@@ -24,6 +25,7 @@ export type LiftRefusal =
|
|
|
24
25
|
| 'ui-open'
|
|
25
26
|
| 'ui-unmarked'
|
|
26
27
|
| 'ui-behind'
|
|
28
|
+
| 'ui-undriven'
|
|
27
29
|
| 'evidence-owed'
|
|
28
30
|
| 'standing-stale'
|
|
29
31
|
| 'standing-conflict'
|
|
@@ -170,6 +172,20 @@ function carried(gates: readonly GateResult[]): {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Whether a verdict's counts show boxes left undriven and none driven. An empty
|
|
177
|
+
* pass, with nothing not driven either, looked at no box because none was owed.
|
|
178
|
+
*/
|
|
179
|
+
function droveNothing(counts: UiCounts | undefined): boolean {
|
|
180
|
+
return (
|
|
181
|
+
counts !== undefined &&
|
|
182
|
+
counts.failed === 0 &&
|
|
183
|
+
counts.eyes === 0 &&
|
|
184
|
+
counts.passed === 0 &&
|
|
185
|
+
counts.notDriven > 0
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
/**
|
|
174
190
|
* Binds only where the evidence record carries a checklist to drive.
|
|
175
191
|
*
|
|
@@ -192,6 +208,9 @@ export async function uiGate(
|
|
|
192
208
|
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
193
209
|
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
194
210
|
if (scope.commit === undefined) return fail('ui', 'ui-behind')
|
|
211
|
+
// Ahead of the head test so a close carried across commits is refused too.
|
|
212
|
+
// A verdict whose count line did not parse reads as unchecked and goes on.
|
|
213
|
+
if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
|
|
195
214
|
if (coversHead(scope.commit, head)) return pass('ui')
|
|
196
215
|
|
|
197
216
|
const changed = await api.readChanged(scope.commit, head)
|
package/src/pr/local.ts
CHANGED
|
@@ -64,3 +64,29 @@ export function dropAddressLine(body: string): string | undefined {
|
|
|
64
64
|
if (isGapAbove && (lines[first] ?? '').trim() === '') lines.splice(first, 1)
|
|
65
65
|
return lines.join('\n')
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A loopback URL with any port and path, and the backticks or angle brackets a
|
|
70
|
+
* pass may have wrapped it in. These hosts resolve on one machine for one
|
|
71
|
+
* session, so no later reader of the thread can open them.
|
|
72
|
+
*/
|
|
73
|
+
const LOOPBACK_URL =
|
|
74
|
+
/[`<]?https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s`>,]*)?[`>]?/g
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rewrites the loopback address on a UI review's `Driven at` line to "a local
|
|
78
|
+
* preview", keeping the head and the renderer. Only that line is read, so a
|
|
79
|
+
* loopback URL a box reason quotes from the page stays as content. Undefined
|
|
80
|
+
* when no line changes, which is also what a second mask of the same body
|
|
81
|
+
* returns.
|
|
82
|
+
*/
|
|
83
|
+
export function maskLocalAddress(body: string): string | undefined {
|
|
84
|
+
let changed = false
|
|
85
|
+
const lines = body.split('\n').map((line) => {
|
|
86
|
+
if (!line.startsWith('Driven at ')) return line
|
|
87
|
+
const masked = line.replace(LOOPBACK_URL, 'a local preview')
|
|
88
|
+
if (masked !== line) changed = true
|
|
89
|
+
return masked
|
|
90
|
+
})
|
|
91
|
+
return changed ? lines.join('\n') : undefined
|
|
92
|
+
}
|