@pmelab/gtd 20.0.1 → 20.2.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": "20.0.1",
3
+ "version": "20.2.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
@@ -19,6 +19,10 @@ to any machine that checks it out.
19
19
  npm install -g @pmelab/gtd
20
20
  ```
21
21
 
22
+ That installs `latest`, which moves once a day to the newest release. Every
23
+ merge to `main` publishes to `next` first: `npm install -g @pmelab/gtd@next`
24
+ gets each one as it lands.
25
+
22
26
  Or run without installing (prefix every `gtd` below with `npx`) — see
23
27
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
24
28
  for the settings most projects tune. A process setting's value is committed to
@@ -63,7 +67,10 @@ for you. Ask your agent if you have any questions.
63
67
  back next week: run the driver and it resumes exactly where it stopped.
64
68
  - **Shareable.** Push the branch, and the workflow travels with it — the state
65
69
  lives in the commits, so another machine (or another person) picks up exactly
66
- where you left off.
70
+ where you left off. Each commit records the history format it was written in
71
+ (`Gtd-Format:`), so a gtd that cannot read a history says so instead of
72
+ misreading it — see
73
+ [History format](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#history-format).
67
74
  - **Files, not chat.** Conversation lives in files like `REQUIREMENTS.md` or
68
75
  `ARCHITECTURE.md`. Request changes by editing them, approve by leaving the
69
76
  tree clean — all in your own editor. There is no chat UI that could flicker.
@@ -5,13 +5,14 @@ import type { Run, Stop } from "../types"
5
5
  import { accessDenial } from "./access"
6
6
  import type { AccessDef } from "./access"
7
7
  import { afterReload, drive, isTrue } from "./drive"
8
- import type { Beat, Io, Landing, Turn, TurnEnd } from "./drive"
8
+ import type { Io, Turn, TurnEnd } from "./drive"
9
9
  import { doors, enter } from "./doors"
10
10
  import { subagentModel } from "./models"
11
11
  import { personaSpec, resumable } from "./persona"
12
12
  import { JUDGE_SYSTEM, judgePrompt, toVerdicts } from "./judge"
13
13
  import type { Judgment } from "./judge"
14
14
  import { catchFrom, throwTo } from "./handoff"
15
+ import { documents } from "./schema"
15
16
  import { READ_ONLY_GIT, ship } from "./ship"
16
17
  import type { ShipIo } from "./ship"
17
18
  import {
@@ -124,16 +125,13 @@ async function settle($: $, end: TurnEnd) {
124
125
  return end
125
126
  }
126
127
 
128
+ const docs = ($: $) => documents((args, stdin) => gtd($, args, stdin))
129
+
127
130
  function io($: $): Io {
128
131
  return {
129
- next: async () => JSON.parse(await gtd($, ["next", "--json"])) as Beat,
132
+ next: docs($).next,
130
133
  plain: () => gtd($, ["next"]),
131
- land: async (verdict) =>
132
- JSON.parse(
133
- verdict === undefined
134
- ? await gtd($, ["land", "--json"])
135
- : await gtd($, ["judge", "answer", "--json"], verdict),
136
- ) as Landing,
134
+ land: docs($).land,
137
135
  sh: async (script, log) => {
138
136
  // Streamed, not `$.process.run`: a check beat can outlast run's 10-minute cap.
139
137
  const argv = log
@@ -153,22 +151,22 @@ function io($: $): Io {
153
151
  for await (const chunk of child) out += chunk.text
154
152
  return { ok: (await child.result).code === 0, out: out.trim() }
155
153
  },
156
- judge: async () => {
157
- const key = (await $.env.get("TYPESAFE_API_KEY")) ?? (await $.env.get("TYPESAFE_AI_KEY"))
158
- if (!key) return judgeInSession($)
159
- try {
160
- const doc = await gtd($, ["judge", "--json"])
161
- const r = await $.process.run(["gtd", "judge", "run", "--provider", "jev"], {
162
- cwd: root,
163
- stdin: doc,
164
- env: { TYPESAFE_API_KEY: key },
165
- timeoutMs: TEN_MINUTES,
166
- })
167
- return r.exitCode === 0 && r.stdout.trim() ? r.stdout : undefined
168
- } catch {
169
- return undefined
170
- }
171
- },
154
+ judge: () =>
155
+ docs($).judge(async (doc, j) => {
156
+ const key = (await $.env.get("TYPESAFE_API_KEY")) ?? (await $.env.get("TYPESAFE_AI_KEY"))
157
+ if (!key) return judgeInSession($, j)
158
+ try {
159
+ const r = await $.process.run(["gtd", "judge", "run", "--provider", "jev"], {
160
+ cwd: root,
161
+ stdin: doc,
162
+ env: { TYPESAFE_API_KEY: key },
163
+ timeoutMs: TEN_MINUTES,
164
+ })
165
+ return r.exitCode === 0 && r.stdout.trim() ? r.stdout : undefined
166
+ } catch {
167
+ return undefined
168
+ }
169
+ }),
172
170
  turn: async (t) => {
173
171
  const entry = (await read($, scopes))[t.memory]
174
172
  const name = (await personaSpec(t)).name
@@ -256,7 +254,7 @@ async function start($: $, landTurn?: string) {
256
254
  // idle rest names; the opening beat captures them like a hand-edit.
257
255
  async function begin($: $, requirements: string) {
258
256
  await findRoot($)
259
- const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
257
+ const b = await docs($).next()
260
258
  if (isTrue(b.initial) && !isTrue(b.idle)) {
261
259
  const paths = (b.changes ?? []).map((c) => c.path).join(", ")
262
260
  return `The working tree has uncommitted changes (${paths}); gtd would start from those. Commit, stash or revert them, or run /gtd to start from them.`
@@ -375,7 +373,7 @@ async function throwNow($: $, target: string | undefined) {
375
373
  if ((await read($, run)).isRunning) return $.ui.log("Stop the loop first: /gtd stop.")
376
374
  closeUi()
377
375
  moveOn($)
378
- const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
376
+ const b = await docs($).next()
379
377
  $.ui.status("handing off…")
380
378
  const rest = {
381
379
  state: b.state,
@@ -415,7 +413,7 @@ async function catchNow($: $, ref: string) {
415
413
  const caught = await catchFrom(shipIo($), ref)
416
414
  if (!caught.ok) return caught.text
417
415
  await findRoot($)
418
- const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
416
+ const b = await docs($).next()
419
417
  if (b.kind === "message") {
420
418
  const stop: Stop = { kind: "gate", text: b.content ?? "", state: b.state, label: b.label }
421
419
  if (b.judge) stop.isJudge = true
@@ -516,9 +514,8 @@ async function writer($: $, prompt: string) {
516
514
  // Without a TypeSafe key a judge gate is put to a small model, as gtd's own
517
515
  // llm provider does, but as a subagent of this session. Any doubt leaves the
518
516
  // gate to the person, which routes the workflow its cautious way.
519
- async function judgeInSession($: $) {
517
+ async function judgeInSession($: $, j: Judgment) {
520
518
  try {
521
- const j = JSON.parse(await gtd($, ["judge", "--json"])) as Judgment
522
519
  const model = (await $.env.get("GTD_JUDGE_MODEL")) ?? "haiku"
523
520
  const reply = await delegate(
524
521
  $,
@@ -675,7 +672,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
675
672
  }
676
673
 
677
674
  async function resume($: $) {
678
- const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
675
+ const b = await docs($).next()
679
676
  if (isTrue(b.idle)) {
680
677
  return `Nothing is in progress. Start a process with /gtd <requirements>, or sketch the change in ${b.file ?? ".gtd/TODO.md"} and run /gtd.`
681
678
  }
@@ -0,0 +1,52 @@
1
+ import { WIRE_SCHEMA } from "../../src/wire/constants.js"
2
+ import type { Beat, Landing } from "./drive"
3
+ import type { Judgment } from "./judge"
4
+
5
+ // The mod can meet a gtd installed apart from it (a checkout without a build
6
+ // falls back to the gtd on PATH), so each document's `schema`, never the
7
+ // package version, decides whether the mod can read it.
8
+ export function readDocument<T>(command: string, text: string): T {
9
+ return checked(command, JSON.parse(text))
10
+ }
11
+
12
+ function checked<T>(command: string, parsed: unknown): T {
13
+ const found = (parsed as { schema?: unknown } | null)?.schema
14
+ if (found !== WIRE_SCHEMA) throw new Error(unsupportedSchema(command, found))
15
+ return parsed as T
16
+ }
17
+
18
+ function unsupportedSchema(command: string, found: unknown) {
19
+ const printed = found === undefined ? "no schema" : `schema ${JSON.stringify(found)}`
20
+ const upgrade =
21
+ typeof found === "number" && found > WIRE_SCHEMA
22
+ ? "upgrade the gtd plugin to read it"
23
+ : "upgrade gtd to the plugin's version"
24
+ return `\`gtd ${command}\` printed ${printed}, but this gtd mod reads only schema ${WIRE_SCHEMA}: ${upgrade}.`
25
+ }
26
+
27
+ type Gtd = (args: string[], stdin?: string) => Promise<string>
28
+
29
+ // Every gtd document the mod reads goes through here, so none skips the check.
30
+ export function documents(gtd: Gtd) {
31
+ return {
32
+ next: async () => readDocument<Beat>("next --json", await gtd(["next", "--json"])),
33
+ land: async (verdict?: string) => {
34
+ const args = verdict === undefined ? ["land", "--json"] : ["judge", "answer", "--json"]
35
+ return readDocument<Landing>(args.join(" "), await gtd(args, verdict))
36
+ },
37
+ // A failed or unparseable read leaves the judge step for a person to answer.
38
+ // Only a schema the mod does not read throws, stopping the run before a
39
+ // paid provider is asked.
40
+ judge: async (answer: (doc: string, j: Judgment) => Promise<string | undefined>) => {
41
+ const doc = await gtd(["judge", "--json"]).catch(() => undefined)
42
+ if (doc === undefined) return undefined
43
+ let parsed: unknown
44
+ try {
45
+ parsed = JSON.parse(doc)
46
+ } catch {
47
+ return undefined
48
+ }
49
+ return answer(doc, checked<Judgment>("judge --json", parsed))
50
+ },
51
+ }
52
+ }