@pmelab/gtd 17.3.0 → 18.1.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.3.0",
3
+ "version": "18.1.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,11 +28,18 @@ 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
 
37
+ Each scope also declares which files its agent turns may read and write;
38
+ `gtd land` refuses a turn that wrote outside its scope, and a driver can pass
39
+ the read side to its agent. `read` is not a security boundary unless your driver
40
+ enforces it at the OS level. See
41
+ [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#file-access).
42
+
36
43
  > **A repository's `gtd.config.ts` is code, and gtd runs it.** A custom workflow
37
44
  > is a TypeScript module, and every gtd command that looks at workflow state —
38
45
  > `gtd next` and `gtd lsp` included, not just `gtd land` — evaluates it. Treat
@@ -0,0 +1,58 @@
1
+ import { isAbsolute, relative, resolve } from "node:path"
2
+
3
+ import { globMatches } from "../../src/replay/Glob.js"
4
+
5
+ export type AccessDef = { read: string[] | null; write: string[] | null }
6
+
7
+ const READ = new Set(["Read"])
8
+ const WRITE: Record<string, string> = {
9
+ Edit: "file_path",
10
+ MultiEdit: "file_path",
11
+ Write: "file_path",
12
+ NotebookEdit: "notebook_path",
13
+ }
14
+
15
+ // Repo-relative form of a path; undefined when it lies outside the repo.
16
+ const inRepo = (p: string, root: string): string | undefined => {
17
+ const rel = relative(root, isAbsolute(p) ? p : resolve(root, p))
18
+ return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel
19
+ }
20
+
21
+ const deny = (verb: string, path: string, globs: string[]) =>
22
+ `gtd access: ${verb} of ${path} is outside this step's ${verb} access (${globs.join(", ") || "none"})`
23
+
24
+ // Bash is deliberately not inspected: access is best effort at the tool level,
25
+ // and `gtd land` is the backstop for writes.
26
+ export function accessDenial(
27
+ tool: string,
28
+ input: Record<string, unknown>,
29
+ access: AccessDef,
30
+ root: string,
31
+ ): string | undefined {
32
+ if (READ.has(tool)) return check("read", input.file_path, access.read, root)
33
+ const key = WRITE[tool]
34
+ if (key) return check("write", input[key], access.write, root)
35
+ if (tool === "Glob" || tool === "Grep") return searchDenial(input.path, access.read, root)
36
+ return undefined
37
+ }
38
+
39
+ function check(verb: string, raw: unknown, globs: string[] | null, root: string) {
40
+ if (globs === null || typeof raw !== "string") return undefined
41
+ const rel = inRepo(raw, root)
42
+ if (rel === undefined || globs.some((g) => globMatches(rel, g))) return undefined
43
+ return deny(verb, rel, globs)
44
+ }
45
+
46
+ // A search is allowed only from a root that sits inside the literal directory
47
+ // prefix of some read glob, so it cannot enumerate files outside the access.
48
+ function searchDenial(raw: unknown, globs: string[] | null, root: string) {
49
+ if (globs === null) return undefined
50
+ const rel = typeof raw === "string" && raw ? inRepo(raw, root) : ""
51
+ if (rel === undefined) return undefined
52
+ const inside = globs.some((g) => {
53
+ const prefix = g.split("*")[0]!
54
+ const dir = prefix.slice(0, prefix.lastIndexOf("/") + 1).replace(/\/$/, "")
55
+ return dir === "" || rel === dir || rel.startsWith(`${dir}/`)
56
+ })
57
+ return inside ? undefined : deny("read", rel || ".", globs)
58
+ }
@@ -1,4 +1,6 @@
1
+ import { ACCESS_REFUSAL } from "../../src/wire/constants.js"
1
2
  import type { Stop } from "../types"
3
+ import type { AccessDef } from "./access"
2
4
 
3
5
  // The subset of `gtd next --json` this driver reads. Absent optional fields
4
6
  // are simply missing; booleans may arrive as strings.
@@ -13,7 +15,9 @@ export type Beat = {
13
15
  memory?: string
14
16
  model?: string
15
17
  system?: string
18
+ skills?: string[]
16
19
  validate?: string
20
+ access?: AccessDef
17
21
  judge?: unknown
18
22
  changes?: { status: string; path: string }[]
19
23
  session?: { id?: string; resume?: boolean | string }
@@ -33,6 +37,9 @@ export type Turn = {
33
37
  label?: string
34
38
  model?: string
35
39
  system?: string
40
+ skills: readonly string[]
41
+ scope: string
42
+ access?: AccessDef
36
43
  }
37
44
 
38
45
  export type TurnEnd = { ok: true } | { ok: false; why: string }
@@ -80,7 +87,7 @@ export async function drive(io: Io, landTurn?: string): Promise<Stop> {
80
87
  const acted = await act(io, b, beat, judged, beat === 1 ? landTurn : undefined)
81
88
  if (acted.stop) return acted.stop
82
89
  judged = acted.verdict ? b.state : undefined
83
- const landed = await land(io, b, acted.verdict)
90
+ const landed = await land(io, b, acted.verdict, true)
84
91
  if (landed) return landed
85
92
  }
86
93
  }
@@ -120,7 +127,7 @@ async function message(io: Io, b: Beat, beat: number, judged: string | undefined
120
127
  }
121
128
 
122
129
  async function agent(io: Io, b: Beat, landTurn: string | undefined): Promise<Acted> {
123
- const memory = b.memory ?? b.session?.id ?? b.state ?? "root"
130
+ const memory = memoryOf(b)
124
131
  const t =
125
132
  landTurn === memory
126
133
  ? ({ ok: true } as const)
@@ -131,6 +138,9 @@ async function agent(io: Io, b: Beat, landTurn: string | undefined): Promise<Act
131
138
  label: b.label,
132
139
  model: b.model || undefined,
133
140
  system: b.system || undefined,
141
+ skills: b.skills ?? [],
142
+ scope: memory.split("#")[0]!,
143
+ access: b.access,
134
144
  })
