@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "17.2.0",
3
+ "version": "18.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -28,8 +28,9 @@ Git history; keep secrets in an environment setting (see
28
28
  Also install all three skill sources the bundled workflow names in its prompts —
29
29
  none is optional. See
30
30
  [Setup](https://github.com/pmelab/gtd/blob/main/docs/setup.md) for the list, the
31
- install flags, and the never-install-the-plugin caveat. Each bundled agent step
32
- has its own addressable `.gtdrc` `skills:` entry — see
31
+ install flags, and the never-install-the-plugin caveat. Skills are declared per
32
+ scope, and restricted per turn where the driver can; each bundled scope has its
33
+ own addressable `.gtdrc` `skills:` entry — see
33
34
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#the-skills-key)
34
35
  to repoint one to a set your own harness has instead.
35
36
 
@@ -468,7 +469,8 @@ thread: a footnote holding a `H:`/`A:` conversation. Threads show as a
468
469
  conversation under their block, question, chunk, or hunk, and one the agent
469
470
  answered last carries a "waiting on you" badge. Tap it to reply — your reply is
470
471
  added as a new `H:` entry; while the agent has yet to answer, reopening it edits
471
- your last entry instead. See
472
+ your last entry instead. Tap an already-answered question to leave a note on it;
473
+ the agent may overturn the answer. See
472
474
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
473
475
  for its `ui:` settings.
474
476
 
@@ -13,6 +13,7 @@ export type Beat = {
13
13
  memory?: string
14
14
  model?: string
15
15
  system?: string
16
+ skills?: string[]
16
17
  validate?: string
17
18
  judge?: unknown
18
19
  changes?: { status: string; path: string }[]
@@ -33,6 +34,8 @@ export type Turn = {
33
34
  label?: string
34
35
  model?: string
35
36
  system?: string
37
+ skills: readonly string[]
38
+ scope: string
36
39
  }
37
40
 
38
41
  export type TurnEnd = { ok: true } | { ok: false; why: string }
@@ -131,6 +134,8 @@ async function agent(io: Io, b: Beat, landTurn: string | undefined): Promise<Act
131
134
  label: b.label,
132
135
  model: b.model || undefined,
133
136
  system: b.system || undefined,
137
+ skills: b.skills ?? [],
138
+ scope: memory.split("#")[0]!,
134
139
  })
135
140
  if (!t.ok) return failed(b, t.why)
136
141
  return b.validate ? validated(io, b, b.validate, memory) : {}
@@ -0,0 +1,28 @@
1
+ import type { Turn } from "./drive"
2
+
3
+ const NEUTRAL = "You are a coding agent working on the task you are given."
4
+
5
+ // `general-purpose` cannot carry a restriction, so every prompt turn gets a
6
+ // registered persona. The name hashes everything the spec depends on: a
7
+ // `.gtdrc` change or gtd upgrade mid-session must register a fresh one.
8
+ export async function personaSpec(t: Pick<Turn, "scope" | "system" | "skills">) {
9
+ const key = JSON.stringify([t.scope, t.system ?? "", t.skills])
10
+ const digest = new Uint8Array(
11
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)),
12
+ )
13
+ const name =
14
+ "p-" + Array.from(digest.slice(0, 6), (b) => b.toString(16).padStart(2, "0")).join("")
15
+ return {
16
+ name,
17
+ prompt: t.system || NEUTRAL,
18
+ skills: [...t.skills],
19
+ disallowedTools: ["Skill"],
20
+ permissionMode: "bypassPermissions",
21
+ }
22
+ }
23
+
24
+ export function resumable(entry: unknown, persona: string): string | undefined {
25
+ if (typeof entry !== "object" || entry === null) return undefined
26
+ const e = entry as { agentId?: unknown; persona?: unknown }
27
+ return typeof e.agentId === "string" && e.persona === persona ? e.agentId : undefined
28
+ }
@@ -3,9 +3,10 @@ import type { EngineInterface, Register } from "claude-code"
3
3
 
4
4
  import type { Run, Stop } from "../types"
5
5
  import { afterReload, drive, isTrue } from "./drive"
6
- import type { Beat, Io, Landing, TurnEnd } from "./drive"
6
+ import type { Beat, Io, Landing, Turn, TurnEnd } from "./drive"
7
7
  import { enter } from "./entry"
8
8
  import { subagentModel } from "./models"
9
+ import { personaSpec, resumable } from "./persona"
9
10
  import { JUDGE_SYSTEM, judgePrompt, toVerdicts } from "./judge"
10
11
  import type { Judgment } from "./judge"
11
12
  import { catchFrom, throwTo } from "./handoff"
