@pmelab/gtd 15.9.0 → 16.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.
@@ -4,6 +4,7 @@ import {
4
4
  type FlowContext,
5
5
  type StepRequest,
6
6
  } from "../flows/index.js"
7
+ import { skills as bundledSkills } from "./skills.js"
7
8
  import { defaults } from "./vars.js"
8
9
 
9
10
  export interface TextContext {
@@ -12,12 +13,46 @@ export interface TextContext {
12
13
  readonly start?: string
13
14
  readonly codeThreads?: readonly CodeThreadInfo[]
14
15
  readonly read?: (path: string) => string | undefined
16
+ /**
17
+ * A PER-KEY `skillsFor` override, keyed by the LOCAL name a bundled step
18
+ * calls itself with (`captureStep`/`renderText` push no scopes, so a local
19
+ * name is also the only name here) — stands in for a `.gtdrc` entry, so
20
+ * only the key it names is overridden; every other step still falls back
21
+ * to the call's own `skills` option, then the bundled map (`./skills.ts`),
22
+ * so an existing step test keeps its skills without having to declare them
23
+ * itself.
24
+ */
25
+ readonly skills?: Readonly<Record<string, readonly string[]>>
15
26
  }
16
27
 
17
28
  const unavailable = (): never => {
18
29
  throw new Error("not available while rendering a text outside a replay")
19
30
  }
20
31
 
32
+ /** The bundled map is keyed by FULL name, but a bundled step calls itself by its own LOCAL name and this fixture pushes no scope to prefix it with — so the default lookup matches on the local name alone, or as the last `.`-separated segment of a bundled key. `health.describe`'s two bundled entries (`packages.item.health.describe`, `build.health.describe`) share one list, so the ambiguity this leaves resolves to the same answer either way. */
33
+ const bundledSkillsFor = (localName: string): readonly string[] => {
34
+ const exact = bundledSkills[localName]
35
+ if (exact !== undefined) return exact
36
+ const suffix = `.${localName}`
37
+ const hit = Object.entries(bundledSkills).find(([key]) => key.endsWith(suffix))
38
+ return hit?.[1] ?? []
39
+ }
40
+
41
+ /**
42
+ * Mirrors `Replay.ts`'s `resolveSkills` three-way precedence: `context.skills`
43
+ * (a per-key override, standing in for `.gtdrc`) beats `ownSkills` (a call's
44
+ * own `skills` option), which beats the bundled default — so a step test that
45
+ * passes its own `skills` option (like `reviewQuality`'s lens) sees it in the
46
+ * preamble too, the same as a real replay would.
47
+ */
48
+ const skillsForOf =
49
+ (context: TextContext) =>
50
+ (localName: string, ownSkills?: readonly string[]): readonly string[] => {
51
+ const configured = context.skills?.[localName]
52
+ if (configured !== undefined) return configured
53
+ return ownSkills ?? bundledSkillsFor(localName)
54
+ }
55
+
21
56
  /** The fixed replay context texts render against; steps, refusals and scopes are unavailable unless overridden. */
22
57
  export const fixtureContext = (
23
58
  context: TextContext = {},
@@ -40,6 +75,7 @@ export const fixtureContext = (
40
75
  vars: { ...defaults, ...context.vars },
41
76
  head: () => context.head ?? "",
42
77
  start: () => context.start ?? "",
78
+ skillsFor: skillsForOf(context),
43
79
  ...overrides,
44
80
  })
45
81
 
@@ -8,7 +8,6 @@ import {
8
8
  buildReviewCollectingPrompt,
9
9
  designGateAnswerMessage,
10
10
  designTriagePrompt,
11
- splitSkills,
12
11
  summaryPrompt,
13
12
  withSkills,
14
13
  } from "./text.js"
@@ -30,42 +29,36 @@ describe("withSkills", () => {
30
29
  })
31
30
  })
32
31
 
