@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -2
- package/claude/hooks/access.ts +58 -0
- package/claude/hooks/drive.ts +47 -4
- package/claude/hooks/persona.ts +28 -0
- package/claude/hooks/register.tsx +35 -19
- package/claude/hooks/wording.ts +3 -0
- package/claude/types/index.d.ts +5 -1
- package/dist/gtd.bundle.mjs +667 -449
- package/package.json +1 -1
- package/schema.json +23 -1
- package/skills/authoring/SKILL.md +6 -4
- package/src/flows/runtime.ts +26 -14
- package/src/workflows/access.test.ts +41 -0
- package/src/workflows/access.ts +23 -0
- package/src/workflows/prose.ts +17 -0
- package/src/workflows/review.test.ts +13 -13
- package/src/workflows/review.ts +4 -4
- package/src/workflows/skills.test.ts +67 -81
- package/src/workflows/skills.ts +33 -37
- package/src/workflows/steps.test.ts +56 -78
- package/src/workflows/steps.ts +10 -19
- package/src/workflows/text.fixture.ts +40 -29
- package/src/workflows/text.test.ts +43 -10
- package/src/workflows/text.ts +9 -14
- package/src/workflows/unified.ts +1 -0
- package/src/workflows/vars.ts +2 -4
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.
|
|
32
|
-
|
|
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
|
+
}
|
package/claude/hooks/drive.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
152
|
-
|
|
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(
|
|
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
|
|
86
|
-
// session's system prompt, as `--system-prompt` does for
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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) {
|
package/claude/hooks/wording.ts
CHANGED
|
@@ -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+ /, "")}`
|
package/claude/types/index.d.ts
CHANGED
|
@@ -28,6 +28,10 @@ export type Run = {
|
|
|
28
28
|
|
|
29
29
|
declare module "claude-code" {
|
|
30
30
|
interface PluginState {
|
|
31
|
-
gtd: {
|
|
31
|
+
gtd: {
|
|
32
|
+
run: Run
|
|
33
|
+
scopes: Record<string, { agentId: string; persona: string }>
|
|
34
|
+
agents: string[]
|
|
35
|
+
}
|
|
32
36
|
}
|
|
33
37
|
}
|