@@ -25,6 +26,7 @@ import {
25
26
  pushText,
26
27
  runningLine,
27
28
  question,
29
+ restartLine,
28
30
  RELOAD_CUT_SCRIPT,
29
31
  SAFE,
30
32
  SHIP,
@@ -37,7 +39,10 @@ type $ = EngineInterface
37
39
 
38
40
  const run = atom({ plugin: "gtd", key: "run" } as const, { isRunning: false, beat: 0 } as Run)
39
41
  // memory scope (`<scope>#<hash7>`) -> the subagent holding that conversation
40
- const scopes = atom({ plugin: "gtd", key: "scopes" } as const, {} as Record<string, string>)
42
+ const scopes = atom(
43
+ { plugin: "gtd", key: "scopes" } as const,
44
+ {} as Record<string, { agentId: string; persona: string }>,
45
+ )
41
46
  const agents = atom({ plugin: "gtd", key: "agents" } as const, [] as string[])
42
47
 
43
48
  const TEN_MINUTES = 600_000
@@ -82,23 +87,19 @@ async function gtd($: $, args: string[], stdin?: string) {
82
87
  return r.stdout
83
88
  }
84
89
 
85
- // One agent type per system prompt. A registered type's prompt replaces the
86
- // session's system prompt, as `--system-prompt` does for the sh driver.
87
- async function persona($: $, system: string) {
88
- const bytes = new TextEncoder().encode(system)
89
- const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
90
- const name =
91
- "p-" + Array.from(digest.slice(0, 6), (b) => b.toString(16).padStart(2, "0")).join("")
92
- if (!personas.has(name)) {
90
+ // One agent type per scope, system prompt and skill list. A registered type's
91
+ // prompt replaces the session's system prompt, as `--system-prompt` does for
92
+ // the sh driver.
93
+ async function persona($: $, t: Pick<Turn, "scope" | "system" | "skills">) {
94
+ const spec = await personaSpec(t)
95
+ if (!personas.has(spec.name)) {
93
96
  await $.agent.register({
94
- name,
97
+ ...spec,
95
98
  description: "gtd workflow persona, spawned by the gtd driver only",
96
- prompt: system,
97
- permissionMode: "bypassPermissions",
98
99
  })
99
- personas.add(name)
100
+ personas.add(spec.name)
100
101
  }
101
- return `gtd:${name}`
102
+ return `gtd:${spec.name}`
102
103
  }
103
104
 
104
105
  async function send($: $, agentId: string, text: string) {
@@ -162,7 +163,10 @@ function io($: $): Io {
162
163
  }
163
164
  },
164
165
  turn: async (t) => {
165
- const known = (await read($, scopes))[t.memory]
166
+ const entry = (await read($, scopes))[t.memory]
167
+ const name = (await personaSpec(t)).name
168
+ const known = resumable(entry, name)
169
+ if (t.resume && entry && !known) $.ui.log(restartLine(t.scope))
166
170
  if (t.resume && known) {
167
171
  const end = send($, known, t.prompt)
168
172
  await update($, run, (r) => ({ ...r, inflight: { agentId: known, memory: t.memory } }))
@@ -172,7 +176,7 @@ function io($: $): Io {
172
176
  // No live conversation for this scope (fresh scope, or this session never
173
177
  // held it): start one, as the sh driver falls back to `--session-id`.
174
178
  const spawned = await $.agent.spawn({
175
- subagentType: t.system ? await persona($, t.system) : "general-purpose",
179
+ subagentType: await persona($, t),
176
180
  prompt: t.prompt,
177
181
  description: t.label || "gtd",
178
182
  model: subagentModel(t.model, {
@@ -185,12 +189,12 @@ function io($: $): Io {
185
189
  if (!id) return { ok: false, why: spawned.deny ?? "subagent spawn refused" }
186
190
  ours.add(id)
187
191
  await update($, agents, (list) => [...list, id].slice(-200))
188
- await update($, scopes, (s) => ({ ...s, [t.memory]: id }))
192
+ await update($, scopes, (s) => ({ ...s, [t.memory]: { agentId: id, persona: name } }))
189
193
  await update($, run, (r) => ({ ...r, inflight: { agentId: id, memory: t.memory } }))
190
194
  return settle($, await turnEnd(id))
191
195
  },
192
196
  resume: async (memory, text) => {
193
- const id = (await read($, scopes))[memory]
197
+ const id = (await read($, scopes))[memory]?.agentId
194
198
  const end = id ? await send($, id, text) : undefined
195
199
  return end ?? { ok: false, why: `no live subagent for ${memory}` }
196
200
  },
@@ -77,6 +77,9 @@ export function runningLine(label: string, beat: number, elapsedMs: number) {
77
77
  // One line per beat, for the status line and the transcript.
78
78
  export const beatLine = (beat: number, b: Beat) => `▸ ${step(b)} · step ${beat}`
79
79
 
80
+ export const restartLine = (scope: string) =>
81
+ `${scope}: skills or system changed — started a fresh subagent`
82
+
80
83
  // What a push to a phone says when gtd stops: the step, and where to act on it.
81
84
  export function pushText(stop: Stop, repo: string, url?: string) {
82
85
  const at = `${repo}: ${headline(stop).replace(/^\S+ /, "")}`
@@ -28,6 +28,10 @@ export type Run = {
28
28
 
29
29
  declare module "claude-code" {
30
30
  interface PluginState {
31
- gtd: { run: Run; scopes: Record<string, string>; agents: string[] }
31
+ gtd: {
32
+ run: Run
33
+ scopes: Record<string, { agentId: string; persona: string }>
34
+ agents: string[]
35
+ }
32
36
  }
33
37
  }