@erclx/canon 5.40.0 → 5.41.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 (49) hide show
  1. package/README.md +34 -17
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  4. package/claude/skills/auto-ship/SKILL.md +6 -0
  5. package/claude/skills/auto-ship/references/fan-in.md +59 -0
  6. package/claude/skills/create-skill/references/skill.md +1 -1
  7. package/claude/skills/decision-escalate/REQUIREMENT.md +1 -1
  8. package/claude/skills/decision-escalate/SKILL.md +1 -2
  9. package/claude/skills/git-branch/REQUIREMENT.md +17 -6
  10. package/claude/skills/git-branch/SKILL.md +63 -7
  11. package/claude/skills/plan-feature/references/batches.md +48 -1
  12. package/claude/skills/plan-feature/references/plan.md +2 -0
  13. package/claude/skills/plan-intake-answer/REQUIREMENT.md +1 -1
  14. package/claude/skills/plan-intake-answer/SKILL.md +1 -2
  15. package/claude/skills/review-address/references/rebase-conflicts.md +7 -0
  16. package/claude/skills/review-ui/SKILL.md +2 -0
  17. package/claude/skills/review-ui/references/close-out.md +47 -0
  18. package/claude/skills/review-ui/references/post.md +3 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  20. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  21. package/claude/skills/role-worker/SKILL.md +9 -0
  22. package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
  23. package/claude/skills/teach-workspace/SKILL.md +1 -2
  24. package/docs/workflow/parallel-features.md +1 -0
  25. package/docs/workflow/skill-map.md +1 -1
  26. package/package.json +1 -1
  27. package/src/claude/cases/git.ts +4 -0
  28. package/src/commands/pr.ts +32 -9
  29. package/src/commands/records.ts +11 -3
  30. package/src/context/shape-gated.ts +1 -0
  31. package/src/markdown/scope.ts +6 -2
  32. package/src/pr/frames.ts +53 -9
  33. package/src/records/validate.ts +1 -0
  34. package/src/tasks/answers.ts +79 -2
  35. package/src/tasks/fan-in.ts +176 -0
  36. package/src/tasks/stack.ts +1 -1
  37. package/tooling/astro/reference.md +80 -39
  38. package/tooling/base/reference.md +63 -32
  39. package/tooling/base/seeds/canon/context/ci.md +7 -15
  40. package/tooling/base/seeds/canon/context/development.md +14 -14
  41. package/tooling/claude/reference.md +40 -22
  42. package/tooling/cloudflare/reference.md +31 -12
  43. package/tooling/go/reference.md +47 -23
  44. package/tooling/nextjs/reference.md +52 -19
  45. package/tooling/php/reference.md +35 -16
  46. package/tooling/python/reference.md +47 -30
  47. package/tooling/shell/reference.md +6 -5
  48. package/tooling/vite-react/reference.md +49 -21
  49. package/tooling/web/reference.md +130 -70
@@ -61,6 +61,14 @@ Announce the pull request as the ship chain's pull request step returns, carryin
61
61
  - Say the reach was unread rather than clear when the installed binary carries no such subcommand, and leave the undeclared list to the pull request
62
62
  - Carry a row this build turned up with its title, origin, and surface. Send it alone only when the session ends before a pull request opens.
63
63
 
64
+ ### A fan-in member
65
+
66
+ A member of a fan-in set opens no pull request, so its done message stands in for the announcement. `auto-ship` sends it after the push.
67
+
68
+ - Carry the branch, the head commit, and the integrator the plan names in place of a pull request number
69
+ - Carry no reach, since the integrator's announcement carries it once for the whole set
70
+ - An integrator is an ordinary worker and owes the announcement above
71
+
64
72
  ### Re-review and blocks
65
73
 
66
74
  Ask for the re-review as `review-address` Step 8 returns, carrying what was addressed and the PR's new CI state. That transition is the other moment only this session knows, and the message starts the re-review.
