@pmelab/gtd 17.2.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "17.2.0",
3
+ "version": "18.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/schema.json CHANGED
@@ -97,7 +97,7 @@
97
97
  },
98
98
  "skills": {
99
99
  "type": "object",
100
- "description": "Flat step full-name -> skill-name array map. Each entry REPLACES the named step's bundled skill list wholesale (never merges into it); [] means no skills at all for that step, and no preamble. A key naming a step the workflow in play does not declare is a load error listing the known names — the schema itself cannot validate a key, only a value's shape.",
100
+ "description": "Flat scope full-name -> skill-name array map. Each entry REPLACES the named scope's skill list wholesale (never merges into it) and reaches every nested scope that sets no list of its own; [] means no skills at all for that scope, and no preamble. A key that is not a scope running a turn is a load error listing the known scopes — the schema itself cannot validate a key, only a value's shape.",
101
101
  "additionalProperties": {
102
102
  "type": "array",
103
103
  "items": {
@@ -115,10 +115,12 @@ needs across steps — a counter, the previous report, a review round's base —
115
115
  lives in local variables; replay rebuilds them.
116
116
 
117
117
  Composition: `scope(name, fn)` prefixes step names (`build.fix`) and sets their
118
- **memory scope** (one scope = one agent conversation = one model/system — mixing
119
- them inside a scope fails the process); `scope({ name?, model, system }, fn)`
120
- also sets defaults for agent steps inside. `refuse(message)` refuses the pending
121
- landing — call it right after the step whose turn you reject.
118
+ **memory scope** (one scope = one agent conversation = one model/system/skills —
119
+ mixing them inside a scope fails the process);
120
+ `scope({ name?, model, system, skills }, fn)` also sets defaults for agent steps
121
+ inside. Skills are declared per scope, never on `agent()`, and a driver
122
+ restricts each turn to its scope's list where it can. `refuse(message)` refuses
123
+ the pending landing — call it right after the step whose turn you reject.
122
124
  `@pmelab/gtd/flows` exports `requireProgress(file)`, `requireAnswers(file)` and
123
125
  `requireRevert(edited, base)`, three such checks ready-made.
124
126
 
@@ -24,8 +24,6 @@ export interface AgentOptions extends SteeringOptions {
24
24
  * is an attempt: recorded, but the process stays at the step (a stall).
25
25
  */
26
26
  readonly allowEmpty?: boolean | undefined
27
- /** The skill names this turn declares — what `gtd next --json`'s `skills` key carries. */
28
- readonly skills?: readonly string[] | undefined
29
27
  }
30
28
 
31
29
  export interface HumanOptions extends SteeringOptions {
@@ -92,6 +90,8 @@ export interface ScopeOptions {
92
90
  readonly model?: string | undefined
93
91
  /** The system prompt every agent step inside runs with, unless it sets its own. */
94
92
  readonly system?: string | undefined
93
+ /** The skill names every agent step inside declares — `gtd next --json`'s `skills`. A nested scope inherits it unless it sets its own. */
94
+ readonly skills?: readonly string[] | undefined
95
95
  }
96
96
 
97
97
  export type StepRequest =
@@ -139,15 +139,8 @@ export interface FlowContext {
139
139
  readonly env: Readonly<Record<string, string>>
140
140
  readonly head: () => string
141
141
  readonly start: () => string
142
- /**
143
- * The skill list `localName` (scoped from here, same as `agent()`) resolves
144
- * to — for a prompt preamble. `ownSkills`, when given, is the same
145
- * precedence tier as a call's own `skills` option: it stands in for the
146
- * bundled default when there isn't one, but a `.gtdrc` entry still beats
147
- * it. Shares its resolution with `agent()`'s own wire resolver (both read
148
- * `Workflow.ts`'s `configuredSkills`/`skills`), so the two can never drift.
149
- */
150
- readonly skillsFor: (localName: string, ownSkills?: readonly string[]) => readonly string[]
142
+ /** The skill list the memory scope `localName` lands in resolves to — the same resolver as the wire's `skills`. */
143
+ readonly skillsFor: (localName: string) => readonly string[]
151
144
  }
152
145
 
153
146
  const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
@@ -253,9 +246,8 @@ export const head = (): string => ctx().head()
253
246
  /** The process's diff base: the commit before it began, or the base `gtd --entry` fixed. */
254
247
  export const start = (): string => ctx().start()
255
248
 
256
- /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. See `FlowContext.skillsFor` for `ownSkills`. */
257
- export const skillsFor = (localName: string, ownSkills?: readonly string[]): readonly string[] =>
258
- ctx().skillsFor(localName, ownSkills)
249
+ /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
250
+ export const skillsFor = (localName: string): readonly string[] => ctx().skillsFor(localName)
259
251
 
260
252
  const settingsProxy = (
261
253
  read: () => Readonly<Record<string, string>>,
@@ -180,13 +180,20 @@ export const questionBarReturn = `- This lap continues the same goal as the firs
180
180
  checkboxes
181
181
  - \`## Answered Questions\` is always the last \`##\` section — a moved
182
182
  question lands there, never wherever \`## Open Questions\` used to sit
183
- - Never re-raise a deleted question, and never re-open a settled
184
- \`## Answered Questions\` entry
183
+ - Never re-raise a deleted question. A settled \`## Answered Questions\`
184
+ entry stays settled, except one carrying a human footnote on its
185
+ \`### \` heading: that is the only entry that may change. Fold the note
186
+ in, then either rewrite the recorded answer in place (still under
187
+ \`## Answered Questions\`) or, when the note leaves the point genuinely
188
+ open, move it back to \`## Open Questions\` as a fresh fork with
189
+ options and the \`_your answer_\` slot. A note that only asks a
190
+ question follows the footnote rule: one \`- A:\` reply, answer unchanged
185
191
  - An answer may earn a follow-up: if it opens a genuinely new fork above
186
192
  the bar — one the answer itself created — raise it as a fresh \`##
187
193
  Open Questions\` entry on this same lap. Never restate a question
188
194
  already asked, and never treat this as licence to re-open a question
189
- already settled under \`## Answered Questions\`
195
+ already settled under \`## Answered Questions\` that carries no
196
+ footnote on its heading
190
197
  - Recognise a silent lap from \`## Open Questions\` still present with
191
198
  nothing ticked and nothing else changed — the human's way of saying
192
199
  the gap is already closed. That lap ends the questions, whatever the
@@ -64,7 +64,7 @@ const drive = async (d: Drive): Promise<Run> => {
64
64
  },
65
65
  "review.closing": () => void files.delete(REVIEW),
66
66
  "review.collecting": () => void files.set(REQUIREMENTS, "## Concern"),
67
- "review.fix-nits": () => {
67
+ "review.fix.nits.fixing": () => {
68
68
  nitsFixed = true
69
69
  },
70
70
  }
@@ -118,22 +118,22 @@ describe("the risk-fix pass", () => {
118
118
  const run = await drive({ notes: [], reviewDocs: [risky, clean] })
119
119
  expect(run.log.slice(0, 5)).toEqual([
120
120
  "review.reviewing",
121
- "review.fix-risks",
121
+ "review.fix.risks.fixing",
122
122
  "health.check",
123
123
  "review.reviewing",
124
124
  "review.await-review",
125
125
  ])
126
- expect(run.prompts.get("review.fix-risks")).toContain("risk-1")
127
- expect(run.prompts.get("review.fix-risks")).toContain("Risk: drops the carry")
128
- expect(run.prompts.get("review.fix-risks")).toContain("Leave `.gtd/REVIEW.md` untouched")
126
+ expect(run.prompts.get("review.fix.risks.fixing")).toContain("risk-1")
127
+ expect(run.prompts.get("review.fix.risks.fixing")).toContain("Risk: drops the carry")
128
+ expect(run.prompts.get("review.fix.risks.fixing")).toContain("Leave `.gtd/REVIEW.md` untouched")
129
129
  })
130
130
 
131
131
  it("a risk the re-review still marks goes to the gate with no second fix", async () => {
132
132
  const run = await drive({ notes: [], reviewDocs: [risky, risky] })
133
- expect(run.log.filter((n) => n === "review.fix-risks")).toHaveLength(1)
133
+ expect(run.log.filter((n) => n === "review.fix.risks.fixing")).toHaveLength(1)
134
134
  expect(run.log.slice(0, 5)).toEqual([
135
135
  "review.reviewing",
136
- "review.fix-risks",
136
+ "review.fix.risks.fixing",
137
137
  "health.check",
138
138
  "review.reviewing",
139
139
  "review.await-review",
@@ -142,7 +142,7 @@ describe("the risk-fix pass", () => {
142
142
 
143
143
  it("no marker means no fix-risks step", async () => {
144
144
  const run = await drive({ notes: [], reviewDocs: [clean] })
145
- expect(run.log).not.toContain("review.fix-risks")
145
+ expect(run.log).not.toContain("review.fix.risks.fixing")
146
146
  expect(run.log[0]).toBe("review.reviewing")
147
147
  expect(run.log[1]).toBe("review.await-review")
148
148
  })
@@ -157,11 +157,11 @@ describe("the risk-fix pass", () => {
157
157
  "review.reviewing",
158
158
  "review.await-review",
159
159
  "review.triage",
160
- "review.fix-nits",
160
+ "review.fix.nits.fixing",
161
161
  "health.check",
162
162
  "review.closing",
163
163
  "review.reviewing",
164
- "review.fix-risks",
164
+ "review.fix.risks.fixing",
165
165
  "health.check",
166
166
  "review.reviewing",
167
167
  "review.await-review",
@@ -250,12 +250,12 @@ describe("review verdict routing", () => {
250
250
  "review.await-review",
251
251
  "review.triage",
252
252
  "review.answer-review-questions",
253
- "review.fix-nits",
253
+ "review.fix.nits.fixing",
254
254
  "health.check",
255
255
  "review.closing",
256
256
  "review.reviewing",
257
257
  ])
258
- expect(run.prompts.get("review.fix-nits")).not.toContain("why?")
258
+ expect(run.prompts.get("review.fix.nits.fixing")).not.toContain("why?")
259
259
  expect(run.prompts.get("review.reviewing")).toContain("Carry-over: commit `c4`")
260
260
  expect(run.result).toBe("stopped")
261
261
  })
@@ -278,7 +278,7 @@ describe("review verdict routing", () => {
278
278
  "review.await-review",
279
279
  "review.triage",
280
280
  "review.answer-review-questions",
281
- "review.fix-nits",
281
+ "review.fix.nits.fixing",
282
282
  "health.check",
283
283
  "review.closing",
284
284
  "review.collecting",
@@ -36,9 +36,9 @@ import {
36
36
  } from "./steps.js"
37
37
  import * as t from "./text.js"
38
38
 
39
- /** The lenses the quality lap reviews with, one turn each: the `qualityReviews` var, split on `,` and trimmed. Unlike a `skills:` entry, this fans out into one whole turn per entry rather than naming one step's skill list — see `build.quality.reviewing` in `./skills.ts` for the (separate) skills a lens turn itself loads. */
40
- export const qualityLenses = (): readonly string[] =>
41
- (vars.qualityReviews ?? "")
39
+ /** Split a `qualityReviews` value into its lenses: on `,`, trimmed, blanks dropped. */
40
+ export const lensesOf = (value: string): readonly string[] =>
41
+ value
42
42
  .split(",")
43
43
  .map((lens) => lens.trim())
44
44
  .filter((lens) => lens.length > 0)
@@ -49,7 +49,7 @@ export const qualityLenses = (): readonly string[] =>
49
49
  * has any.
50
50
  */
51
51
  export const qualityLap = async (): Promise<"clean" | "findings"> => {
52
- for (const lens of qualityLenses()) await reviewQuality(lens)
52
+ for (const lens of lensesOf(vars.qualityReviews ?? "")) await reviewQuality(lens)
53
53
  return (read(QUALITY) ?? "").length > 0 ? "findings" : "clean"
54
54
  }
55
55
 
@@ -1,94 +1,80 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { skills } from "./skills.js"
3
+ import { unified } from "./index.js"
3
4
 
4
- // Pins every bundled step's own declared list, keyed by its full name. This
5
- // file alone only catches a LOCAL-name rename (the list literally changes);
6
- // it cannot catch a SCOPE rename (e.g. `scope("architecture")` renamed to
7
- // `scope("design.architecture")`), because it never runs the real flow — it
8
- // compares the map against itself. Every one of the sixteen keys has its
9
- // own e2e grounding closing that gap instead — each runs the actual scoped
10
- // name through replay, so a scope rename desyncs a preamble assertion there,
11
- // not just silently drops a step's skills (see AGENTS.md's "workflow is
12
- // code" rule: a step's full name is public API):
13
- // - design.triage, architecture.author, architecture.decompose,
14
- // packages.item.building — default-workflow.feature's main scenario
15
- // - packages.item.spec.review, packages.item.fix-spec — the same
16
- // scenario, continued
17
- // - packages.item.fix-suite, packages.item.health.describe — the
18
- // "escalation loop ... under packages.item" scenario
19
- // - build.fix — state-skills.feature
20
- // - build.health.describe — the "repeated check failures escalate"
21
- // scenario
22
- // - build.quality.reviewing — per-step-skills.feature
23
- // - build.fix-quality — quality-review-lap.feature
24
- // - build.review.reviewing, build.review.collecting —
25
- // default-workflow.feature's review-loop scenarios
26
- // - build.review.answer-review-questions, build.review.fix-nits — NOT yet
27
- // e2e-grounded; only steps.test.ts's preamble checks, which use local names
28
- describe("the bundled workflow's skills map", () => {
29
- it("declares exactly the seventeen bundled agent steps, by full name", () => {
30
- expect(Object.keys(skills).sort()).toEqual(
5
+ const { defaults } = unified
6
+
7
+ const bundled = skills(defaults)
8
+
9
+ // Pins the lists by scope full name. It never runs the real flow, so a
10
+ // scope rename is caught by the e2e features that rest on these names
11
+ // (default-workflow, state-skills, scope-skills, quality-review-lap), not here.
12
+ describe("the bundled workflow's skills export", () => {
13
+ it("declares exactly the bundled scopes under the default qualityReviews", () => {
14
+ expect(Object.keys(bundled).sort()).toEqual(
31
15
  [
32
- "design.triage",
33
- "architecture.author",
16
+ "design",
17
+ "architecture",
34
18
  "architecture.decompose",
35
- "packages.item.building",
36
- "packages.item.fix-suite",
37
- "packages.item.fix-spec",
38
- "packages.item.spec.review",
39
- "packages.item.health.describe",
40
- "build.fix",
41
- "build.health.describe",
42
- "build.quality.reviewing",
43
- "build.fix-quality",
44
- "build.review.reviewing",
45
- "build.review.answer-review-questions",
46
- "build.review.fix-nits",
47
- "build.review.fix-risks",
48
- "build.review.collecting",
19
+ "packages.item",
20
+ "packages.item.fix.suite",
21
+ "packages.item.fix.spec",
22
+ "packages.item.spec",
23
+ "packages.item.health",
24
+ "build",
25
+ "build.health",
26
+ "build.fix.quality",
27
+ "build.review",
28
+ "build.review.fix.nits",
29
+ "build.review.fix.risks",
30
+ "build.quality.correctness",
31
+ "build.quality.owasp-security",
32
+ "build.quality.ponytail-review",
33
+ "build.quality.test-audit",
34
+ "build.quality.conventions",
35
+ "build.quality.spec-challenge",
49
36
  ].sort(),
50
37
  )
51
38
  })
52
39
 
53
- it("packages.item.health.describe and build.health.describe are both present, with the same list — the shared escalation loop resolves under both scopes", () => {
54
- expect(skills["packages.item.health.describe"]).toEqual(skills["build.health.describe"])
55
- expect(skills["packages.item.health.describe"]).toEqual(["debugging-and-error-recovery"])
40
+ it("keys no group scope", () => {
41
+ for (const group of ["packages.item.fix", "build.fix", "build.review.fix", "build.quality"]) {
42
+ expect(bundled).not.toHaveProperty(group)
43
+ }
44
+ })
45
+
46
+ it("pins each scope's list", () => {
47
+ expect(bundled).toMatchObject({
48
+ design: ["spec-driven-development", "planning-and-task-breakdown"],
49
+ architecture: ["api-and-interface-design", "documentation-and-adrs", "ponytail"],
50
+ "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
51
+ "packages.item": ["test-driven-development", "incremental-implementation"],
52
+ "packages.item.fix.suite": ["debugging-and-error-recovery"],
53
+ "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
54
+ "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
55
+ "packages.item.health": ["debugging-and-error-recovery"],
56
+ build: ["debugging-and-error-recovery"],
57
+ "build.health": ["debugging-and-error-recovery"],
58
+ "build.fix.quality": ["incremental-implementation", "code-simplification"],
59
+ "build.review": ["code-review-and-quality"],
60
+ "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
61
+ "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
62
+ })
63
+ })
64
+
65
+ it("loads one lens skill per quality lens: correctness its review skill, conventions and spec-challenge none, any other itself", () => {
66
+ expect(bundled["build.quality.correctness"]).toEqual(["code-review-and-quality"])
67
+ expect(bundled["build.quality.conventions"]).toEqual([])
68
+ expect(bundled["build.quality.spec-challenge"]).toEqual([])
69
+ expect(bundled["build.quality.owasp-security"]).toEqual(["owasp-security"])
56
70
  })
57
71
 
58
- it("pins each step's own declared list", () => {
59
- expect(skills["design.triage"]).toEqual([
60
- "spec-driven-development",
61
- "planning-and-task-breakdown",
62
- ])
63
- expect(skills["architecture.author"]).toEqual([
64
- "api-and-interface-design",
65
- "documentation-and-adrs",
66
- "ponytail",
67
- ])
68
- expect(skills["architecture.decompose"]).toEqual([
69
- "incremental-implementation",
70
- "planning-and-task-breakdown",
71
- ])
72
- expect(skills["packages.item.building"]).toEqual([
73
- "test-driven-development",
74
- "incremental-implementation",
75
- ])
76
- expect(skills["packages.item.fix-suite"]).toEqual(["debugging-and-error-recovery"])
77
- expect(skills["build.fix"]).toEqual(["debugging-and-error-recovery"])
78
- expect(skills["packages.item.fix-spec"]).toEqual([
79
- "incremental-implementation",
80
- "code-simplification",
81
- ])
82
- expect(skills["build.fix-quality"]).toEqual([
83
- "incremental-implementation",
84
- "code-simplification",
85
- ])
86
- expect(skills["packages.item.spec.review"]).toEqual([
87
- "code-review-and-quality",
88
- "spec-driven-development",
89
- ])
90
- expect(skills["build.review.reviewing"]).toEqual(["code-review-and-quality"])
91
- expect(skills["build.review.collecting"]).toEqual(["code-review-and-quality"])
92
- expect(skills["build.quality.reviewing"]).toEqual([])
72
+ it("keys one lens scope per qualityReviews entry", () => {
73
+ const custom = skills({ ...defaults, qualityReviews: " my-lens ,, correctness" })
74
+ expect(
75
+ Object.keys(custom)
76
+ .filter((k) => k.startsWith("build.quality."))
77
+ .sort(),
78
+ ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
93
79
  })
94
80
  })
@@ -1,38 +1,34 @@
1
- // The bundled workflow's default skills, one list per agent step, keyed by
2
- // the step's FULL name (its scope prefixes included) — the same name a
3
- // `.gtdrc` `skills:` entry addresses. A step's name is not derivable from
4
- // flow code alone (loops, branches, shared calls), so this map is the only
5
- // place every addressable name is declared up front; `steps.test.ts` pins
6
- // each step's declared list against a renamed map key.
7
- //
8
- // `packages.item.health.describe` and `build.health.describe` are not a
9
- // duplicate: the escalation loop (`healthy()`, in `./health.ts`) is shared
10
- // code reached from both `scope("packages") -> scope("item")` and
11
- // `scope("build")`, so the one `health.describe` step name resolves to two
12
- // different full names depending on which caller is looping.
13
- export const skills: Readonly<Record<string, readonly string[]>> = {
14
- "design.triage": ["spec-driven-development", "planning-and-task-breakdown"],
15
- "architecture.author": ["api-and-interface-design", "documentation-and-adrs", "ponytail"],
1
+ import { builtInLenses } from "./steps.js"
2
+ import { lensesOf } from "./review.js"
3
+
4
+ // The bundled workflow's default skills, one list per SCOPE, keyed by the
5
+ // scope's full name — the same name a `.gtdrc` `skills:` entry addresses. A
6
+ // nested scope with no entry inherits its parent's; group scopes
7
+ // (`packages.item.fix`, `build.fix`, `build.review.fix`, `build.quality`) run
8
+ // no turn and carry no key. `build.quality.<lens>` is one key per
9
+ // `qualityReviews` entry, so the export is a function of the vars.
10
+ export const skills = (
11
+ vars: Readonly<Record<string, string>>,
12
+ ): Readonly<Record<string, readonly string[]>> => ({
13
+ design: ["spec-driven-development", "planning-and-task-breakdown"],
14
+ architecture: ["api-and-interface-design", "documentation-and-adrs", "ponytail"],
16
15
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
17
- "packages.item.building": ["test-driven-development", "incremental-implementation"],
18
- "packages.item.fix-suite": ["debugging-and-error-recovery"],
19
- "packages.item.fix-spec": ["incremental-implementation", "code-simplification"],
20
- "packages.item.spec.review": ["code-review-and-quality", "spec-driven-development"],
21
- "packages.item.health.describe": ["debugging-and-error-recovery"],
22
- "build.fix": ["debugging-and-error-recovery"],
23
- "build.health.describe": ["debugging-and-error-recovery"],
24
- // No bundled default: unlike every other entry, the lens named in the
25
- // prompt BODY already varies per turn (see `reviewQuality` in
26
- // `./steps.ts`), which is what the preamble falls back to naming instead —
27
- // a fixed bundled list here would repeat one entry on every turn. A
28
- // `.gtdrc` `skills:` entry still overrides that per-turn fallback, on the
29
- // wire and in the preamble alike (`resolveSkills` in `../replay/Replay.ts`
30
- // is what both share).
31
- "build.quality.reviewing": [],
32
- "build.fix-quality": ["incremental-implementation", "code-simplification"],
33
- "build.review.reviewing": ["code-review-and-quality"],
34
- "build.review.answer-review-questions": ["code-review-and-quality"],
35
- "build.review.fix-nits": ["incremental-implementation", "code-simplification"],
36
- "build.review.fix-risks": ["debugging-and-error-recovery", "incremental-implementation"],
37
- "build.review.collecting": ["code-review-and-quality"],
38
- }
16
+ "packages.item": ["test-driven-development", "incremental-implementation"],
17
+ "packages.item.fix.suite": ["debugging-and-error-recovery"],
18
+ "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
19
+ "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
20
+ // `health` is shared code reached from both `packages.item` and `build`.
21
+ "packages.item.health": ["debugging-and-error-recovery"],
22
+ build: ["debugging-and-error-recovery"],
23
+ "build.health": ["debugging-and-error-recovery"],
24
+ "build.fix.quality": ["incremental-implementation", "code-simplification"],
25
+ "build.review": ["code-review-and-quality"],
26
+ "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
27
+ "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
28
+ ...Object.fromEntries(
29
+ lensesOf(vars.qualityReviews ?? "").map((lens) => [
30
+ `build.quality.${lens}`,
31
+ builtInLenses[lens]?.skills ?? [lens],
32
+ ]),
33
+ ),
34
+ })