@alecsibilia/luca 14.0.0-alpha.24 → 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,15 @@
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
+
9
+ ## 14.0.0-alpha.25
10
+
11
+ No changes in this release.
12
+
3
13
  ## 14.0.0-alpha.24
4
14
 
5
15
  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.24",
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.24"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.26"
@@ -173,7 +173,7 @@ export const resultJsonSchema = ({
173
173
  * hooks.
174
174
  *
175
175
  * @example
176
- * const options = agentOptions({ role, may_edit_tests, cwd, model: CLAUDE_MODEL, effort: 'xhigh', claude_path, config, common_git_dir, home, env, guard_hook, luca_server: null, delivery_hook: null })
176
+ * const options = agentOptions({ role, may_edit_tests, cwd, model: CLAUDE_MODEL, effort: 'xhigh', claude_path, config, common_git_dir, home, user_temp_dir, env, guard_hook, luca_server: null, delivery_hook: null })
177
177
  */
178
178
  export const agentOptions = ({
179
179
  role,
@@ -185,6 +185,7 @@ export const agentOptions = ({
185
185
  config,
186
186
  common_git_dir,
187
187
  home,
188
+ user_temp_dir,
188
189
  env,
189
190
  guard_hook,
190
191
  luca_server,
@@ -203,6 +204,8 @@ export const agentOptions = ({
203
204
  config: EngineConfig
204
205
  common_git_dir: string
205
206
  home: string
207
+ /** macOS's per-user temp folder (`darwinUserTempDir`), or `null`. */
208
+ user_temp_dir: string | null
206
209
  env: Record<string, string>
207
210
  guard_hook: HookCallback
208
211
  /** The engine's `luca` server (`createLucaServer`), or `null` for none. */
@@ -253,6 +256,7 @@ export const agentOptions = ({
253
256
  worktree: cwd,
254
257
  common_git_dir,
255
258
  home,
259
+ user_temp_dir,
256
260
  config,
257
261
  }),
258
262
  env,
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Where the Claude launcher finds things on disk: the real path of the
3
- * `claude` binary, and the repo's common git dir (shared by its worktrees).
3
+ * `claude` binary, the repo's common git dir (shared by its worktrees), and
4
+ * macOS's per-user temp folder.
4
5
  */
5
6
  import { realpathSync } from 'node:fs'
7
+ import { isAbsolute } from 'node:path'
6
8
 
7
9
  import { runCommand } from '../shell/run-command'
8
10
 
@@ -27,3 +29,41 @@ export const commonGitDir = async ({
27
29
  }
28
30
  return realpathSync(result.stdout.trim())
29
31
  }
32
+
33
+ /** The command that prints macOS's per-user temp folder. */
34
+ export const GETCONF_USER_TEMP_DIR = ['getconf', 'DARWIN_USER_TEMP_DIR']
35
+
36
+ /**
37
+ * The real path of macOS's per-user temp folder (`getconf
38
+ * DARWIN_USER_TEMP_DIR`, such as `/private/var/folders/ab/cd1234/T`), or
39
+ * `null` off macOS, or when `getconf` fails or names no folder there.
40
+ *
41
+ * A sandboxed writer needs it: Foundation stages every atomic write there,
42
+ * not in `$TMPDIR`, so `swift test` and `xcodebuild` can't rebuild without
43
+ * it ("unable to write manifest ... Operation not permitted").
44
+ *
45
+ * @example
46
+ * const user_temp_dir = await darwinUserTempDir({ platform: process.platform, getconf: GETCONF_USER_TEMP_DIR })
47
+ */
48
+ export const darwinUserTempDir = async ({
49
+ platform,
50
+ getconf,
51
+ }: {
52
+ platform: NodeJS.Platform
53
+ /** The command to ask; the launcher passes `GETCONF_USER_TEMP_DIR`. */
54
+ getconf: string[]
55
+ }): Promise<string | null> => {
56
+ if (platform !== 'darwin') return null
57
+ try {
58
+ const result = await runCommand({
59
+ cmd: getconf,
60
+ cwd: '/',
61
+ timeout_ms: 10_000,
62
+ })
63
+ const path = result.stdout.trim()
64
+ if (result.exit_code !== 0 || !isAbsolute(path)) return null
65
+ return realpathSync(path)
66
+ } catch {
67
+ return null
68
+ }
69
+ }
@@ -25,7 +25,12 @@ import {
25
25
  } from './claude-messages'
26
26
  import { agentEnv, agentOptions, checkLaunchModel } from './claude-options'
27
27
  import { createInputChannel } from './input-channel'
28
- import { commonGitDir, realClaudePath } from './launch-paths'
28
+ import {
29
+ commonGitDir,
30
+ darwinUserTempDir,
31
+ GETCONF_USER_TEMP_DIR,
32
+ realClaudePath,
33
+ } from './launch-paths'
29
34
  import { createDeliveryHook, createLucaServer } from './message-tool'
30
35
  import {
31
36
  nextOutcome,
@@ -116,6 +121,10 @@ export const createLaunch = ({
116
121
  config,
117
122
  common_git_dir: gitDir,
118
123
  home: realpathSync(homedir()),
124
+ user_temp_dir: await darwinUserTempDir({
125
+ platform: process.platform,
126
+ getconf: GETCONF_USER_TEMP_DIR,
127
+ }),
119
128
  env: agentEnv({ source: process.env }),
120
129
  guard_hook: createGuardHook({
121
130
  role: guardRoleOf({ role }),
@@ -2,6 +2,7 @@ import { isAbsolute, join } from 'node:path'
2
2
 
3
3
  import type { SandboxSettings } from '@anthropic-ai/claude-agent-sdk'
4
4
 
5
+ import { isWriter } from './guard-roles'
5
6
  import { LOCKFILES, type GuardRole } from './role-rules'
6
7
 
7
8
  import type { EngineConfig } from '../config/engine-config'
@@ -37,8 +38,13 @@ const mustBeAbsolute = (paths: Record<string, string>): void => {
37
38
  * learner, and a test-writer that may not edit tests may not write the
38
39
  * worktree at all.
39
40
  *
41
+ * Test-writers and implementers, who run the test commands, may also write
42
+ * macOS's per-user temp folder (`user_temp_dir`), where Foundation stages
43
+ * atomic writes: without it, `swift test` and `xcodebuild` can't rebuild.
44
+ * Nothing else is opened, and nothing at all without that folder.
45
+ *
40
46
  * @example
41
- * const sandbox = sandboxSettings({ role: 'implementer', may_edit_tests: false, worktree, common_git_dir, home, config })
47
+ * const sandbox = sandboxSettings({ role: 'implementer', may_edit_tests: false, worktree, common_git_dir, home, user_temp_dir, config })
42
48
  */
43
49
  export const sandboxSettings = ({
44
50
  role,
@@ -46,6 +52,7 @@ export const sandboxSettings = ({
46
52
  worktree,
47
53
  common_git_dir,
48
54
  home,
55
+ user_temp_dir,
49
56
  config,
50
57
  }: {
51
58
  role: GuardRole
@@ -56,9 +63,19 @@ export const sandboxSettings = ({
56
63
  /** The repo's shared `.git` folder (`git rev-parse --git-common-dir`). */
57
64
  common_git_dir: string
58
65
  home: string
66
+ /**
67
+ * macOS's per-user temp folder (`darwinUserTempDir`), real and
68
+ * absolute, or `null` off macOS or when it can't be found.
69
+ */
70
+ user_temp_dir: string | null
59
71
  config: EngineConfig
60
72
  }): SandboxSettings => {
61
- mustBeAbsolute({ worktree, common_git_dir, home })
73
+ mustBeAbsolute({
74
+ worktree,
75
+ common_git_dir,
76
+ home,
77
+ ...(user_temp_dir === null ? {} : { user_temp_dir }),
78
+ })
62
79
  const git = [common_git_dir, join(worktree, '.git')]
63
80
  const inWorktree = (patterns: string[]) =>
64
81
  patterns.map((pattern) => join(worktree, pattern))
@@ -87,6 +104,9 @@ export const sandboxSettings = ({
87
104
  allowLocalBinding: false,
88
105
  },
89
106
  filesystem: {
107
+ ...(user_temp_dir !== null && isWriter(role)
108
+ ? { allowWrite: [user_temp_dir] }
109
+ : {}),
90
110
  denyWrite: [...git, ...byRole[role]],
91
111
  denyRead: SECRET_PATHS.map((path) => join(home, path)),
92
112
  },
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.24",
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()