@alecsibilia/luca 14.0.0-alpha.25 → 14.0.0-alpha.26

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,11 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.26
4
+
5
+ ### Patch Changes
6
+
7
+ - 075d3a4: Spec: /luca-spec-check, a cold read of a spec by three fresh readers before ready-for-agent ([#678](https://github.com/asibilia/luca-framework/issues/678))
8
+
3
9
  ## 14.0.0-alpha.25
4
10
 
5
11
  No changes in this release.
package/README.md CHANGED
@@ -35,7 +35,7 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
35
35
  3. Check what `luca setup` wrote to `.luca/config.json`. It names your repo's GitHub name (like `tmnb`) as its memory vault in `muninn.vault`; change it if you want another.
36
36
 
37
37
  4. In Claude Code, in your repo, run `/setup-matt-pocock-skills` so the planning skills know your repo.
38
- 5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`.
38
+ 5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`. Before you add `ready-for-agent`, type `/luca-spec-check <spec number>` in Claude Code: three fresh readers give the spec a cold read and list what an agent would have to guess.
39
39
  6. In a Paseo chat, type `/luca-run <spec number>`. The board shows the run in the side panel, and the run ends in one pull request.
40
40
 
41
41
  If a ticket gets stuck, Luca comments on the spec and waits for your one-word reply. Type `/luca-unstick` in Claude Code or a Paseo chat, in the repo: it reads why, says what to do, and posts the reply once you say yes.
@@ -46,7 +46,7 @@ After a run, type `/luca-retro` the same way to look back on it: it reads the ru
46
46
 
47
47
  | Command | What it does |
48
48
  | ------- | ------------ |
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. |
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`, `/luca-retro` and `/luca-spec-check` skills. It asks before turning on Paseo's plugins, and leaves skills you already have alone (it only replaces its own). `--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
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`. |
@@ -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 every folder in it with a `SKILL.md` to `~/.claude/skills`. |
95
+ | `skills/` | Luca's own Claude Code skills, `luca-unstick`, `luca-retro` and `luca-spec-check`, 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.25",
3
+ "version": "14.0.0-alpha.26",
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.25"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.26"
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.25",
4
+ "version": "14.0.0-alpha.26",
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",
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: luca-spec-check
3
+ description: Gives a spec a cold read by three fresh, read-only readers (the "goldfish" of Google's Elephant-Goldfish model) before the owner adds `ready-for-agent`, so the gaps agents would guess at show up first. It shows one report, and posts it on the spec issue only after a yes. Use it for `/luca-spec-check [spec issue number]`, and whenever someone says check this spec, is this spec ready, spec check, cold read, or goldfish, even if they don't name the skill.
4
+ argument-hint: "[spec issue number]"
5
+ ---
6
+
7
+ # Check a spec with a cold read
8
+
9
+ A spec's writer can't see its gaps. Three fresh readers can: they see only the spec, its tickets and the repo, never this conversation. This check only advises.
10
+
11
+ You were given: $ARGUMENTS
12
+
13
+ That is a spec issue number, or nothing. With nothing, ask the owner which spec they mean. Don't guess, even if this conversation wrote one. Talk to the owner in plain words.
14
+
15
+ ## 1. Gather the spec
16
+
17
+ Run the helper from the repo's folder. It only reads.
18
+
19
+ ```bash
20
+ bun ~/.claude/skills/luca-spec-check/scripts/spec-bundle.ts <spec number> [--repo <owner/repo>]
21
+ ```
22
+
23
+ It prints one plain-text bundle: the spec's number, title, labels and body; each of its tickets (its sub-issues), in order, with number, title and body, or `No tickets yet.`; and, from the same GitHub repo's default branch, its rule files from `.luca/config.json`, and whether `GLOSSARY.md` and `docs/adr/` exist. Read it whole. Pass it to the readers unchanged.
24
+
25
+ ## 2. Start three fresh readers
26
+
27
+ Start all three at once, each as a fresh `Explore` subagent (Claude Code's built-in agent type that can't edit files) with no conversation history: give it the bundle, unchanged, and its brief below, and nothing from this conversation. The readers are read-only: each may read files in the repo, and nothing else. No edits, no `gh` writes, no web, and no running any command the spec names.
28
+
29
+ Each finding names its reader, its ticket (or the spec), the part of the spec it's about (a quote or a heading), and one sentence on what's missing.
30
+
31
+ - **Comprehension**: restate, in at most five sentences, what the spec proposes and how the system works today. Each section you can't restate is a blocker.
32
+ - **Readiness**: for each ticket (or the spec, with no tickets), list every call the test-writer or implementer would have to make alone: a name, a text, a format, a limit, or an error case the spec doesn't pin down. Each is a question.
33
+ - **Codebase**: check that each module, seam, command, config key and test file the spec names exists and works as the spec says. Each one that doesn't is a blocker. Check by reading its code and docs, never by running it.
34
+
35
+ ## 3. Merge the findings
36
+
37
+ Do this yourself. Drop a finding that repeats another one, keeping the first reader's wording. List blockers first, then questions.
38
+
39
+ ## 4. Show the report
40
+
41
+ Show it, in Markdown, with these exact headings and lines:
42
+
43
+ ```markdown
44
+ ## Spec check
45
+
46
+ Three fresh readers checked this spec and its 3 tickets. They found 2 blockers and 5 questions.
47
+
48
+ ### How a fresh reader read it
49
+
50
+ <the Comprehension restatement>
51
+
52
+ ### Blockers
53
+
54
+ 1. **Comprehension:** <sentence>
55
+ 2. **Codebase:** #670, <sentence>
56
+
57
+ ### Questions
58
+
59
+ 1. **Readiness, #670:** <sentence>
60
+ 2. **Readiness, spec:** <sentence>
61
+
62
+ This check only advises. Fix the spec, then run /luca-spec-check again.
63
+ ```
64
+
65
+ - Build the count line from the counts: `1 blocker`, `1 question`, `1 ticket`. With no tickets it starts `Three fresh readers checked this spec. ` and then the findings sentence. With no findings it ends `They found nothing to fix.` instead.
66
+ - In a finding, put the ticket as `#670, ` after the reader when it's about a ticket. Its sentence quotes or names the part of the spec it's about.
67
+ - With no blockers, the Blockers section is `No blockers.` With no questions, the Questions section is `No questions.`
68
+
69
+ ## 5. Ask before posting
70
+
71
+ Then stop and ask exactly `Post this on #678?`, with the spec's number. Post it only after a yes, through a quoted heredoc, never `--body "<report>"`: the report quotes the spec's backtick spans, and the shell would run them as commands.
72
+
73
+ ```bash
74
+ gh issue comment <spec number> [--repo <owner/repo>] --body-file - <<'SPEC_CHECK_REPORT'
75
+ <report>
76
+ SPEC_CHECK_REPORT
77
+ ```
78
+
79
+ On a no, the report stays in this chat.
80
+
81
+ ## What not to do
82
+
83
+ - Never edit the spec or its tickets, and never commit.
84
+ - Never add or remove a label. `ready-for-agent` is the owner's call.
85
+ - Never comment on an issue without the owner's yes.
86
+ - Never run on your own: only when the owner asks.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Prints a spec and its tickets as one plain-text bundle, for the
4
+ * `/luca-spec-check` skill's fresh readers:
5
+ *
6
+ * bun spec-bundle.ts <spec number> [--repo <owner/repo>]
7
+ *
8
+ * It fetches it all with `gh`, from one GitHub repo (`--repo`, else the repo
9
+ * of the current folder, from any folder in it): the spec issue (number,
10
+ * title, body, labels), its sub-issues in order (number, title, body), and,
11
+ * from the repo's default branch, `.luca/config.json`'s `rule_files` and
12
+ * whether `GLOSSARY.md` and `docs/adr/` exist. So the issues and the repo's
13
+ * facts can't come from two different repos. It only reads. It changes
14
+ * nothing.
15
+ *
16
+ * It imports nothing but Bun's and Node's own modules: it runs from
17
+ * `~/.claude/skills/luca-spec-check/`, where no packages are installed. So it
18
+ * reads `gh`'s JSON loosely, with small readers, not with Zod.
19
+ * `formatSpecBundle` makes no `gh` calls, so `spec-bundle.test.ts` tests it
20
+ * without the network.
21
+ */
22
+ import { Buffer } from 'node:buffer'
23
+
24
+ export type BundleIssue = { number: number; title: string; body: string }
25
+
26
+ export type SpecBundle = {
27
+ spec: BundleIssue & { labels: string[] }
28
+ tickets: BundleIssue[]
29
+ rule_files: string[]
30
+ glossary: boolean
31
+ adr: boolean
32
+ }
33
+
34
+ const issueText = ({
35
+ heading,
36
+ issue,
37
+ }: {
38
+ heading: string
39
+ issue: BundleIssue
40
+ }): string =>
41
+ [`${heading} #${issue.number}: ${issue.title}`, '', issue.body.trim()].join(
42
+ '\n'
43
+ )
44
+
45
+ /** The bundle's text: the spec, its tickets in order, then the repo. Pure. */
46
+ export const formatSpecBundle = ({
47
+ spec,
48
+ tickets,
49
+ rule_files,
50
+ glossary,
51
+ adr,
52
+ }: SpecBundle): string =>
53
+ [
54
+ issueText({ heading: '== Spec', issue: spec }),
55
+ `Labels: ${spec.labels.length === 0 ? 'none' : spec.labels.join(', ')}`,
56
+ '',
57
+ tickets.length === 0
58
+ ? 'No tickets yet.'
59
+ : tickets
60
+ .map((ticket) =>
61
+ issueText({ heading: '== Ticket', issue: ticket })
62
+ )
63
+ .join('\n\n'),
64
+ '',
65
+ '== Repo',
66
+ '',
67
+ 'Rule files:',
68
+ ...(rule_files.length === 0
69
+ ? ['- none']
70
+ : rule_files.map((file) => `- ${file}`)),
71
+ `GLOSSARY.md: ${glossary ? 'yes' : 'no'}`,
72
+ `docs/adr/: ${adr ? 'yes' : 'no'}`,
73
+ ].join('\n')
74
+
75
+ // NOTE: these small readers are copies of the ones in
76
+ // `../../luca-retro/scripts/retro-summary.ts`: each skill's script must
77
+ // stand alone. Keep them in step.
78
+
79
+ const isObject = (value: unknown): value is Record<string, unknown> =>
80
+ typeof value === 'object' && value !== null && !Array.isArray(value)
81
+
82
+ const text = (value: unknown): string =>
83
+ typeof value === 'string' ? value : ''
84
+
85
+ const numberOr = (value: unknown): number | null =>
86
+ typeof value === 'number' ? value : null
87
+
88
+ const listOf = (value: unknown): unknown[] =>
89
+ Array.isArray(value) ? value : []
90
+
91
+ const objectOf = (value: unknown): Record<string, unknown> =>
92
+ isObject(value) ? value : {}
93
+
94
+ /** Says why, and exits. */
95
+ const fail = (why: string): never => {
96
+ console.error(why)
97
+ return process.exit(1)
98
+ }
99
+
100
+ /**
101
+ * Runs `gh api` on `path`: its JSON, or `null` when GitHub says Not Found.
102
+ * Any other failure exits with what `gh` said, so a lost connection never
103
+ * reads as a missing file.
104
+ */
105
+ const ghApi = (path: string): unknown => {
106
+ const out = Bun.spawnSync(['gh', 'api', path])
107
+ if (out.exitCode === 0) return JSON.parse(out.stdout.toString())
108
+ const said = out.stderr.toString().trim()
109
+ return said.includes('HTTP 404')
110
+ ? null
111
+ : fail(`gh api ${path} failed: ${said}`)
112
+ }
113
+
114
+ /** `ghApi`, where Not Found exits too. */
115
+ const ghFound = (path: string): unknown =>
116
+ ghApi(path) ?? fail(`gh api ${path} failed: Not Found (HTTP 404)`)
117
+
118
+ /** An issue, from `gh api`'s JSON. */
119
+ const toIssue = (raw: unknown): BundleIssue => {
120
+ const issue = objectOf(raw)
121
+ return {
122
+ number: numberOr(issue.number) ?? 0,
123
+ title: text(issue.title),
124
+ body: text(issue.body),
125
+ }
126
+ }
127
+
128
+ /** An issue's label names, from `gh api`'s JSON. */
129
+ const labelsOf = (raw: unknown): string[] =>
130
+ listOf(objectOf(raw).labels).map((label) =>
131
+ typeof label === 'string' ? label : text(objectOf(label).name)
132
+ )
133
+
134
+ /** A file's text, from `gh api`'s contents JSON, or `null` when it has none. */
135
+ const fileText = (raw: unknown): string | null => {
136
+ const file = objectOf(raw)
137
+ return file.encoding === 'base64'
138
+ ? Buffer.from(text(file.content), 'base64').toString('utf8')
139
+ : null
140
+ }
141
+
142
+ /** The `rule_files` in a `.luca/config.json`'s text, or none. */
143
+ const ruleFiles = (config: string | null): string[] => {
144
+ if (config === null) return []
145
+ try {
146
+ return listOf(objectOf(JSON.parse(config)).rule_files).filter(
147
+ (file): file is string => typeof file === 'string'
148
+ )
149
+ } catch {
150
+ return []
151
+ }
152
+ }
153
+
154
+ /** The spec number and the optional `--repo` value, or null when either is missing. Pure. */
155
+ export const parseArgs = (
156
+ argv: string[]
157
+ ): { number: number; repo: string | null } | null => {
158
+ const repoAt = argv.indexOf('--repo')
159
+ const repo = repoAt >= 0 ? argv[repoAt + 1] : null
160
+ if (repo === undefined) return null
161
+ const number = argv.find(
162
+ (arg, index) =>
163
+ /^\d+$/.test(arg) && (repoAt < 0 || index !== repoAt + 1)
164
+ )
165
+ return number === undefined ? null : { number: Number(number), repo }
166
+ }
167
+
168
+ const main = () => {
169
+ const args = parseArgs(Bun.argv.slice(2))
170
+ if (args === null) {
171
+ console.error(
172
+ 'Usage: bun spec-bundle.ts <spec number> [--repo <owner/repo>]'
173
+ )
174
+ process.exit(1)
175
+ }
176
+ // `gh` fills in `{owner}/{repo}` from the repo of the current folder.
177
+ const repo = `repos/${args.repo ?? '{owner}/{repo}'}`
178
+ const issue = `${repo}/issues/${args.number}`
179
+ const raw = ghFound(issue)
180
+ const subs = ghFound(`${issue}/sub_issues?per_page=100`)
181
+ console.log(
182
+ formatSpecBundle({
183
+ spec: { ...toIssue(raw), labels: labelsOf(raw) },
184
+ tickets: listOf(subs).map(toIssue),
185
+ rule_files: ruleFiles(
186
+ fileText(ghApi(`${repo}/contents/.luca/config.json`))
187
+ ),
188
+ glossary: ghApi(`${repo}/contents/GLOSSARY.md`) !== null,
189
+ adr: ghApi(`${repo}/contents/docs/adr`) !== null,
190
+ })
191
+ )
192
+ }
193
+
194
+ if (import.meta.main) main()