@@ -117,5 +125,6 @@ Never concede on the name alone or proceed as if the sets never collided. A work
117
125
  ## Lifetime
118
126
 
119
127
  - The role runs from the worktree entry to the merge of the branch it built, and nothing carries to a second row, which is a new build under a new plan
128
+ - A fan-in member's role ends at its push, since the integrator merges the branch and fixes on its own branch whatever the merge surfaces
120
129
  - A talk session outlives the merge and ends when the operator says the conversation is over
121
130
  - Treat this body as possibly older than the branch under it. A plugin skill loads from the marketplace cache, so a session changing its own skills may hold the copy from before the last update.
@@ -62,4 +62,4 @@ Two failures land specifically on where the folder sits. A workspace resolved ag
62
62
  - Creating a wiki folder a project never chose
63
63
  - What the verbs do internally, which is the CLI domain's own contract rather than this skill's, and the records backup that carries the folder off one disk
64
64
  - What a lesson renders as and how a quiz behaves once rendered, which the lesson-craft reference shapes and a rendering layer executes
65
- - Deciding when to fire. The skill is user-invoked through `disable-model-invocation`, so opening a workspace is the learner's call rather than a description match.
65
+ - Deciding when to fire. The description states `Do NOT auto-trigger`, so opening a workspace is the learner's call rather than a description match.
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  name: teach-workspace
3
- description: Opens and runs a learning workspace on one subject, holding a mission, resources, numbered lessons, reference pages, a glossary, and learning records that survive across sessions, proposes where a durable page from one belongs once it outgrows the workspace, and lands the confirmed pages from a branch. Use when asked to "teach me X", "open a learning workspace", "I want to learn X", "quiz me on this", "continue the lesson", "resume my workspace on X", "promote this reference page", or "fold the promoted pages in". Do NOT use to write project documentation, which belongs to the surface owning that document, and do NOT use to answer one question, which is an ordinary reply.
4
- disable-model-invocation: true
3
+ description: Opens and runs a learning workspace on one subject, holding a mission, resources, numbered lessons, reference pages, a glossary, and learning records that survive across sessions, proposes where a durable page from one belongs once it outgrows the workspace, and lands the confirmed pages from a branch. Use when asked to "teach me X", "open a learning workspace", "I want to learn X", "quiz me on this", "continue the lesson", "resume my workspace on X", "promote this reference page", or "fold the promoted pages in". Do NOT use to write project documentation, which belongs to the surface owning that document, and do NOT use to answer one question, which is an ordinary reply. Do NOT auto-trigger.
5
4
  argument-hint: <subject to learn, the topic of a workspace to resume or promote, or fold>
6
5
  metadata:
7
6
  family: answer
@@ -41,6 +41,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
41
41
 
42
42
  Merge order matters only when two branches touch the same file. Expect to rebase over `CLAUDE.md`, `tooling/**`, `canon/context/context-model.md`, `docs/agents/`, and any folder's `index.md`. Resolve an `index.md` conflict with `canon indexes regen` over the rebased tree.
43
43
 
44
+ - **Fan-in.** Batches of one change sharing edge files ship as one pull request, opened by an integrator that merges each member branch. See `claude/skills/plan-feature/references/batches.md`.
44
45
  - **Merge order.** Merge the smallest hotspot footprint first, and a branch touching `CLAUDE.md` or a context entry last.
45
46
  - **Rebase.** The branch merging second runs `/review-address`, which trial-merges against `origin/main` and rebases. Push, then merge.
46
47
  - **Clean up.** Run `/git-worktree cleanup` to remove merged worktrees. To start fresh, `ExitWorktree(action: "keep")`, then `/session-worktree <new-name>`.
@@ -67,7 +67,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
67
67
  | `canon:docs-sync` | A change left `README.md` or `docs/` stale |
68
68
  | `canon:git-stage` | Staged changes span several concerns |
69
69
  | `canon:git-commit` | Staged changes are one concern |
