@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.
Files changed (67) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/git-pr/references/closing-issues.md +2 -0
  4. package/claude/skills/plan-feature/SKILL.md +18 -15
  5. package/claude/skills/plan-feature/references/batches.md +5 -5
  6. package/claude/skills/plan-feature/references/plan.md +82 -33
  7. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  8. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  9. package/claude/skills/plan-intake/SKILL.md +8 -0
  10. package/claude/skills/plan-intake/references/intake.md +9 -1
  11. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  12. package/claude/skills/plan-ready/SKILL.md +3 -3
  13. package/claude/skills/plan-ready/references/assembly.md +7 -7
  14. package/claude/skills/plan-ready/references/ready.md +8 -6
  15. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  16. package/claude/skills/review-ui/SKILL.md +7 -14
  17. package/claude/skills/review-ui/references/close-out.md +1 -1
  18. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  19. package/claude/skills/review-ui/references/post.md +36 -3
  20. package/claude/skills/review-ui/references/tick.md +4 -1
  21. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  22. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  30. package/claude/skills/role-planner/SKILL.md +1 -1
  31. package/claude/skills/role-reviewer/SKILL.md +1 -1
  32. package/claude/skills/role-worker/SKILL.md +4 -2
  33. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  34. package/claude/skills/session-map/references/session.md +1 -1
  35. package/claude/skills/task-board/references/board.md +2 -2
  36. package/claude/skills/task-board/references/tasks.md +5 -3
  37. package/claude/skills/task-complete/SKILL.md +1 -0
  38. package/claude/skills/task-complete/references/archive.md +2 -1
  39. package/docs/agents/migrate.md +2 -2
  40. package/docs/agents/records.md +1 -1
  41. package/docs/target/migrations.md +4 -2
  42. package/docs/workflow/operating-model.md +2 -2
  43. package/docs/workflow/parallel-features.md +2 -2
  44. package/package.json +1 -1
  45. package/src/commands/claude.ts +4 -0
  46. package/src/commands/migrate.ts +5 -4
  47. package/src/commands/pr.ts +712 -58
  48. package/src/commands/records.ts +45 -8
  49. package/src/commands/tasks.ts +21 -1
  50. package/src/hooks/post-merge.ts +11 -0
  51. package/src/migrate/session-defaults.ts +20 -5
  52. package/src/pr/drive-copy.ts +218 -0
  53. package/src/pr/lift.ts +19 -0
  54. package/src/pr/local.ts +26 -0
  55. package/src/pr/merge.ts +175 -0
  56. package/src/pr/rest.ts +3 -0
  57. package/src/pr/review-scope.ts +40 -1
  58. package/src/pr/tick.ts +21 -1
  59. package/src/records/structure.ts +147 -0
  60. package/src/records/validate.ts +113 -12
  61. package/src/tasks/archive.ts +154 -8
  62. package/src/tasks/board.ts +68 -13
  63. package/src/tasks/closes.ts +9 -5
  64. package/src/tasks/validate.ts +43 -4
  65. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  66. package/tooling/claude/reference.md +3 -1
  67. package/tooling/claude/seeds/.claude/settings.json +1 -1
@@ -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 every check passed',
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(async (kind: string, opts: ValidateCommandOptions) => {
124
- process.exitCode = await runValidate(kind, opts)
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
- return report(await validateWithStacks(root, kind), emitJson, root)
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
- ...stackFindings(live),
763
- ...fanInFindings(live),
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,
@@ -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
  }
@@ -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 own order, so the
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 missing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
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: memoryAbsent && missing.length === DENIED_TOOLS.length,
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
+ }