135
145
  if (!t.ok) return failed(b, t.why)
136
146
  return b.validate ? validated(io, b, b.validate, memory) : {}
@@ -148,8 +158,25 @@ async function validated(io: Io, b: Beat, validate: string, memory: string): Pro
148
158
  }
149
159
  }
150
160
 
151
- async function land(io: Io, b: Beat, verdict: string | undefined): Promise<Stop | undefined> {
152
- const l = await io.land(verdict)
161
+ const memoryOf = (b: Beat) => b.memory ?? b.session?.id ?? b.state ?? "root"
162
+
163
+ // A prompt beat whose turn wrote outside its access gets one recovery turn
164
+ // with the refusal text; a second refusal is an error, not another loop.
165
+ async function land(
166
+ io: Io,
167
+ b: Beat,
168
+ verdict: string | undefined,
169
+ mayRecover: boolean,
170
+ ): Promise<Stop | undefined> {
171
+ let l: Landing
172
+ try {
173
+ l = await io.land(verdict)
174
+ } catch (e) {
175
+ const text = e instanceof Error ? e.message : String(e)
176
+ if (b.kind !== "prompt" || !text.includes(ACCESS_REFUSAL)) throw e
177
+ if (!mayRecover) return { kind: "error", text, ...where(b) }
178
+ return recover(io, b, verdict, text)
179
+ }
153
180
  const code = await io.sh(l.script, b.log)
154
181
  if (code !== 0) return { kind: "error", text: `landing script exited ${code}`, ...where(b) }
155
182
  if (!isTrue(l.settled)) return undefined
@@ -157,6 +184,22 @@ async function land(io: Io, b: Beat, verdict: string | undefined): Promise<Stop
157
184
  return { kind: "done", text, ...where(b) }
158
185
  }
159
186
 
187
+ async function recover(
188
+ io: Io,
189
+ b: Beat,
190
+ verdict: string | undefined,
191
+ text: string,
192
+ ): Promise<Stop | undefined> {
193
+ const memory = memoryOf(b)
194
+ const r = await io.resume(memory, text)
195
+ if (!r.ok) return { kind: "error", text: r.why, ...where(b) }
196
+ if (b.validate) {
197
+ const v = await validated(io, b, b.validate, memory)
198
+ if (v.stop) return v.stop
199
+ }
200
+ return land(io, b, verdict, false)
201
+ }
202
+
160
203
  // What a reload that cut the loop off does next. Only a turn the agent
161
204
  // finished lands; any other end runs it again. A script cut off may have run
162
205
  // partly, and running it again is not safe to assume, so a person decides.
@@ -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
+ }
@@ -2,10 +2,13 @@ import { atom, read, update } from "claude-code"
2
2
  import type { EngineInterface, Register } from "claude-code"
3
3
 
4
4
  import type { Run, Stop } from "../types"
5
+ import { accessDenial } from "./access"
6
+ import type { AccessDef } from "./access"
5
7
  import { afterReload, drive, isTrue } from "./drive"
6
- import type { Beat, Io, Landing, TurnEnd } from "./drive"
8
+ import type { Beat, Io, Landing, Turn, TurnEnd } from "./drive"
7
9
  import { enter } from "./entry"
8
10
  import { subagentModel } from "./models"
11
+ import { personaSpec, resumable } from "./persona"
9
12
  import { JUDGE_SYSTEM, judgePrompt, toVerdicts } from "./judge"
10
13
  import type { Judgment } from "./judge"
11
14
  import { catchFrom, throwTo } from "./handoff"