33
- describe("splitSkills", () => {
34
- it("splits on comma, trims each name, drops empty entries", () => {
35
- expect(splitSkills("a, b")).toEqual(["a", "b"])
36
- expect(splitSkills(" a ,, b ")).toEqual(["a", "b"])
37
- })
38
-
39
- it("returns [] for undefined, empty, or blank input", () => {
40
- expect(splitSkills(undefined)).toEqual([])
41
- expect(splitSkills("")).toEqual([])
42
- expect(splitSkills(" ")).toEqual([])
43
- })
44
- })
45
-
46
32
  describe("agentWithSkills", () => {
47
- const capture = (fn: () => Promise<void>) => captureStep(fn)
33
+ const capture = (fn: () => Promise<void>, skills?: Readonly<Record<string, readonly string[]>>) =>
34
+ captureStep(fn, skills !== undefined ? { skills } : {})
48
35
 
49
- it("produces a byte-identical preamble to withSkills for a comma-free name list", async () => {
50
- const skills = "code-review, testing"
51
- const viaHelper = await capture(() => agentWithSkills("name", skills, "do-the-work"))
52
- if (viaHelper.kind !== "agent") throw new Error("unreachable")
53
- expect(viaHelper.prompt).toBe(withSkills(skills, "do-the-work"))
36
+ it("puts skillsFor(name)'s list, joined, in the preamble", async () => {
37
+ const request = await capture(() => agentWithSkills("name", "do-the-work"), {
38
+ name: ["code-review", "testing"],
39
+ })
40
+ if (request.kind !== "agent") throw new Error("unreachable")
41
+ expect(request.prompt).toBe(withSkills("code-review, testing", "do-the-work"))
54
42
  })
55
43
 
56
- it("passes the skills list, split, as the option, alongside the preamble", async () => {
57
- const request = await capture(() =>
58
- agentWithSkills("name", "code-review, testing", "do-the-work"),
59
- )
44
+ it("passes no skills option itself — the replay resolver fills the wire field", async () => {
45
+ const request = await capture(() => agentWithSkills("name", "do-the-work"))
60
46
  if (request.kind !== "agent") throw new Error("unreachable")
61
- expect(request.options.skills).toEqual(["code-review", "testing"])
47
+ expect(request.options.skills).toBeUndefined()
62
48
  })
63
49
 
64
- it("leaves the prompt bare and the option an empty array when skills is undefined", async () => {
65
- const request = await capture(() => agentWithSkills("name", undefined, "do-the-work"))
50
+ it("leaves the prompt bare when the name resolves to no configured skills", async () => {
51
+ const request = await capture(() => agentWithSkills("name", "do-the-work"), { name: [] })
66
52
  if (request.kind !== "agent") throw new Error("unreachable")
67
53
  expect(request.prompt).toBe("do-the-work")
68
- expect(request.options.skills).toEqual([])
54
+ })
55
+
56
+ it("still accepts an explicit options.skills of its own, untouched", async () => {
57
+ const request = await capture(() =>
58
+ agentWithSkills("name", "do-the-work", { skills: ["own-skill"] }),
59
+ )
60
+ if (request.kind !== "agent") throw new Error("unreachable")
61
+ expect(request.options.skills).toEqual(["own-skill"])
69
62
  })
70
63
  })
71
64
 
@@ -2,6 +2,7 @@ import {
2
2
  agent,
3
3
  codeThreads,
4
4
  head,
5
+ skillsFor,
5
6
  start,
6
7
  vars,
7
8
  type AgentOptions,
@@ -36,31 +37,23 @@ export const withSkills = (skills: string | undefined, prompt: string): string =
36
37
  return `${skillsPreamble.replaceAll("{skills}", skills)}\n\n${prompt}`
37
38
  }
38
39
 
39
- /** `skills:` split on `,`, each name trimmed, empty entries dropped — `[]` for `undefined`, `""`, or blank. */
40
- export const splitSkills = (raw: string | undefined): readonly string[] =>
41
- (raw ?? "")
42
- .split(",")
43
- .map((name) => name.trim())
44
- .filter((name) => name.length > 0)
45
-
46
40
  /**
47
- * An `agent()` step declaring `skills`: the raw, comma-separated var feeds
48
- * both the `skills` option (the wire's `skills` key, split) and, re-joined
49
- * with `", "`, `withSkills`'s preamble — one declaration, so the two can
50
- * never drift apart.
41
+ * An `agent()` step whose preamble names its resolved skills: `skillsFor`
42
+ * resolves `name` (scoped from here, same as `agent()`'s own resolver) for
43
+ * the preamble prose, passing `options.skills` through as `ownSkills` — the
44
+ * same precedence tier `agent()`'s own wire resolver gives a call's own
45
+ * `skills` option, so a step that declares one (`reviewQuality`'s per-turn
46
+ * lens) gets it in the preamble too, not just on the wire, whenever no
47
+ * bundled or configured entry outranks it. Both reads share one resolver
48
+ * (`Replay.ts`'s `resolveSkills`), so the preamble and the wire can't drift
49
+ * apart.
51
50
  */
52
51
  export const agentWithSkills = (
53
52
  name: string,
54
- skills: string | undefined,
55
53
  prompt: string,
56
54
  options: AgentOptions = {},
57
- ): Promise<void> => {
58
- const list = splitSkills(skills)
59
- return agent(name, withSkills(list.join(", "), prompt), {
60
- ...options,
61
- skills: list,
62
- })
63
- }
55
+ ): Promise<void> =>
56
+ agent(name, withSkills(skillsFor(name, options.skills).join(", "), prompt), options)
64
57
 
65
58
  export const unwindFailure = (commit: string): string =>
66
59
  `gtd could not unwind ${commit} out of your working tree.`
@@ -814,10 +807,13 @@ every commit is machine-authored.
814
807
  high-level architectural changes — never which files changed; \`git
815
808
  diff --stat\` is for that, not this message
816
809
  - If the range removes or renames a documented config key, CLI flag, or
817
- workflow state — a public surface, not an internal one — add a literal
818
- \`BREAKING CHANGE:\` footer naming what disappears. A \`!\` on the type
819
- prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's own
820
- commit-analyzer has missed that marker before, cutting no release
810
+ workflow state, OR makes a previously-accepted config value now fail to
811
+ load (a stricter validation, an escape a value must now spell differently)
812
+ — a public surface, not an internal one, either way — add a literal
813
+ \`BREAKING CHANGE:\` footer naming what breaks and, for the second case,
814
+ the exact escape or rewrite a committed \`.gtdrc\` needs. A \`!\` on the
815
+ type prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's
816
+ own commit-analyzer has missed that marker before, cutting no release
821
817
  until a follow-up commit carried the literal footer instead
822
818
 
823
819
  The process's entry commit is \`${it.entryCommit}\`. ${human}Inspect the range: \`git log ${it.processBase}..${it.processTip}\`
@@ -32,6 +32,7 @@ import * as t from "./text.js"
32
32
 
33
33
  export { threads, type ThreadInfo } from "../flows/index.js"
34
34
  export { defaults } from "./vars.js"
35
+ export { skills } from "./skills.js"
35
36
  export { agentWithSkills } from "./text.js"
36
37
  export * from "./steps.js"
37
38
  export * from "./health.js"
@@ -10,14 +10,12 @@ export const defaults: Readonly<Record<string, string>> = {
10
10
  specPreJudge: "0.9",
11
11
  reviewNoteActionable: "0.7",
12
12
  architectureSkipMinP: "0.85",
13
- triageSkills: "spec-driven-development, planning-and-task-breakdown",
14
- architectureSkills: "api-and-interface-design, documentation-and-adrs, ponytail",
15
- decomposeSkills: "incremental-implementation, planning-and-task-breakdown",
16
- buildSkills: "test-driven-development, incremental-implementation",
17
- fixSkills: "debugging-and-error-recovery",
18
- reviewFixSkills: "incremental-implementation, code-simplification",
19
- reviewSkills: "code-review-and-quality",
20
- specReviewSkills: "code-review-and-quality, spec-driven-development",
21
- escalateSkills: "debugging-and-error-recovery",
13
+ // The per-step skill lists formerly declared here as `*Skills` vars now
14
+ // live in `./skills.ts`, addressed per step by `.gtdrc` `skills:`.
15
+ // `qualityReviews` is the one exception: it fans out into one turn per
16
+ // entry (`qualityLenses`, in `./review.ts`) rather than naming one step's
17
+ // skill list, so it stays a var — pairing with `build.quality.reviewing`'s
18
+ // entry in `./skills.ts`, which replaces the lens on every one of those
19
+ // turns.
22
20
  qualityReviews: "owasp-security, ponytail-review, test-audit",
23
21
  }