70
- | `canon:git-branch` | A branch name needs generating or renaming |
70
+ | `canon:git-branch` | A branch needs creating, naming, or renaming |
71
71
  | `canon:git-pr` | A pull request needs a title and body |
72
72
 
73
73
  ## After the pull request opens
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.40.0",
4
+ "version": "5.41.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -6,6 +6,10 @@ export const GIT_CASES: readonly SkillCase[] = [
6
6
  prompt: 'Come up with a conventional name for this branch.',
7
7
  expect: 'git-branch',
8
8
  },
9
+ {
10
+ prompt: 'I am on main and about to start a feature, create its branch.',
11
+ expect: 'git-branch',
12
+ },
9
13
  {
10
14
  prompt: "Turn what's staged into one commit message.",
11
15
  expect: 'git-commit',
@@ -57,6 +57,7 @@ import {
57
57
  pruneFramesWithLegacy,
58
58
  pushFrame,
59
59
  readFrame,
60
+ referenceSlug,
60
61
  type TreeEntry,
61
62
  } from '@/pr/frames'
62
63
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
@@ -184,6 +185,7 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
184
185
  interface FramesOptions extends ReadOptions {
185
186
  readonly add?: string
186
187
  readonly box?: string
188
+ readonly reference?: string
187
189
  readonly head?: string
188
190
  readonly pass?: string
189
191
  readonly theme?: string
@@ -196,6 +198,7 @@ type FramesCommandRefusal =
196
198
  | 'bad-mode'
197
199
  | 'no-number'
198
200
  | 'bad-box'
201
+ | 'bad-reference'
199
202
  | 'bad-head'
200
203
  | 'bad-pass'
201
204
  | 'bad-theme'
@@ -212,13 +215,15 @@ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
212
215
  'no-number':
213
216
  '--add and --drop write for one pull request, so name its number.',
214
217
  'bad-box':
215
- '--add takes --box <n>, a positive whole number naming the checklist box.',
218
+ '--add takes --box <n>, a positive whole number naming the checklist box, or --reference <page>/<frame>, never both.',
219
+ 'bad-reference':
220
+ '--reference takes <page>/<frame>, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
216
221
  'bad-head':
217
222
  '--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
218
223
  'bad-pass':
219
224
  '--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
220
225
  'bad-theme':
221
- '--theme takes dark or light and goes with --add, so a second shot of one box never replaces the first.',
226
+ '--theme takes dark or light and goes with --add --box, so a second shot of one box never replaces the first.',
222
227
  'bad-days': '--prune takes a positive whole number of days.',
223
228
  'unreadable-frame':
224
229
  'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
@@ -1011,6 +1016,10 @@ export function register(program: Command): void {
1011
1016
  .option('--json', 'Add a machine-readable record on stdout')
1012
1017
  .option('--add <file>', 'Push this PNG as a frame')
1013
1018
  .option('--box <n>', 'The checklist box the frame shows, with --add')
1019
+ .option(
1020
+ '--reference <address>',
1021
+ 'The design reference the capture shows, as <page>/<frame>, with --add in place of --box',
1022
+ )
1014
1023
  .option(
1015
1024
  '--head <sha>',
1016
1025
  "The head the frame was driven at, with --add, defaulting to the pull request's",
@@ -1035,7 +1044,8 @@ export function register(program: Command): void {
1035
1044
  `An add writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
1036
1045
  'so nothing is checked out and no other branch is touched. A frame lands',
1037
1046
  'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
1038
- 'with --theme, and `link` names that',
1047
+ 'with --theme, or ref-<page>--<frame>.png for a design reference capture',
1048
+ 'passed as --reference <page>/<frame> in place of --box, and `link` names that',
1039
1049
  `path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
1040
1050
  'requests and resolves until its own pull request is dropped. The pass',
1041
1051
  'segment keeps a second pass at one head and box from replacing an image',
@@ -1056,10 +1066,11 @@ export function register(program: Command): void {
1056
1066
  ' ok an add pushed, or a drop or prune finished',
1057
1067
  ' bad-mode not exactly one of --add, --drop, --prune',
1058
1068
  ' no-number --add or --drop named no pull request',
1059
- ' bad-box --add without a positive --box',
1069
+ ' bad-box --add with both --box and --reference, or neither',
1070
+ ' bad-reference --reference is not <page>/<frame> in the safe name form',
1060
1071
  ' bad-head --head is not 7 to 40 lowercase hex characters',
1061
1072
  ' bad-pass --add without a compact UTC --pass stamp',
1062
- ' bad-theme --theme is not dark or light, or no --add',
1073
+ ' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
1063
1074
  ' bad-days --prune without a positive number of days',
1064
1075
  ' unreadable-frame the --add file is missing, empty, or not a PNG',
1065
1076
  ' read-only GitHub refused the write, as on a fork token',
@@ -3084,10 +3095,18 @@ function framesArgumentRefusal(
3084
3095
  if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
3085
3096
  if (
3086
3097
  opts.add !== undefined &&
3087
- (opts.box === undefined || !POSITIVE_WHOLE.test(opts.box))
3098
+ (opts.box === undefined) === (opts.reference === undefined)
3088
3099
  ) {
3089
3100
  return 'bad-box'
3090
3101
  }
3102
+ if (opts.add !== undefined && opts.box !== undefined) {
3103
+ if (!POSITIVE_WHOLE.test(opts.box)) return 'bad-box'
3104
+ }
3105
+ if (opts.reference !== undefined) {
3106
+ if (opts.add === undefined || referenceSlug(opts.reference) === undefined) {
3107
+ return 'bad-reference'
3108
+ }
3109
+ }
3091
3110
  if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
3092
3111
  if (
3093
3112
  opts.add !== undefined &&
@@ -3097,7 +3116,9 @@ function framesArgumentRefusal(
3097
3116
  }
3098
3117
  if (
3099
3118
  opts.theme !== undefined &&
3100
- (opts.add === undefined || !isFrameTheme(opts.theme))
3119
+ (opts.add === undefined ||
3120
+ opts.reference !== undefined ||
3121
+ !isFrameTheme(opts.theme))
3101
3122
  ) {
3102
3123
  return 'bad-theme'
3103
3124
  }
@@ -3199,7 +3220,8 @@ async function runFrames(
3199
3220
  if (head === undefined || head === '') return refuseFrames('no-object-head')
3200
3221
  if (!isFrameHead(head)) return refuseFrames('bad-head')
3201
3222
 
3202
- const box = Number(opts.box)
3223
+ const box = opts.box === undefined ? undefined : Number(opts.box)
3224
+ const reference = opts.reference
3203
3225
  const pass = opts.pass ?? ''
3204
3226
  const theme = opts.theme as FrameTheme | undefined
3205
3227
  const result = await pushFrame(api, {
@@ -3207,6 +3229,7 @@ async function runFrames(
3207
3229
  head,
3208
3230
  pass,
3209
3231
  box,
3232
+ reference,
3210
3233
  theme,
3211
3234
  bytes: bytes ?? new Uint8Array(),
3212
3235
  })
@@ -3215,7 +3238,7 @@ async function runFrames(
3215
3238
  return finish(
3216
3239
  {
3217
3240
  number: pull,
3218
- box,
3241
+ ...(box === undefined ? { reference } : { box }),
3219
3242
  ...(theme === undefined ? {} : { theme }),
3220
3243
  head,
3221
3244
  pass,
@@ -44,6 +44,7 @@ import {
44
44
  plural,
45
45
  } from '@/cli/ui'
46
46
  import { currentWorktreeRoot, mainWorktreeRoot } from '@/git/worktree'
47
+ import { fanInFindings } from '@/tasks/fan-in'
47
48
  import { readLivePlans, stackFindings } from '@/tasks/stack'
48
49
 
49
50
  /** Returned when a record carries a finding, which is the gating result. */
@@ -742,7 +743,7 @@ async function runValidate(
742
743
  }
743
744
 
744
745
  /**
745
- * Appends the one plans finding that reads across files. It sits in the command
746
+ * Appends the plans findings that read across files. They sit in the command
746
747
  * layer because `src/records/` sits below `src/tasks/`.
747
748
  */
748
749
  async function validateWithStacks(
@@ -752,9 +753,16 @@ async function validateWithStacks(
752
753
  const outcome = await validateRecords(root, kind)
753
754
  if (!outcome.ok || kind !== 'plans') return outcome
754
755
 
755
- const stacked = stackFindings(await readLivePlans(root))
756
+ const live = await readLivePlans(root)
756
757
 
757
- return { ...outcome, findings: [...outcome.findings, ...stacked] }
758
+ return {
759
+ ...outcome,
760
+ findings: [
761
+ ...outcome.findings,
762
+ ...stackFindings(live),
763
+ ...fanInFindings(live),
764
+ ],
765
+ }
758
766
  }
759
767
 
760
768
  function report(
@@ -19,6 +19,7 @@ export const SHAPE_GATED_FOLDERS: readonly string[] = [
19
19
  'canon/context/features/',
20
20
  'canon/context/surfaces/',
21
21
  'canon/context/workflow/',
22
+ 'tooling/base/seeds/',
22
23
  ]
23
24
 
24
25
  /** Whether a repository-relative entry path sits under a gated prefix. */
@@ -128,6 +128,10 @@ export const GATED_PATHS: readonly string[] = [
128
128
  'claude/skills/youtube-transcripts/SKILL.md',
129
129
  '.claude/skills/internal-',
130
130
  'docs/',
131
+ 'tooling/',
132
+ 'wiki/',
133
+ 'README.md',
134
+ 'CONTRIBUTING.md',
131
135
  ]
132
136
 
133
137
  /**
@@ -136,8 +140,8 @@ export const GATED_PATHS: readonly string[] = [
136
140
  * entries, the docs pages, and the skill bodies and references through a stage
137
141
  * of its own before this measure existed, and waiting on `GATED_PATHS` would
138
142
  * reopen that hole. The seeds, the stack references, the wiki, and the root
139
- * guides were never held to it, and seven runs in the stack references would
140
- * fail the push on the day this lands, so those wait for their batch.
143
+ * guides were never held to it, so they joined `GATED_PATHS` only once their
144
+ * rewrite brought them to zero.
141
145
  */
142
146
  const ALWAYS_GATED: readonly DensityKind[] = ['bullet-run']
143
147
 
package/src/pr/frames.ts CHANGED
@@ -19,6 +19,9 @@ const PASS_PATTERN = /^\d{8}T\d{6}Z$/
19
19
  /** How many times an add rebuilds on a tip another writer moved. */
20
20
  const ADD_ATTEMPTS = 3
21
21
 
22
+ /** One half of a reference address. It opens on a letter or digit and holds no separator, so `..` and `/` cannot survive it. */
23
+ const REFERENCE_PART = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
24
+
22
25
  const FOLDER_PATTERN = /^pr-(\d+)$/
23
26
 
24
27
  const DAY_MS = 86_400_000
@@ -77,7 +80,8 @@ export interface FrameInput {
77
80
  readonly number: number
78
81
  readonly head: string
79
82
  readonly pass: string
80
- readonly box: number
83
+ readonly box?: number
84
+ readonly reference?: string
81
85
  readonly theme?: FrameTheme
82
86
  readonly bytes: Uint8Array
83
87
  }
@@ -158,6 +162,39 @@ export function frameLink(repo: string, path: string): string {
158
162
  return `https://github.com/${repo}/blob/${FRAMES_BRANCH}/${path}?raw=true`
159
163
  }
160
164
 
165
+ /**
166
+ * The file-name slug of a `<page>/<frame>` address, or undefined when the
167
+ * address would not round-trip. Each half must open on a letter or digit and
168
+ * hold only letters, digits, dots, underscores, and dashes, and neither may
169
+ * carry `--`, so the joined slug reads back as the same two names and a path
170
+ * never gains a directory or escapes the pass folder.
171
+ */
172
+ export function referenceSlug(address: string): string | undefined {
173
+ const parts = address.split('/')
174
+ if (parts.length !== 2) return undefined
175
+ const [page, frame] = parts as [string, string]
176
+ const valid = [page, frame].every(
177
+ (part) => REFERENCE_PART.test(part) && !part.includes('--'),
178
+ )
179
+ return valid ? `${page}--${frame}` : undefined
180
+ }
181
+
182
+ /**
183
+ * Where a design reference capture lands, beside the evidence frames of one
184
+ * pass. The slug repeats for one reference, so a second capture in a pass
185
+ * overwrites only itself. Undefined when the address has no valid slug.
186
+ */
187
+ export function referencePath(
188
+ number: number,
189
+ head: string,
190
+ pass: string,
191
+ address: string,
192
+ ): string | undefined {
193
+ const slug = referenceSlug(address)
194
+ if (slug === undefined) return undefined
195
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/ref-${slug}.png`
196
+ }
197
+
161
198
  /** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
162
199
  export async function readFrame(path: string): Promise<Uint8Array | undefined> {
163
200
  const bytes = await readFile(path).catch(() => undefined)
@@ -179,17 +216,24 @@ export async function pushFrame(
179
216
  api: FramesApi,
180
217
  input: FrameInput,
181
218
  ): Promise<PushResult> {
182
- const path = framePath(
183
- input.number,
184
- input.head,
185
- input.pass,
186
- input.box,
187
- input.theme,
188
- )
219
+ const path =
220
+ input.reference === undefined
221
+ ? framePath(
222
+ input.number,
223
+ input.head,
224
+ input.pass,
225
+ input.box ?? 0,
226
+ input.theme,
227
+ )
228
+ : referencePath(input.number, input.head, input.pass, input.reference)
229
+ if (path === undefined) return { kind: 'refused', reason: 'push-failed' }
189
230
  const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
190
231
  if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
191
232
  const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
192
- const message = `chore(frames): add box ${input.box} of #${input.number}`
233
+ const message =
234
+ input.reference === undefined
235
+ ? `chore(frames): add box ${input.box} of #${input.number}`
236
+ : `chore(frames): add reference ${input.reference} of #${input.number}`
193
237
 
194
238
  for (let attempt = 0; attempt < ADD_ATTEMPTS; attempt += 1) {
195
239
  const tip = await api.readTip()
@@ -48,6 +48,7 @@ export const FINDING_KINDS = [
48
48
  'batch-unsplit',
49
49
  'source-unquoted',
50
50
  'stack-foldable',
51
+ 'fan-in-unmatched',
51
52
  ] as const
52
53
 
53
54
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -1,8 +1,15 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { basename, isAbsolute, relative, resolve } from 'node:path'
4
+ import { branchRefs } from '@/git/worktree'
5
+ import { fanInBranch, fanInFindings, readFanInMembers } from '@/tasks/fan-in'
4
6
  import { isUnder } from '@/tasks/paths'
5
- import { JUDGED_APART_REMEDY, readLivePlans, stackPairs } from '@/tasks/stack'
7
+ import {
8
+ JUDGED_APART_REMEDY,
9
+ readLivePlans,
10
+ type StackPlan,
11
+ stackPairs,
12
+ } from '@/tasks/stack'
6
13
  import { recordDir, recordDirs } from '@/roots/record'
7
14
  import {
8
15
  hasStagedBatches,
@@ -49,6 +56,28 @@ export interface PlanAnswers {
49
56
 
50
57
  export type AnswersOutcome = PlanAnswers | AnswersRefused
51
58
 
59
+ /**
60
+ * Whether `refs/remotes/origin/<branch>` exists, or `null` when the read
61
+ * itself failed. Injected so the gate is testable without a repository.
62
+ */
63
+ export type RemoteBranchReader = (
64
+ root: string,
65
+ branch: string,
66
+ ) => Promise<boolean | null>
67
+
68
+ /**
69
+ * Reads the remote-tracking ref as the last fetch left it, so a member pushed
70
+ * since then reads as absent and holds its integrator, which fails safe. The
71
+ * local head is not read, since it exists mid-build and a pushed branch is the
72
+ * only signal a member writes when it is done.
73
+ */
74
+ export const originBranchPushed: RemoteBranchReader = async (root, branch) => {
75
+ const report = await branchRefs(branch, root)
76
+ if (!report.readable) return null
77
+
78
+ return report.refs.includes(`refs/remotes/origin/${branch}`)
79
+ }
80
+
52
81
  /**
53
82
  * A reference that named a live plan, carrying the absolute path a reader opens
54
83
  * and the root-relative spelling a record reports.
@@ -217,6 +246,7 @@ export function resolvePlanReference(
217
246
  export async function planAnswers(
218
247
  root: string,
219
248
  reference: string,
249
+ readRemoteBranch: RemoteBranchReader = originBranchPushed,
220
250
  ): Promise<AnswersOutcome> {
221
251
  const resolved = resolvePlanReference(root, reference)
222
252
  if (!resolved.ok) return resolved
@@ -233,7 +263,8 @@ export async function planAnswers(
233
263
  }
234
264
 
235
265
  const stem = basename(resolved.path, '.md')
236
- const pairs = stackPairs(await readLivePlans(root))
266
+ const live = await readLivePlans(root)
267
+ const pairs = stackPairs(live)
237
268
 
238
269
  for (const { child, parent, shared } of pairs) {
239
270
  if (child !== stem) continue
@@ -244,6 +275,8 @@ export async function planAnswers(
244
275
  })
245
276
  }
246
277
 
278
+ open.push(...(await fanInEntries(root, stem, text, live, readRemoteBranch)))
279
+
247
280
  return {
248
281
  ok: true,
249
282
  plan: resolved.plan,
@@ -252,6 +285,50 @@ export async function planAnswers(
252
285
  }
253
286
  }
254
287
 
288
+ /**
289
+ * A plan whose fan-in set disagrees holds on `Fan-in set`, on either side,
290
+ * since an integrator listing an archived member would otherwise read that
291
+ * member's leftover branch as done. An integrator whose set agrees holds on
292
+ * `Fan-in pending` until every member branch is on origin.
293
+ */
294
+ async function fanInEntries(
295
+ root: string,
296
+ stem: string,
297
+ text: string,
298
+ live: readonly StackPlan[],
299
+ readRemoteBranch: RemoteBranchReader,
300
+ ): Promise<OpenQuestion[]> {
301
+ const entries: OpenQuestion[] = fanInFindings(live)
302
+ .filter(({ record }) => record === `${stem}.md`)
303
+ .map(({ message }) => ({ label: 'Fan-in set', why: message }))
304
+
305
+ const branches = readFanInMembers(text).map(fanInBranch)
306
+ if (branches.length === 0 || entries.length > 0) return entries
307
+
308
+ const pushed = await Promise.all(
309
+ branches.map((branch) => readRemoteBranch(root, branch)),
310
+ )
311
+
312
+ if (pushed.includes(null)) {
313
+ return [
314
+ {
315
+ label: 'Fan-in pending',
316
+ why: 'git could not read the member branches on origin, so the set is unread rather than done.',
317
+ },
318
+ ]
319
+ }
320
+
321
+ const absent = branches.filter((_, index) => pushed[index] !== true)
322
+ if (absent.length === 0) return []
323
+
324
+ return [
325
+ {
326
+ label: 'Fan-in pending',
327
+ why: `waits on ${absent.map((branch) => `\`${branch}\``).join(', ')} reaching origin. Fetch, then read again.`,
328
+ },
329
+ ]
330
+ }
331
+
255
332
  function refuse(
256
333
  reason: AnswerRefusal,
257
334
  message: string,