@@ -25,6 +28,7 @@ import {
25
28
  pushText,
26
29
  runningLine,
27
30
  question,
31
+ restartLine,
28
32
  RELOAD_CUT_SCRIPT,
29
33
  SAFE,
30
34
  SHIP,
@@ -37,7 +41,10 @@ type $ = EngineInterface
37
41
 
38
42
  const run = atom({ plugin: "gtd", key: "run" } as const, { isRunning: false, beat: 0 } as Run)
39
43
  // memory scope (`<scope>#<hash7>`) -> the subagent holding that conversation
40
- const scopes = atom({ plugin: "gtd", key: "scopes" } as const, {} as Record<string, string>)
44
+ const scopes = atom(
45
+ { plugin: "gtd", key: "scopes" } as const,
46
+ {} as Record<string, { agentId: string; persona: string }>,
47
+ )
41
48
  const agents = atom({ plugin: "gtd", key: "agents" } as const, [] as string[])
42
49
 
43
50
  const TEN_MINUTES = 600_000
@@ -61,6 +68,11 @@ let isShipping = false
61
68
  // subagents answering into a file: what each may touch (see tool.call)
62
69
  const delegates = new Map<string, { file: string; mayRun: RegExp | undefined }>()
63
70
  const ours = new Set<string>()
71
+ // What each turn agent may read and write; reset on every spawn and resume,
72
+ // since the steering file can differ per step.
73
+ const accesses = new Map<string, AccessDef>()
74
+ const setAccess = (id: string, a: AccessDef | undefined) =>
75
+ void (a ? accesses.set(id, a) : accesses.delete(id))
64
76
  const waiting = new Map<string, (end: TurnEnd) => void>()
65
77
  const ended = new Map<string, TurnEnd>()
66
78
  const personas = new Set<string>()
@@ -82,23 +94,19 @@ async function gtd($: $, args: string[], stdin?: string) {
82
94
  return r.stdout
83
95
  }
84
96
 
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)) {
97
+ // One agent type per scope, system prompt and skill list. A registered type's
98
+ // prompt replaces the session's system prompt, as `--system-prompt` does for
99
+ // the sh driver.
100
+ async function persona($: $, t: Pick<Turn, "scope" | "system" | "skills">) {
101
+ const spec = await personaSpec(t)
102
+ if (!personas.has(spec.name)) {
93
103
  await $.agent.register({
94
- name,
104
+ ...spec,
95
105
  description: "gtd workflow persona, spawned by the gtd driver only",
96
- prompt: system,
97
- permissionMode: "bypassPermissions",
98
106
  })
99
- personas.add(name)
107
+ personas.add(spec.name)
100
108
  }
101
- return `gtd:${name}`
109
+ return `gtd:${spec.name}`
102
110
  }
103
111
 
104
112
  async function send($: $, agentId: string, text: string) {
@@ -162,8 +170,12 @@ function io($: $): Io {
162
170
  }
163
171
  },
164
172
  turn: async (t) => {
165
- const known = (await read($, scopes))[t.memory]
173
+ const entry = (await read($, scopes))[t.memory]
174
+ const name = (await personaSpec(t)).name
175
+ const known = resumable(entry, name)
176
+ if (t.resume && entry && !known) $.ui.log(restartLine(t.scope))
166
177
  if (t.resume && known) {
178
+ setAccess(known, t.access)
167
179
  const end = send($, known, t.prompt)
168
180
  await update($, run, (r) => ({ ...r, inflight: { agentId: known, memory: t.memory } }))
169
181
  const ended = await end
@@ -172,7 +184,7 @@ function io($: $): Io {
172
184
  // No live conversation for this scope (fresh scope, or this session never
173
185
  // held it): start one, as the sh driver falls back to `--session-id`.
174
186
  const spawned = await $.agent.spawn({
175
- subagentType: t.system ? await persona($, t.system) : "general-purpose",
187
+ subagentType: await persona($, t),
176
188
  prompt: t.prompt,
177
189
  description: t.label || "gtd",
178
190
  model: subagentModel(t.model, {
@@ -184,13 +196,14 @@ function io($: $): Io {
184
196
  const id = spawned.agentId
185
197
  if (!id) return { ok: false, why: spawned.deny ?? "subagent spawn refused" }
186
198
  ours.add(id)
199
+ setAccess(id, t.access)
187
200
  await update($, agents, (list) => [...list, id].slice(-200))
188
- await update($, scopes, (s) => ({ ...s, [t.memory]: id }))
201
+ await update($, scopes, (s) => ({ ...s, [t.memory]: { agentId: id, persona: name } }))
189
202
  await update($, run, (r) => ({ ...r, inflight: { agentId: id, memory: t.memory } }))
190
203
  return settle($, await turnEnd(id))
191
204
  },
192
205
  resume: async (memory, text) => {
193
- const id = (await read($, scopes))[memory]
206
+ const id = (await read($, scopes))[memory]?.agentId
194
207
  const end = id ? await send($, id, text) : undefined
195
208
  return end ?? { ok: false, why: `no live subagent for ${memory}` }
196
209
  },
@@ -740,6 +753,9 @@ export const register: Register = (on) => {
740
753
  // would still be writing the tree gtd is committing.
741
754
  on("tool.call", ($, e, next) => {
742
755
  if (!e.agentId || !ours.has(e.agentId)) return next(e)
756
+ const access = accesses.get(e.agentId)
757
+ const denial = access && accessDenial(String(e.tool), e, access, root)
758
+ if (denial) return { deny: denial }
743
759
  const delegated = delegates.get(e.agentId)
744
760
  if (delegated) {
745
761
  if (e.tool === "Write" && e.file_path !== delegated.file) {
@@ -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
  }