@alecsibilia/luca 14.0.0-alpha.10 → 14.0.0-alpha.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.11
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.10
4
8
 
5
9
  No changes in this release.
package/README.md CHANGED
@@ -49,7 +49,7 @@ After a run, type `/luca-retro` the same way to look back on it: it reads the ru
49
49
  | `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`), and Luca's own `/luca-unstick` and `/luca-retro` skills. It asks before turning on Paseo's plugins, and leaves skills you already have alone (it only replaces its own two). `--skip-skills` skips the planning skills. Inside a git repo, it then runs `luca setup` for that repo. |
50
50
  | `luca setup` | Gets the repo in this folder ready: the labels a run needs (and the `release:*` labels when the repo uses changesets), `.luca/config.json` (a new one gets the repo's GitHub name as its vault; an existing vault is kept), and a check of GitHub sub-issues and dependencies. `--base <branch>` names the base branch. |
51
51
  | `luca doctor` | Checks this computer, this repo when you're in one, and what v13 left behind. Each problem comes with its exact fix. `luca doctor --fix` fixes what's safe without asking: it never deletes (v13 files go to a dated backup folder) and never commits, and it lists the repo files to commit. |
52
- | `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then it reloads the board, copies the new `/luca-unstick` and `/luca-retro` skills, and says when to run `/reload-skills`. |
52
+ | `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then the version it just installed finishes the job with its own code: it reloads the board, copies its skills (every one it ships, new ones included), and says when to run `/reload-skills`. |
53
53
  | `luca upgrade --to <version>` | Installs that exact version, an older one too, to go back when a version breaks. |
54
54
 
55
55
  `init`, `setup`, and `upgrade` end with their part of `luca doctor`'s checks.
@@ -92,7 +92,7 @@ This is the one package that goes to npm. It's TypeScript source with no build a
92
92
  | ------ | ---------- |
93
93
  | `engine/` | The engine's source, from [`packages/engine/src`](../engine/src). Both bins point into it. |
94
94
  | `board/` | The board plugin's folder, from [`packages/board`](../board): `paseo-plugin.json`, its entry points, its client, server, and shared code, and its own `package.json`, so Paseo can install it as a folder source. |
95
- | `skills/` | Luca's own Claude Code skills, `luca-unstick` and `luca-retro`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy them to `~/.claude/skills`. |
95
+ | `skills/` | Luca's own Claude Code skills, `luca-unstick` and `luca-retro`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy every folder in it with a `SKILL.md` to `~/.claude/skills`. |
96
96
  | `LICENSE` | The repo's license. |
97
97
 
98
98
  Test files and test helpers stay out. The engine and the board stay private workspace packages, and this package lists every package they import at runtime as its own dependency.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.10",
3
+ "version": "14.0.0-alpha.11",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.10"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.11"
@@ -400,7 +400,7 @@ const PackageJsonSchema = z.looseObject({
400
400
  })
401
401
 
402
402
  /** The package a file belongs to: the nearest package.json with a name. */
403
- const packageOf = async (
403
+ export const packageOf = async (
404
404
  file: string
405
405
  ): Promise<{ name: string; version: string | null } | null> => {
406
406
  for (let dir = dirname(file); ; dir = dirname(dir)) {
@@ -329,8 +329,8 @@ const checkPlanningSkills = async ({ home }: { home: string }) => {
329
329
  }
330
330
 
331
331
  /**
332
- * Luca's own skills (#504): each is in `~/.claude/skills`, and its files
333
- * are the ones the installed Luca ships.
332
+ * Luca's own skills (#504): each one in the installed Luca's skills folder
333
+ * (#529) is in `~/.claude/skills`, and its files are the ones it ships.
334
334
  */
335
335
  const checkLucaSkills = async ({
336
336
  home,
@@ -342,10 +342,9 @@ const checkLucaSkills = async ({
342
342
  luca_version: string
343
343
  }) => {
344
344
  const drift = await skillDrift({ home, skills_dir })
345
- const unshipped = drift.filter(({ unshipped }) => unshipped)
346
- if (unshipped.length > 0) {
345
+ if (drift.length === 0) {
347
346
  return problem({
348
- detail: `Luca's folder ${skills_dir} has no ${unshipped.map(({ skill }) => skill).join(', ')} skill.`,
347
+ detail: `Luca's folder ${skills_dir} has no skills.`,
349
348
  fix: `Reinstall Luca: bun add -g ${LUCA_PACKAGE}@alpha`,
350
349
  })
351
350
  }
@@ -5,20 +5,22 @@ import type { StepEnd } from './board-in-paseo'
5
5
  import { reason } from './doctor-checks'
6
6
 
7
7
  /**
8
- * Luca's own Claude Code skills: `/luca-unstick` (#504) and `/luca-retro`
9
- * (#514). They ship in the package's `skills/` folder, next to `engine/`
10
- * and `board/` (`packages/engine/skills/` in the repo). `luca init` and `luca upgrade`
11
- * copy them to `~/.claude/skills/<skill>/`, so they work in any repo and
12
- * any Paseo chat, and `luca doctor` checks the copy is the one the
13
- * installed Luca ships (`luca doctor --fix` copies it again).
8
+ * Luca's own Claude Code skills, such as `/luca-unstick` (#504) and
9
+ * `/luca-retro` (#514). They ship in the package's `skills/` folder, next
10
+ * to `engine/` and `board/` (`packages/engine/skills/` in the repo). `luca
11
+ * init` and `luca upgrade` copy them to `~/.claude/skills/<skill>/`, so
12
+ * they work in any repo and any Paseo chat, and `luca doctor` checks the
13
+ * copy is the one the installed Luca ships (`luca doctor --fix` copies it
14
+ * again).
15
+ *
16
+ * Which skills there are comes from that folder, not a list in the code
17
+ * (#529): every folder in it with a `SKILL.md`. So a skill new in a
18
+ * release is copied and checked even by code from before it.
14
19
  *
15
20
  * A copy writes every file the skill ships (not its tests), over Luca's own
16
21
  * earlier copy. It never deletes, and never touches other skills.
17
22
  */
18
23
 
19
- /** Luca's own skills, by folder name. */
20
- export const LUCA_SKILLS = ['luca-unstick', 'luca-retro']
21
-
22
24
  /** Test files stay in the repo: they aren't part of a skill. */
23
25
  const isTestFile = (path: string) => /\.test\.[cm]?[jt]sx?$/.test(path)
24
26
 
@@ -26,6 +28,31 @@ const isTestFile = (path: string) => /\.test\.[cm]?[jt]sx?$/.test(path)
26
28
  export const claudeSkillsDir = ({ home }: { home: string }): string =>
27
29
  join(home, '.claude', 'skills')
28
30
 
31
+ /**
32
+ * Luca's own skills in `skills_dir`, by folder name, sorted: each folder
33
+ * with a `SKILL.md`. None when the folder is missing.
34
+ *
35
+ * @example
36
+ * await lucaSkills({ skills_dir }) // ['luca-retro', 'luca-unstick']
37
+ */
38
+ export const lucaSkills = async ({
39
+ skills_dir,
40
+ }: {
41
+ skills_dir: string
42
+ }): Promise<string[]> => {
43
+ try {
44
+ const manifests = await Array.fromAsync(
45
+ new Bun.Glob('*/SKILL.md').scan({
46
+ cwd: skills_dir,
47
+ onlyFiles: true,
48
+ })
49
+ )
50
+ return manifests.map((manifest) => dirname(manifest)).toSorted()
51
+ } catch {
52
+ return []
53
+ }
54
+ }
55
+
29
56
  /**
30
57
  * The files one skill ships, relative to its folder in `skills_dir`,
31
58
  * sorted: every file but tests. None when the folder is missing.
@@ -57,8 +84,6 @@ export const skillFiles = async ({
57
84
  /** How one installed skill differs from the one Luca ships. */
58
85
  export type SkillDrift = {
59
86
  skill: string
60
- /** Luca's install folder has no files for it. */
61
- unshipped: boolean
62
87
  /** Shipped files the installed copy lacks. */
63
88
  missing: string[]
64
89
  /** Shipped files whose installed copy has other content. */
@@ -67,7 +92,8 @@ export type SkillDrift = {
67
92
 
68
93
  /**
69
94
  * How each of Luca's skills in `~/.claude/skills` differs from the ones in
70
- * `skills_dir`; a skill that matches has empty lists. Read-only.
95
+ * `skills_dir`; a skill that matches has empty lists. None when
96
+ * `skills_dir` has no skills. Read-only.
71
97
  */
72
98
  export const skillDrift = async ({
73
99
  home,
@@ -77,7 +103,7 @@ export const skillDrift = async ({
77
103
  skills_dir: string
78
104
  }): Promise<SkillDrift[]> => {
79
105
  const drift: SkillDrift[] = []
80
- for (const skill of LUCA_SKILLS) {
106
+ for (const skill of await lucaSkills({ skills_dir })) {
81
107
  const files = await skillFiles({ skills_dir, skill })
82
108
  const missing: string[] = []
83
109
  const changed: string[] = []
@@ -94,7 +120,7 @@ export const skillDrift = async ({
94
120
  changed.push(file)
95
121
  }
96
122
  }
97
- drift.push({ skill, unshipped: files.length === 0, missing, changed })
123
+ drift.push({ skill, missing, changed })
98
124
  }
99
125
  return drift
100
126
  }
@@ -107,7 +133,7 @@ export const skillDrift = async ({
107
133
  *
108
134
  * @example
109
135
  * await installLucaSkills({ home: homedir(), skills_dir, prefix: '[luca init]', log: console.log })
110
- * // [luca init] Luca's skills: installed /luca-unstick, /luca-retro in ~/.claude/skills
136
+ * // [luca init] Luca's skills: installed /luca-retro, /luca-unstick in ~/.claude/skills
111
137
  */
112
138
  export const installLucaSkills = async ({
113
139
  home,
@@ -125,17 +151,15 @@ export const installLucaSkills = async ({
125
151
  return { ok, message }
126
152
  }
127
153
  try {
154
+ const drift = await skillDrift({ home, skills_dir })
155
+ if (drift.length === 0) {
156
+ return say({
157
+ message: `${prefix} Luca's skills: Luca's folder ${skills_dir} has none. Reinstall Luca.`,
158
+ ok: false,
159
+ })
160
+ }
128
161
  const written: string[] = []
129
- for (const { skill, unshipped, missing, changed } of await skillDrift({
130
- home,
131
- skills_dir,
132
- })) {
133
- if (unshipped) {
134
- return say({
135
- message: `${prefix} Luca's skills: ${skill} isn't in Luca's folder ${skills_dir}. Reinstall Luca.`,
136
- ok: false,
137
- })
138
- }
162
+ for (const { skill, missing, changed } of drift) {
139
163
  for (const file of [...missing, ...changed]) {
140
164
  const target = join(claudeSkillsDir({ home }), skill, file)
141
165
  await mkdir(dirname(target), { recursive: true })
@@ -11,25 +11,45 @@
11
11
  * board in Paseo, keeping its settings, and ends with `luca doctor`'s
12
12
  * computer checks. See `runUpgrade`.
13
13
  *
14
+ * Those last steps run in the version just installed (#529): upgrade
15
+ * hands off to it, as
16
+ *
17
+ * luca upgrade --finish [--board-before <fingerprint>]
18
+ *
19
+ * which only Luca runs. It doesn't check for going runs or install
20
+ * anything: it reloads the board, copies Luca's skills, and runs the
21
+ * checks, with its own code. See `finishUpgrade`.
22
+ *
14
23
  * Exits 0 when done, 1 when it refused or a step failed, 2 on bad flags.
15
24
  */
25
+ import { realpath } from 'node:fs/promises'
16
26
  import { homedir } from 'node:os'
27
+ import { dirname, join } from 'node:path'
17
28
 
18
29
  import { z } from 'zod'
19
30
 
31
+ import type { Paseo } from './computer-adapters'
20
32
  import {
21
33
  claudeOf,
22
34
  computerOf,
23
35
  lucaInstall,
24
36
  muninnHealth,
25
37
  muninnOf,
38
+ packageOf,
26
39
  paseoOf,
27
40
  } from './computer-adapters-real'
28
41
  import { computerChecks } from './computer-checks'
29
42
  import { reason } from './doctor-checks'
30
43
  import { defaultRegistryPath } from './going-runs'
31
44
  import { listProcesses } from './live-runs'
32
- import { runUpgrade, type BunGlobal, type NpmRegistry } from './upgrade'
45
+ import {
46
+ runUpgrade,
47
+ type BunGlobal,
48
+ type HandOff,
49
+ type NewInstall,
50
+ type NpmRegistry,
51
+ } from './upgrade'
52
+ import { finishUpgrade } from './upgrade-finish'
33
53
 
34
54
  import { LUCA_PACKAGE } from '../config/luca-version'
35
55
  import { defaultRunsDir } from '../journal/journal'
@@ -77,36 +97,204 @@ const bunOf = ({ home }: { home: string }): BunGlobal => ({
77
97
  },
78
98
  })
79
99
 
80
- /** The `--to` version, `null` without it, or `undefined` on bad flags. */
81
- const parseTo = (argv: string[]): string | null | undefined => {
82
- if (argv.length === 0) return null
83
- const [flag, version] = argv
84
- return flag === '--to' &&
85
- argv.length === 2 &&
86
- version !== undefined &&
87
- version !== '' &&
88
- !version.startsWith('-')
89
- ? version
90
- : undefined
100
+ /**
101
+ * The new install's file older Luca looks for (see `upgrade-finish.ts`):
102
+ * next to its `luca.ts` when it can run `luca upgrade --finish`.
103
+ */
104
+ const FINISH_MODULE = 'upgrade-finish.ts'
105
+
106
+ /**
107
+ * The new install's `luca` entry, when it is `version` of Luca and can
108
+ * finish an upgrade; otherwise why not, in one sentence without a period.
109
+ * Finds it the way the PATH does: the `luca` in Bun's global commands
110
+ * folder (`bun pm bin -g`), which `bun add -g` points at the new install.
111
+ * Read-only.
112
+ *
113
+ * @example
114
+ * await findNewInstall({ bin_dir: '/Users/me/.bun/bin', version: '14.0.0-alpha.11' })
115
+ * // { ok: true, entry: '/Users/me/.bun/install/global/node_modules/@alecsibilia/luca/engine/cli/luca.ts' }
116
+ */
117
+ export const findNewInstall = async ({
118
+ bin_dir,
119
+ version,
120
+ }: {
121
+ bin_dir: string
122
+ version: string
123
+ }): Promise<{ ok: true; entry: string } | { ok: false; why: string }> => {
124
+ const entry = await realpath(join(bin_dir, 'luca')).catch(() => null)
125
+ if (entry === null) {
126
+ return {
127
+ ok: false,
128
+ why: `Bun's global commands folder ${bin_dir} has no luca`,
129
+ }
130
+ }
131
+ const found = await packageOf(entry)
132
+ if (found?.name !== LUCA_PACKAGE || found.version !== version) {
133
+ const what =
134
+ found === null ? entry : `${found.name}@${found.version ?? '?'}`
135
+ return {
136
+ ok: false,
137
+ why: `The luca in ${bin_dir} is ${what}, not ${LUCA_PACKAGE}@${version}`,
138
+ }
139
+ }
140
+ if (!(await Bun.file(join(dirname(entry), FINISH_MODULE)).exists())) {
141
+ return {
142
+ ok: false,
143
+ why: `Luca ${version} can't finish an upgrade itself`,
144
+ }
145
+ }
146
+ return { ok: true, entry }
91
147
  }
92
148
 
149
+ /**
150
+ * Runs `luca upgrade --finish` from `entry` with `bun_path`, its output
151
+ * going straight to this terminal; resolves to its exit code. Throws when
152
+ * it can't be started.
153
+ */
154
+ export const handOff = async ({
155
+ bun_path,
156
+ entry,
157
+ board_before,
158
+ cwd,
159
+ }: {
160
+ bun_path: string
161
+ entry: string
162
+ board_before: string | null
163
+ cwd: string
164
+ }): Promise<HandOff> => {
165
+ const child = Bun.spawn({
166
+ cmd: [
167
+ bun_path,
168
+ entry,
169
+ 'upgrade',
170
+ '--finish',
171
+ ...(board_before === null ? [] : ['--board-before', board_before]),
172
+ ],
173
+ cwd,
174
+ stdio: ['inherit', 'inherit', 'inherit'],
175
+ })
176
+ return { handed_off: true, exit_code: await child.exited }
177
+ }
178
+
179
+ /**
180
+ * The install `bun add -g` just made: found with `findNewInstall`, and
181
+ * handed the last steps with `handOff`. When it isn't found, or can't
182
+ * finish, says why, and this process runs them.
183
+ */
184
+ const newInstallOf = ({ home }: { home: string }): NewInstall => ({
185
+ finish: async ({ version, board_before }) => {
186
+ const bin = await runCommand({
187
+ cmd: [process.execPath, 'pm', 'bin', '-g'],
188
+ cwd: home,
189
+ timeout_ms: 30_000,
190
+ })
191
+ if (bin.exit_code !== 0) {
192
+ return {
193
+ handed_off: false,
194
+ why: `Bun didn't say where its global commands are (bun pm bin -g exited ${bin.exit_code})`,
195
+ }
196
+ }
197
+ const found = await findNewInstall({
198
+ bin_dir: bin.stdout.trim(),
199
+ version,
200
+ })
201
+ if (!found.ok) return { handed_off: false, why: found.why }
202
+ return handOff({
203
+ bun_path: process.execPath,
204
+ entry: found.entry,
205
+ board_before,
206
+ cwd: home,
207
+ })
208
+ },
209
+ })
210
+
211
+ /** What `luca upgrade`'s flags ask for. */
212
+ export type UpgradeArgs =
213
+ /** `luca upgrade [--to <version>]`; `to` is `null` without `--to`. */
214
+ | { finish: false; to: string | null }
215
+ /** `luca upgrade --finish [--board-before <fingerprint>]`, from an older Luca. */
216
+ | { finish: true; board_before: string | null }
217
+
218
+ /** A flag's value: not empty and not another flag. */
219
+ const isValue = (value: string | undefined): value is string =>
220
+ value !== undefined && value !== '' && !value.startsWith('-')
221
+
222
+ /**
223
+ * The flags after `upgrade`, or `undefined` when they're bad. Pure.
224
+ *
225
+ * @example
226
+ * parseUpgradeArgs({ argv: ['--to', '14.0.0-alpha.3'] }) // { finish: false, to: '14.0.0-alpha.3' }
227
+ * parseUpgradeArgs({ argv: ['--finish'] }) // { finish: true, board_before: null }
228
+ */
229
+ export const parseUpgradeArgs = ({
230
+ argv,
231
+ }: {
232
+ argv: string[]
233
+ }): UpgradeArgs | undefined => {
234
+ const [flag, value, ...rest] = argv
235
+ if (flag === undefined) return { finish: false, to: null }
236
+ if (flag === '--to' && isValue(value) && rest.length === 0) {
237
+ return { finish: false, to: value }
238
+ }
239
+ if (flag === '--finish' && value === undefined) {
240
+ return { finish: true, board_before: null }
241
+ }
242
+ if (flag === '--finish' && value === '--board-before') {
243
+ const [board_before, ...extra] = rest
244
+ if (isValue(board_before) && extra.length === 0) {
245
+ return { finish: true, board_before }
246
+ }
247
+ }
248
+ return undefined
249
+ }
250
+
251
+ /** Doctor's computer checks of the Luca on disk now. */
252
+ const computerChecksOf =
253
+ ({ home, paseo }: { home: string; paseo: Paseo }) =>
254
+ async () =>
255
+ computerChecks({
256
+ home,
257
+ // Read again: an install changes the version on disk.
258
+ ...(await lucaInstall()),
259
+ computer: computerOf({ home }),
260
+ muninn: muninnOf({ home }),
261
+ muninn_health: muninnHealth,
262
+ claude: claudeOf({ home }),
263
+ paseo,
264
+ })
265
+
93
266
  /** Runs `luca upgrade` with the flags after `upgrade`; returns the exit code. */
94
267
  export const upgradeCommand = async ({
95
268
  argv,
96
269
  }: {
97
270
  argv: string[]
98
271
  }): Promise<number> => {
99
- const to = parseTo(argv)
100
- if (to === undefined) {
272
+ const args = parseUpgradeArgs({ argv })
273
+ if (args === undefined) {
101
274
  console.error(UPGRADE_USAGE)
102
275
  return 2
103
276
  }
104
277
  const home = homedir()
105
278
  const paseo = paseoOf({ client_id: 'luca-upgrade' })
279
+ const log = (line: string) => {
280
+ console.log(line)
281
+ }
106
282
  try {
107
283
  const { luca_version, ...install } = await lucaInstall()
284
+ if (args.finish) {
285
+ const end = await finishUpgrade({
286
+ version: luca_version,
287
+ board_before: args.board_before,
288
+ home,
289
+ paseo,
290
+ ...install,
291
+ computer_checks: computerChecksOf({ home, paseo }),
292
+ log,
293
+ })
294
+ return end.ok ? 0 : 1
295
+ }
108
296
  const end = await runUpgrade({
109
- to,
297
+ to: args.to,
110
298
  home,
111
299
  installed_version: luca_version,
112
300
  runs_dir: defaultRunsDir(),
@@ -119,20 +307,9 @@ export const upgradeCommand = async ({
119
307
  bun: bunOf({ home }),
120
308
  paseo,
121
309
  ...install,
122
- computer_checks: async () =>
123
- computerChecks({
124
- home,
125
- // Read again: the install changed the version on disk.
126
- ...(await lucaInstall()),
127
- computer: computerOf({ home }),
128
- muninn: muninnOf({ home }),
129
- muninn_health: muninnHealth,
130
- claude: claudeOf({ home }),
131
- paseo,
132
- }),
133
- log: (line) => {
134
- console.log(line)
135
- },
310
+ new_install: newInstallOf({ home }),
311
+ computer_checks: computerChecksOf({ home, paseo }),
312
+ log,
136
313
  })
137
314
  return end.ok ? 0 : 1
138
315
  } catch (error) {
@@ -0,0 +1,149 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { setUpBoard } from './board-in-paseo'
5
+ import type { PaseoPlugins } from './computer-adapters'
6
+ import {
7
+ formatChecks,
8
+ hasProblem,
9
+ reason,
10
+ type DoctorCheck,
11
+ } from './doctor-checks'
12
+ import { installLucaSkills } from './luca-skills'
13
+
14
+ /**
15
+ * The last steps of `luca upgrade`, after `bun add -g` (#529): reload the
16
+ * board, say when `/reload-skills` is needed, copy Luca's own skills, and
17
+ * run doctor's computer checks.
18
+ *
19
+ * They run in the newly installed Luca, not the one that installed it:
20
+ * `luca upgrade` hands off to the new install's `luca upgrade --finish`,
21
+ * so the board setup, the skills, and the checks are the new version's.
22
+ *
23
+ * Older Luca looks for this file next to the new install's `luca.ts` to
24
+ * tell whether the new install can finish an upgrade. Don't rename or move
25
+ * it.
26
+ */
27
+
28
+ /** How upgrade ended; `message` is the last line it printed. */
29
+ export type UpgradeEnd = { ok: boolean; message: string }
30
+
31
+ /**
32
+ * A fingerprint of every file in `dir` but its `node_modules`, or `null`
33
+ * when it can't be read. Short, so it fits on a command line.
34
+ */
35
+ export const boardFingerprint = async (dir: string): Promise<string | null> => {
36
+ const lines: string[] = []
37
+ const walk = async (at: string): Promise<void> => {
38
+ const entries = await readdir(at, { withFileTypes: true })
39
+ for (const entry of entries) {
40
+ const path = join(at, entry.name)
41
+ if (entry.isDirectory()) {
42
+ if (entry.name !== 'node_modules') await walk(path)
43
+ } else if (entry.isFile()) {
44
+ const bytes = await Bun.file(path).arrayBuffer()
45
+ lines.push(`${path}\t${Bun.hash(bytes)}`)
46
+ }
47
+ }
48
+ }
49
+ try {
50
+ await walk(dir)
51
+ } catch {
52
+ return null
53
+ }
54
+ return Bun.hash(lines.sort().join('\n')).toString(36)
55
+ }
56
+
57
+ /**
58
+ * Runs `luca upgrade`'s last steps for the installed `version`: reloads
59
+ * the board with its settings kept and its paths rewritten, says to run
60
+ * `/reload-skills` when the board's files differ from `board_before`,
61
+ * copies Luca's skills, then prints `computer_checks`. Ends with "Luca
62
+ * <version> is installed." Never throws.
63
+ *
64
+ * @example
65
+ * const end = await finishUpgrade({ version: '14.0.0-alpha.11', board_before, home, paseo, board_dir, skills_dir, engine_path, bun_path, computer_checks, log: console.log })
66
+ */
67
+ export const finishUpgrade = async ({
68
+ version,
69
+ board_before,
70
+ home,
71
+ paseo,
72
+ board_dir,
73
+ skills_dir,
74
+ engine_path,
75
+ bun_path,
76
+ computer_checks,
77
+ log,
78
+ }: {
79
+ /** The version just installed. */
80
+ version: string
81
+ /** The board folder's fingerprint before the install, or `null` when unknown. */
82
+ board_before: string | null
83
+ /** The home folder, whose `.claude/skills` gets Luca's own skills. */
84
+ home: string
85
+ paseo: PaseoPlugins
86
+ /** The board folder inside Luca's install folder. */
87
+ board_dir: string
88
+ /** The skills folder inside Luca's install folder. */
89
+ skills_dir: string
90
+ /** The real path of the installed `luca-run`. */
91
+ engine_path: string
92
+ /** Bun's own path. */
93
+ bun_path: string
94
+ /** Doctor's computer checks of the new install; none when not given. */
95
+ computer_checks?: () => Promise<DoctorCheck[]>
96
+ log: (line: string) => void
97
+ }): Promise<UpgradeEnd> => {
98
+ const say = ({ message, ok }: UpgradeEnd): UpgradeEnd => {
99
+ log(`[luca upgrade] ${message}`)
100
+ return { ok, message }
101
+ }
102
+ try {
103
+ const board = await setUpBoard({
104
+ command: 'luca upgrade',
105
+ paseo,
106
+ // Upgrade only reloads; `luca init` asks to turn plugins on.
107
+ ask: async () => false,
108
+ board_dir,
109
+ engine_path,
110
+ bun_path,
111
+ log,
112
+ })
113
+ if (board.ok) {
114
+ const after = await boardFingerprint(board_dir)
115
+ if (
116
+ board_before === null ||
117
+ after === null ||
118
+ board_before !== after
119
+ ) {
120
+ log(
121
+ "[luca upgrade] The board's files changed: run /reload-skills in a Paseo chat so its slash commands are up to date."
122
+ )
123
+ }
124
+ }
125
+ const skills = await installLucaSkills({
126
+ home,
127
+ skills_dir,
128
+ prefix: '[luca upgrade]',
129
+ log,
130
+ })
131
+ const end = say({
132
+ message: !board.ok
133
+ ? `Luca ${version} is installed, but the board wasn't reloaded. Run luca init to fix it.`
134
+ : !skills.ok
135
+ ? `Luca ${version} is installed, but its skills weren't copied. Run luca doctor --fix to fix it.`
136
+ : `Luca ${version} is installed.`,
137
+ ok: board.ok && skills.ok,
138
+ })
139
+ const checks = (await computer_checks?.()) ?? []
140
+ for (const line of formatChecks({ checks })) log(line)
141
+ return { ...end, ok: end.ok && !hasProblem({ checks }) }
142
+ } catch (error) {
143
+ // Such as computer checks that threw.
144
+ return say({
145
+ message: `Luca ${version} is installed, but its last steps failed: ${reason(error)}`,
146
+ ok: false,
147
+ })
148
+ }
149
+ }
@@ -1,18 +1,13 @@
1
- import { readdir } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
-
4
- import { setUpBoard } from './board-in-paseo'
5
1
  import type { PaseoPlugins } from './computer-adapters'
6
- import {
7
- formatChecks,
8
- hasProblem,
9
- reason,
10
- type DoctorCheck,
11
- } from './doctor-checks'
2
+ import { reason, type DoctorCheck } from './doctor-checks'
12
3
  import { describeRun, goingRuns } from './going-runs'
13
4
  import type { ListProcesses } from './live-runs'
14
- import { installLucaSkills } from './luca-skills'
15
5
  import { resumeCommand } from './run-modes'
6
+ import {
7
+ boardFingerprint,
8
+ finishUpgrade,
9
+ type UpgradeEnd,
10
+ } from './upgrade-finish'
16
11
 
17
12
  import { LUCA_PACKAGE } from '../config/luca-version'
18
13
 
@@ -35,12 +30,20 @@ import { LUCA_PACKAGE } from '../config/luca-version'
35
30
  * 4. Reloads the board in Paseo, keeping its settings, and rewrites its
36
31
  * engine and Bun paths (see `setUpBoard`).
37
32
  * 5. Says when `/reload-skills` is needed: when the board's files changed.
38
- * 6. Copies Luca's own skills (`/luca-unstick`, `/luca-retro`) from the new
39
- * install into `~/.claude/skills` (see `installLucaSkills`).
33
+ * 6. Copies Luca's own skills (every one in the new install's `skills/`)
34
+ * into `~/.claude/skills` (see `installLucaSkills`).
40
35
  * 7. Ends with doctor's computer checks (see `computerChecks`).
41
36
  *
42
- * npm's registry, Bun, Paseo, `ps`, and the computer checks are adapters,
43
- * so tests use fakes.
37
+ * Steps 4-7 (`finishUpgrade`) run in the new install, not in this process
38
+ * (#529): this process runs the old version's code, which doesn't know
39
+ * what the new version's board setup, skills, and checks are. So after
40
+ * step 3 upgrade hands off to the new install's `luca upgrade --finish`,
41
+ * passes its exit code through, and doesn't run them itself. A new install
42
+ * that can't finish (a version from before #529, such as a downgrade's)
43
+ * gets them from this process, as before.
44
+ *
45
+ * npm's registry, Bun, Paseo, `ps`, the new install, and the computer
46
+ * checks are adapters, so tests use fakes.
44
47
  */
45
48
 
46
49
  /** npm's registry lookups for `@alecsibilia/luca`. */
@@ -55,8 +58,28 @@ export type BunGlobal = {
55
58
  addGlobal: (args: { spec: string }) => Promise<void>
56
59
  }
57
60
 
58
- /** How upgrade ended; `message` is the last line it printed. */
59
- export type UpgradeEnd = { ok: boolean; message: string }
61
+ export type { UpgradeEnd }
62
+
63
+ /** How handing the last steps to the new install went. */
64
+ export type HandOff =
65
+ /** The new install ran them; it exited with `exit_code`. */
66
+ | { handed_off: true; exit_code: number }
67
+ /** The new install can't run them, and `why` (one sentence, no period). */
68
+ | { handed_off: false; why: string }
69
+
70
+ /** The newly installed Luca. */
71
+ export type NewInstall = {
72
+ /**
73
+ * Runs `luca upgrade --finish` in the new install of `version`, which
74
+ * prints its own lines; or says why it can't. Throws when the new
75
+ * install couldn't be started.
76
+ */
77
+ finish: (args: {
78
+ version: string
79
+ /** The board folder's fingerprint before the install. */
80
+ board_before: string | null
81
+ }) => Promise<HandOff>
82
+ }
60
83
 
61
84
  /** The first version of new Luca; without `--to`, nothing older is installed. */
62
85
  const FIRST_NEW_MAJOR = 14
@@ -124,40 +147,15 @@ export const channelTarget = ({
124
147
  return { ok: true, version }
125
148
  }
126
149
 
127
- /**
128
- * A fingerprint of every file in `dir` but its `node_modules`, or `null`
129
- * when it can't be read.
130
- */
131
- const fingerprint = async (dir: string): Promise<string | null> => {
132
- const lines: string[] = []
133
- const walk = async (at: string): Promise<void> => {
134
- const entries = await readdir(at, { withFileTypes: true })
135
- for (const entry of entries) {
136
- const path = join(at, entry.name)
137
- if (entry.isDirectory()) {
138
- if (entry.name !== 'node_modules') await walk(path)
139
- } else if (entry.isFile()) {
140
- const bytes = await Bun.file(path).arrayBuffer()
141
- lines.push(`${path}\t${Bun.hash(bytes)}`)
142
- }
143
- }
144
- }
145
- try {
146
- await walk(dir)
147
- } catch {
148
- return null
149
- }
150
- return lines.sort().join('\n')
151
- }
152
-
153
150
  /**
154
151
  * Runs `luca upgrade`: refuses while a run is going, picks the version,
155
- * installs it with Bun, reloads the board with its settings kept and its
156
- * paths rewritten, then prints `computer_checks`. Never throws: a refusal,
157
- * a failed step, or a check that is a problem ends it with `ok: false`.
152
+ * installs it with Bun, then hands the last steps to `new_install` (see
153
+ * `finishUpgrade`), or runs them here when it can't take them. Never
154
+ * throws: a refusal, a failed step, or a check that is a problem ends it
155
+ * with `ok: false`.
158
156
  *
159
157
  * @example
160
- * const end = await runUpgrade({ to: null, home: homedir(), installed_version: lucaVersion(), runs_dir: defaultRunsDir(), registry_path, list_processes: listProcesses, npm, bun, paseo, board_dir, skills_dir, engine_path, bun_path, computer_checks, log: console.log })
158
+ * const end = await runUpgrade({ to: null, home: homedir(), installed_version: lucaVersion(), runs_dir: defaultRunsDir(), registry_path, list_processes: listProcesses, npm, bun, paseo, board_dir, skills_dir, engine_path, bun_path, new_install, computer_checks, log: console.log })
161
159
  */
162
160
  export const runUpgrade = async ({
163
161
  to,
@@ -173,6 +171,7 @@ export const runUpgrade = async ({
173
171
  skills_dir,
174
172
  engine_path,
175
173
  bun_path,
174
+ new_install,
176
175
  computer_checks,
177
176
  log,
178
177
  }: {
@@ -198,9 +197,11 @@ export const runUpgrade = async ({
198
197
  engine_path: string
199
198
  /** Bun's own path. */
200
199
  bun_path: string
200
+ /** The install `bun add -g` makes, which finishes the upgrade. */
201
+ new_install: NewInstall
201
202
  /**
202
203
  * Doctor's computer checks of the new install, run after the board
203
- * reload; none when not given.
204
+ * reload when this process runs the last steps; none when not given.
204
205
  */
205
206
  computer_checks?: () => Promise<DoctorCheck[]>
206
207
  log: (line: string) => void
@@ -253,47 +254,48 @@ export const runUpgrade = async ({
253
254
  })
254
255
  }
255
256
 
256
- const before = await fingerprint(board_dir)
257
+ const before = await boardFingerprint(board_dir)
257
258
  const spec = `${LUCA_PACKAGE}@${version}`
258
259
  log(`[luca upgrade] installing ${spec} (from ${installed_version})`)
259
260
  await bun.addGlobal({ spec })
260
261
  log(`[luca upgrade] installed ${spec}`)
261
262
 
262
- const board = await setUpBoard({
263
- command: 'luca upgrade',
263
+ let hand_off: HandOff
264
+ try {
265
+ hand_off = await new_install.finish({
266
+ version,
267
+ board_before: before,
268
+ })
269
+ } catch (error) {
270
+ return say({
271
+ message: `Luca ${version} is installed, but Luca couldn't start it to finish the upgrade (${reason(error)}). Run luca init to finish: it reloads the board and copies Luca's skills.`,
272
+ ok: false,
273
+ })
274
+ }
275
+ if (hand_off.handed_off) {
276
+ // The new install printed its own lines, its last one included.
277
+ return hand_off.exit_code === 0
278
+ ? { ok: true, message: `Luca ${version} is installed.` }
279
+ : say({
280
+ message: `Luca ${version} is installed, but its last steps failed (exit ${hand_off.exit_code}). See above for what to fix.`,
281
+ ok: false,
282
+ })
283
+ }
284
+ log(
285
+ `[luca upgrade] ${hand_off.why}, so Luca ${installed_version} does the last steps.`
286
+ )
287
+ return await finishUpgrade({
288
+ version,
289
+ board_before: before,
290
+ home,
264
291
  paseo,
265
- // Upgrade only reloads; `luca init` asks to turn plugins on.
266
- ask: async () => false,
267
292
  board_dir,
293
+ skills_dir,
268
294
  engine_path,
269
295
  bun_path,
296
+ computer_checks,
270
297
  log,
271
298
  })
272
- if (board.ok) {
273
- const after = await fingerprint(board_dir)
274
- if (before === null || after === null || before !== after) {
275
- log(
276
- "[luca upgrade] The board's files changed: run /reload-skills in a Paseo chat so its slash commands are up to date."
277
- )
278
- }
279
- }
280
- const skills = await installLucaSkills({
281
- home,
282
- skills_dir,
283
- prefix: '[luca upgrade]',
284
- log,
285
- })
286
- const end = say({
287
- message: !board.ok
288
- ? `Luca ${version} is installed, but the board wasn't reloaded. Run luca init to fix it.`
289
- : !skills.ok
290
- ? `Luca ${version} is installed, but its skills weren't copied. Run luca doctor --fix to fix it.`
291
- : `Luca ${version} is installed.`,
292
- ok: board.ok && skills.ok,
293
- })
294
- const checks = (await computer_checks?.()) ?? []
295
- for (const line of formatChecks({ checks })) log(line)
296
- return { ...end, ok: end.ok && !hasProblem({ checks }) }
297
299
  } catch (error) {
298
300
  // Such as a failed `bun add -g` or registry lookup.
299
301
  return say({ message: reason(error), ok: false })
@@ -0,0 +1,48 @@
1
+ import { mkdir, symlink } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { $ } from 'bun'
5
+
6
+ /** The folders a skill helper can be started from, for one repo and none. */
7
+ export type Checkouts = {
8
+ /** The main checkout: where a run's `run_started` repo points. */
9
+ main: string
10
+ /** A `git worktree add` of the main checkout. */
11
+ worktree: string
12
+ /** A folder inside the main checkout. */
13
+ subfolder: string
14
+ /** A symlink to the main checkout. */
15
+ link: string
16
+ /** A folder in no git repo. */
17
+ outside: string
18
+ }
19
+
20
+ /**
21
+ * Real throwaway checkouts under `dir`: a git repo with one commit, a
22
+ * worktree of it, a subfolder, a symlink to it, and a folder outside git.
23
+ * For the `/luca-unstick` and `/luca-retro` helpers' repo lookup, which must
24
+ * find the same runs from each of them.
25
+ *
26
+ * @example
27
+ * const { main, worktree } = await makeCheckouts({ dir })
28
+ */
29
+ export const makeCheckouts = async ({
30
+ dir,
31
+ }: {
32
+ dir: string
33
+ }): Promise<Checkouts> => {
34
+ const main = join(dir, 'app')
35
+ const worktree = join(dir, 'app-worktree')
36
+ const subfolder = join(main, 'src', 'deep')
37
+ const link = join(dir, 'app-link')
38
+ const outside = join(dir, 'not-git')
39
+ await mkdir(subfolder, { recursive: true })
40
+ await mkdir(outside, { recursive: true })
41
+ await Bun.write(join(subfolder, 'a.txt'), 'a\n')
42
+ await $`git init -q -b main && git add -A && git -c user.name=t -c user.email=t@t -c commit.gpgsign=false commit -qm init`
43
+ .cwd(main)
44
+ .quiet()
45
+ await $`git worktree add -q -b side ${worktree}`.cwd(main).quiet()
46
+ await symlink(main, link)
47
+ return { main, worktree, subfolder, link, outside }
48
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alecsibilia/luca",
3
3
  "description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
4
- "version": "14.0.0-alpha.10",
4
+ "version": "14.0.0-alpha.11",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alec Sibilia <sibilia.alec@gmail.com>",
7
7
  "homepage": "https://github.com/asibilia/luca-framework#readme",
@@ -20,7 +20,7 @@ Run the helper from the repo's folder. It only reads.
20
20
  bun ~/.claude/skills/luca-retro/scripts/retro-summary.ts [run id ...]
21
21
  ```
22
22
 
23
- - No run id: it sums up this repo's newest finished run (one that opened its PR, was stopped, or had nothing to do). If that isn't the one the owner means, ask.
23
+ - No run id: it sums up this repo's newest finished run (one that opened its PR, was stopped, or had nothing to do), found from the main checkout, any of its worktrees, or a subfolder. If that isn't the one the owner means, ask.
24
24
  - Several run ids: it sums up each, then lists the patterns that repeat across them. A repeat ranks higher.
25
25
  - It prints the run's spec, repo, Luca versions, and how it ended; then what got stuck and how long the owner took to reply, fix loops that hit their cap, leftover scan hits, agents out of turns, "No open agent session" failures, Jev calls that failed, checks and tests that failed again and again, setup changes agents asked for, joins that clashed, replies, the agents' assumptions, the findings fixers declined, slow steps, and tokens. Every line names its journal seq (`#123`).
26
26
 
@@ -27,9 +27,9 @@
27
27
  * own fixtures, and every kind in `KINDS_READ` against the journal's kinds,
28
28
  * so the two can't drift apart unseen.
29
29
  */
30
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
30
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
31
31
  import { homedir } from 'node:os'
32
- import { dirname, join, resolve } from 'node:path'
32
+ import { basename, dirname, join, resolve } from 'node:path'
33
33
 
34
34
  /** Every journal record kind this script reads. */
35
35
  export const KINDS_READ = [
@@ -1185,7 +1185,7 @@ export const repoRuns = ({
1185
1185
  repos: string[]
1186
1186
  }): RunChoice[] => {
1187
1187
  if (!existsSync(runs_dir)) return []
1188
- const want = new Set(repos.map((repo) => resolve(repo)))
1188
+ const want = new Set(repos.map(realPath))
1189
1189
  return readdirSync(runs_dir)
1190
1190
  .map((run_id) => ({
1191
1191
  run_id,
@@ -1197,7 +1197,7 @@ export const repoRuns = ({
1197
1197
  const started = records.find((r) => r.kind === 'run_started')
1198
1198
  return (
1199
1199
  started !== undefined &&
1200
- want.has(resolve(text(started.content.repo) || '/'))
1200
+ want.has(realPath(text(started.content.repo) || '/'))
1201
1201
  )
1202
1202
  })
1203
1203
  .map(({ run_id, records }) => ({
@@ -1258,14 +1258,36 @@ export const parseArgs = ({
1258
1258
  }
1259
1259
 
1260
1260
  /**
1261
- * The current folder's repo: its git top folder, and, in a worktree, the
1262
- * main checkout's too (a run's repo is the main checkout). The folder
1263
- * itself outside git.
1261
+ * A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
1262
+ * just made absolute when it doesn't exist.
1264
1263
  */
1265
- const currentRepos = (): string[] => {
1264
+ const realPath = (path: string): string => {
1265
+ try {
1266
+ return realpathSync(path)
1267
+ } catch {
1268
+ return resolve(path)
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * The repos a run started from `cwd` may name, real paths, main first: the
1274
+ * main checkout (a run's repo is the main checkout), then the git top folder
1275
+ * (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
1276
+ * bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
1277
+ * no main checkout, only its top folder.
1278
+ *
1279
+ * Its twin is `currentRepos` in `luca-unstick/scripts/stuck-summary.ts`: each skill folder is installed
1280
+ * to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
1281
+ * the same; a test in each pins it.
1282
+ *
1283
+ * @example
1284
+ * currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
1285
+ */
1286
+ export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
1266
1287
  const git = (...args: string[]) => {
1267
- const out = Bun.spawnSync(['git', ...args])
1268
- return out.exitCode === 0 ? out.stdout.toString().trim() : null
1288
+ const out = Bun.spawnSync(['git', '-C', cwd, ...args])
1289
+ const said = out.stdout.toString().trim()
1290
+ return out.exitCode === 0 && said !== '' ? said : null
1269
1291
  }
1270
1292
  const top = git('rev-parse', '--show-toplevel')
1271
1293
  const common = git(
@@ -1273,10 +1295,12 @@ const currentRepos = (): string[] => {
1273
1295
  '--path-format=absolute',
1274
1296
  '--git-common-dir'
1275
1297
  )
1276
- return [
1277
- top ?? process.cwd(),
1278
- ...(common === null ? [] : [dirname(common)]),
1279
- ].filter((repo, index, all) => all.indexOf(repo) === index)
1298
+ const main =
1299
+ common !== null && basename(common) === '.git' ? dirname(common) : null
1300
+ return [main, top ?? cwd]
1301
+ .filter((repo) => repo !== null)
1302
+ .map(realPath)
1303
+ .filter((repo, index, all) => all.indexOf(repo) === index)
1280
1304
  }
1281
1305
 
1282
1306
  const main = () => {
@@ -1287,7 +1311,10 @@ const main = () => {
1287
1311
  })
1288
1312
  let run_ids = args.run_ids
1289
1313
  if (run_ids.length === 0) {
1290
- const repos = args.repo === null ? currentRepos() : [args.repo]
1314
+ const repos =
1315
+ args.repo === null
1316
+ ? currentRepos({ cwd: process.cwd() })
1317
+ : [args.repo]
1291
1318
  const runs = repoRuns({ runs_dir: args.runs_dir, repos })
1292
1319
  const pick = newestFinished({ runs })
1293
1320
  if (pick === null) {
@@ -25,7 +25,7 @@ bun ~/.claude/skills/luca-unstick/scripts/stuck-summary.ts <run id> <ticket numb
25
25
  Pass the ticket as a bare number (`134`, not `#134`): in the shell, `#` starts a comment, so `#134` would be lost. Leave out what you weren't given.
26
26
 
27
27
  - With a run id, it sums up that run. With a ticket too, only that ticket (and the run itself).
28
- - With no run id, it lists this repo's runs, newest first, and sums up the newest one that waits on a reply. If that isn't the one the owner means, ask.
28
+ - With no run id, it lists this repo's runs (from the main checkout, any of its worktrees, or a subfolder), newest first, and sums up the newest one that waits on a reply. If that isn't the one the owner means, ask.
29
29
  - It prints: the spec, its owner, the repo (`owner/repo`), the Luca version the run is on, the run branch, whether the engine is running now, how the board saw it end, and, for each stuck thing, the stuck reason and detail, the worktree, the agent's last result (such as a `bad_test` with its file, test, and reason), the last failed checks with the end of their output, and the replies since.
30
30
 
31
31
  Where things live, if you need more:
@@ -20,9 +20,9 @@
20
20
  * engine's own fixtures, and every kind in `KINDS_READ` against the
21
21
  * journal's kinds, so the two can't drift apart unseen.
22
22
  */
23
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
23
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
24
24
  import { homedir } from 'node:os'
25
- import { join, resolve } from 'node:path'
25
+ import { basename, dirname, join, resolve } from 'node:path'
26
26
 
27
27
  /** Every journal record kind this script reads. */
28
28
  export const KINDS_READ = [
@@ -761,17 +761,18 @@ export type RunChoice = {
761
761
 
762
762
  /**
763
763
  * The runs of `repo` in `runs_dir`, newest first, with how many things
764
- * wait on a reply in each. A run's repo is its `run_started` repo.
764
+ * wait on a reply in each. A run's repo is its `run_started` repo; it
765
+ * matches any of `repos`, symlinks followed.
765
766
  */
766
767
  export const repoRuns = ({
767
768
  runs_dir,
768
- repo,
769
+ repos,
769
770
  }: {
770
771
  runs_dir: string
771
- repo: string
772
+ repos: string[]
772
773
  }): RunChoice[] => {
773
774
  if (!existsSync(runs_dir)) return []
774
- const want = resolve(repo)
775
+ const want = new Set(repos.map(realPath))
775
776
  return readdirSync(runs_dir)
776
777
  .map((run_id) => ({
777
778
  run_id,
@@ -783,7 +784,7 @@ export const repoRuns = ({
783
784
  const started = records.find((r) => r.kind === 'run_started')
784
785
  return (
785
786
  started !== undefined &&
786
- resolve(text(started.content.repo) || '/') === want
787
+ want.has(realPath(text(started.content.repo) || '/'))
787
788
  )
788
789
  })
789
790
  .map(({ run_id, records }) => ({
@@ -939,10 +940,50 @@ const watchReply = async ({
939
940
  }
940
941
  }
941
942
 
942
- /** The git repo the current folder is in, or the folder itself. */
943
- const currentRepo = (): string => {
944
- const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'])
945
- return top.exitCode === 0 ? top.stdout.toString().trim() : process.cwd()
943
+ /**
944
+ * A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
945
+ * just made absolute when it doesn't exist.
946
+ */
947
+ const realPath = (path: string): string => {
948
+ try {
949
+ return realpathSync(path)
950
+ } catch {
951
+ return resolve(path)
952
+ }
953
+ }
954
+
955
+ /**
956
+ * The repos a run started from `cwd` may name, real paths, main first: the
957
+ * main checkout (a run's repo is the main checkout), then the git top folder
958
+ * (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
959
+ * bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
960
+ * no main checkout, only its top folder.
961
+ *
962
+ * Its twin is `currentRepos` in `luca-retro/scripts/retro-summary.ts`: each skill folder is installed
963
+ * to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
964
+ * the same; a test in each pins it.
965
+ *
966
+ * @example
967
+ * currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
968
+ */
969
+ export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
970
+ const git = (...args: string[]) => {
971
+ const out = Bun.spawnSync(['git', '-C', cwd, ...args])
972
+ const said = out.stdout.toString().trim()
973
+ return out.exitCode === 0 && said !== '' ? said : null
974
+ }
975
+ const top = git('rev-parse', '--show-toplevel')
976
+ const common = git(
977
+ 'rev-parse',
978
+ '--path-format=absolute',
979
+ '--git-common-dir'
980
+ )
981
+ const main =
982
+ common !== null && basename(common) === '.git' ? dirname(common) : null
983
+ return [main, top ?? cwd]
984
+ .filter((repo) => repo !== null)
985
+ .map(realPath)
986
+ .filter((repo, index, all) => all.indexOf(repo) === index)
946
987
  }
947
988
 
948
989
  const main = async () => {
@@ -966,13 +1007,18 @@ const main = async () => {
966
1007
  }
967
1008
  let run_id = args.run_id
968
1009
  if (run_id === null) {
969
- const repo = args.repo ?? currentRepo()
970
- const runs = repoRuns({ runs_dir: args.runs_dir, repo })
1010
+ const repos =
1011
+ args.repo === null
1012
+ ? currentRepos({ cwd: process.cwd() })
1013
+ : [args.repo]
1014
+ const runs = repoRuns({ runs_dir: args.runs_dir, repos })
971
1015
  if (runs.length === 0) {
972
- console.log(`No Luca runs of ${repo} in ${args.runs_dir}.`)
1016
+ console.log(
1017
+ `No Luca runs of ${repos.join(' or ')} in ${args.runs_dir}.`
1018
+ )
973
1019
  return
974
1020
  }
975
- console.log(`Runs of ${repo}, newest first:`)
1021
+ console.log(`Runs of ${repos[0]}, newest first:`)
976
1022
  for (const run of runs.slice(0, 10)) {
977
1023
  console.log(
978
1024
  `- ${run.run_id}: spec #${run.spec ?? '?'}, started ${run.started}, ${run.over ? 'over' : 'not over'}, ${run.stuck} waiting on a reply`