@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 +4 -0
- package/README.md +2 -2
- package/board/package.json +1 -1
- package/board/server/luca-version.ts +1 -1
- package/engine/cli/computer-adapters-real.ts +1 -1
- package/engine/cli/computer-checks.ts +4 -5
- package/engine/cli/luca-skills.ts +49 -25
- package/engine/cli/upgrade-command.ts +206 -29
- package/engine/cli/upgrade-finish.ts +149 -0
- package/engine/cli/upgrade.ts +80 -78
- package/engine/testing/checkouts-fixtures.ts +48 -0
- package/package.json +1 -1
- package/skills/luca-retro/SKILL.md +1 -1
- package/skills/luca-retro/scripts/retro-summary.ts +42 -15
- package/skills/luca-unstick/SKILL.md +1 -1
- package/skills/luca-unstick/scripts/stuck-summary.ts +61 -15
package/CHANGELOG.md
CHANGED
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
|
|
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
|
|
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.
|
package/board/package.json
CHANGED
|
@@ -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.
|
|
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
|
|
333
|
-
* are the ones
|
|
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
|
-
|
|
346
|
-
if (unshipped.length > 0) {
|
|
345
|
+
if (drift.length === 0) {
|
|
347
346
|
return problem({
|
|
348
|
-
detail: `Luca's folder ${skills_dir} has no
|
|
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
|
|
9
|
-
* (#514). They ship in the package's `skills/` folder, next
|
|
10
|
-
* and `board/` (`packages/engine/skills/` in the repo). `luca
|
|
11
|
-
* copy them to `~/.claude/skills/<skill>/`, so
|
|
12
|
-
* any Paseo chat, and `luca doctor` checks the
|
|
13
|
-
* installed Luca ships (`luca doctor --fix` copies it
|
|
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.
|
|
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
|
|
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,
|
|
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-
|
|
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,
|
|
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 {
|
|
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
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
100
|
-
if (
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
package/engine/cli/upgrade.ts
CHANGED
|
@@ -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 (
|
|
39
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
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,
|
|
156
|
-
*
|
|
157
|
-
* a failed step, or a check that is a problem ends it
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
1262
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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 =
|
|
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
|
-
|
|
769
|
+
repos,
|
|
769
770
|
}: {
|
|
770
771
|
runs_dir: string
|
|
771
|
-
|
|
772
|
+
repos: string[]
|
|
772
773
|
}): RunChoice[] => {
|
|
773
774
|
if (!existsSync(runs_dir)) return []
|
|
774
|
-
const want =
|
|
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
|
-
|
|
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
|
-
/**
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
|
970
|
-
|
|
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(
|
|
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 ${
|
|
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`
|