@pmelab/gtd 64.3.0 → 66.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -25
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +86 -26
- package/claude/hooks/register.tsx +35 -82
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +965 -163
- package/dist/gtd.core.mjs +150 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +105 -7
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +380 -33
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +4 -0
- package/src/flows/index.ts +7 -0
- package/src/flows/runtime.ts +153 -15
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +92 -16
- package/src/replay/Trailers.ts +17 -0
- package/src/replay/Tree.ts +5 -0
- package/src/replay/index.ts +1 -1
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +15 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +22 -17
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +118 -1
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +140 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +26 -12
- package/src/wire/constants.ts +3 -0
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +3 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- package/src/workflow-kit/scenarios.ts +8 -0
- package/claude/hooks/ship.ts +0 -730
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -27,6 +27,11 @@ function makeHash(message: string, parent: string | null, tree: Map<string, stri
|
|
|
27
27
|
return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** An executable file's content carries this prefix inside the twin, so every tree copy keeps the mode for free; only the read edges strip it. */
|
|
31
|
+
const EXEC = "\u0000exec\u0000"
|
|
32
|
+
const plain = (content: string): string =>
|
|
33
|
+
content.startsWith(EXEC) ? content.slice(EXEC.length) : content
|
|
34
|
+
|
|
30
35
|
export class InMemRepo {
|
|
31
36
|
// Data-only marker read by no code path: embeds the sentinel in every
|
|
32
37
|
// instance so a leaked object still carries it.
|
|
@@ -180,6 +185,89 @@ export class InMemRepo {
|
|
|
180
185
|
this.index = new Map(tree)
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
/** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
|
|
189
|
+
switchBranchTo(name: string, start?: string): void {
|
|
190
|
+
const hash = start === undefined ? this.head : this.resolveRef(start)
|
|
191
|
+
if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`)
|
|
192
|
+
this.moveToBranch(name, hash, true)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
|
|
196
|
+
switchTo(name: string): void {
|
|
197
|
+
const hash = this.branches.get(name)
|
|
198
|
+
if (hash === undefined) throw new Error(`invalid reference: ${name}`)
|
|
199
|
+
if (hash === this.head) return this.moveToBranch(name, hash, false)
|
|
200
|
+
const from = this.headTree()
|
|
201
|
+
const to = this.getCommit(hash)?.files ?? new Map<string, string>()
|
|
202
|
+
const pending = [
|
|
203
|
+
...new Set([...from.keys(), ...this.index.keys(), ...this.worktree.keys()]),
|
|
204
|
+
].filter(
|
|
205
|
+
(path) =>
|
|
206
|
+
this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path),
|
|
207
|
+
)
|
|
208
|
+
if (pending.some((path) => to.get(path) !== from.get(path))) {
|
|
209
|
+
throw new Error(`cannot switch to '${name}': local changes would be overwritten`)
|
|
210
|
+
}
|
|
211
|
+
const carry = (side: Map<string, string>) => {
|
|
212
|
+
const out = new Map(to)
|
|
213
|
+
for (const path of pending) {
|
|
214
|
+
const content = side.get(path)
|
|
215
|
+
if (content === undefined) out.delete(path)
|
|
216
|
+
else out.set(path, content)
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
this.worktree = carry(this.worktree)
|
|
221
|
+
this.index = carry(this.index)
|
|
222
|
+
this.head = hash
|
|
223
|
+
this.currentBranch = name
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private moveToBranch(name: string, hash: string, reset: boolean): void {
|
|
227
|
+
if (hash !== this.head) {
|
|
228
|
+
if (this.statusPorcelain().trim() !== "") {
|
|
229
|
+
throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`)
|
|
230
|
+
}
|
|
231
|
+
const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
|
|
232
|
+
this.head = hash
|
|
233
|
+
this.worktree = new Map(tree)
|
|
234
|
+
this.index = new Map(tree)
|
|
235
|
+
}
|
|
236
|
+
if (reset || !this.branches.has(name)) this.branches.set(name, hash)
|
|
237
|
+
this.currentBranch = name
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
deleteBranch(name: string): void {
|
|
241
|
+
if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`)
|
|
242
|
+
if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private remotes: Map<string, Map<string, string>> = new Map()
|
|
246
|
+
|
|
247
|
+
/** Test setup: a remote that accepts pushes. */
|
|
248
|
+
// fallow-ignore-next-line unused-class-member
|
|
249
|
+
addRemote(name: string): void {
|
|
250
|
+
this.remotes.set(name, new Map())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What a remote holds after pushes: ref → hash. */
|
|
254
|
+
// fallow-ignore-next-line unused-class-member
|
|
255
|
+
remoteRefs(name: string): ReadonlyMap<string, string> {
|
|
256
|
+
return this.remotes.get(name) ?? new Map()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
|
|
260
|
+
pushRef(remote: string, ref: string): string | undefined {
|
|
261
|
+
const there = this.remotes.get(remote)
|
|
262
|
+
if (there === undefined) {
|
|
263
|
+
return `fatal: '${remote}' does not appear to be a git repository`
|
|
264
|
+
}
|
|
265
|
+
const hash = this.resolveRef(ref)
|
|
266
|
+
if (hash === null) return `error: src refspec ${ref} does not match any`
|
|
267
|
+
there.set(ref, hash)
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
270
|
+
|
|
183
271
|
/** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
|
|
184
272
|
listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
|
|
185
273
|
return [...this.refs.entries()]
|
|
@@ -284,7 +372,8 @@ export class InMemRepo {
|
|
|
284
372
|
if (!hash) return null
|
|
285
373
|
const c = this.getCommit(hash)
|
|
286
374
|
if (!c) return null
|
|
287
|
-
|
|
375
|
+
const content = c.files.get(path)
|
|
376
|
+
return content === undefined ? null : plain(content)
|
|
288
377
|
}
|
|
289
378
|
|
|
290
379
|
/**
|
|
@@ -317,7 +406,20 @@ export class InMemRepo {
|
|
|
317
406
|
|
|
318
407
|
/** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
|
|
319
408
|
readFile(path: string): string | undefined {
|
|
320
|
-
|
|
409
|
+
const content = this.worktree.get(path)
|
|
410
|
+
return content === undefined ? undefined : plain(content)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `chmod +x` / `-x` on a working-tree file. */
|
|
414
|
+
setExecutable(path: string, on: boolean): void {
|
|
415
|
+
const content = this.readFile(path)
|
|
416
|
+
if (content !== undefined) this.worktree.set(path, on ? EXEC + content : content)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Every path's file mode at `ref`, or in the working tree when `ref` is `undefined`. */
|
|
420
|
+
modesAt(ref: string | undefined): Map<string, string> {
|
|
421
|
+
const tree = ref === undefined ? this.worktree : this.treeAt(ref)
|
|
422
|
+
return new Map([...tree].map(([path, c]) => [path, c.startsWith(EXEC) ? "100755" : "100644"]))
|
|
321
423
|
}
|
|
322
424
|
|
|
323
425
|
/** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
|
|
@@ -473,6 +575,39 @@ export class InMemRepo {
|
|
|
473
575
|
return undefined
|
|
474
576
|
}
|
|
475
577
|
|
|
578
|
+
/** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
|
|
579
|
+
shipCommit(
|
|
580
|
+
branch: string,
|
|
581
|
+
parent: string,
|
|
582
|
+
shipMessage: string,
|
|
583
|
+
historyRef: string,
|
|
584
|
+
message: string,
|
|
585
|
+
): string | undefined {
|
|
586
|
+
this.stageAll()
|
|
587
|
+
if (this.head === null) return "fatal: Needed a single revision"
|
|
588
|
+
if (this.signingFails) return "error: gpg failed to sign the data"
|
|
589
|
+
const kept = [...(this.commits.get(parent)?.files ?? [])]
|
|
590
|
+
const tree = new Map([
|
|
591
|
+
...[...this.index].filter(([path]) => !path.startsWith(".gtd/")),
|
|
592
|
+
...kept.filter(([path]) => path.startsWith(".gtd/")),
|
|
593
|
+
])
|
|
594
|
+
const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`
|
|
595
|
+
const hash = makeHash(shipped, parent, tree)
|
|
596
|
+
if (this.branches.get(branch) !== parent) {
|
|
597
|
+
return `fatal: update_ref failed for ref 'refs/heads/${branch}': cannot lock ref 'refs/heads/${branch}': is at ${this.branches.get(branch) ?? "nothing"} but expected ${parent}`
|
|
598
|
+
}
|
|
599
|
+
this.commits.set(hash, {
|
|
600
|
+
hash,
|
|
601
|
+
message: shipped,
|
|
602
|
+
files: tree,
|
|
603
|
+
parent,
|
|
604
|
+
seq: this.nextSeq++,
|
|
605
|
+
signed: this.signingEnabled,
|
|
606
|
+
})
|
|
607
|
+
this.branches.set(branch, hash)
|
|
608
|
+
return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`)
|
|
609
|
+
}
|
|
610
|
+
|
|
476
611
|
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
477
612
|
discardPending(): void {
|
|
478
613
|
this.index = new Map(this.worktree)
|
|
@@ -557,7 +692,9 @@ export class InMemRepo {
|
|
|
557
692
|
}
|
|
558
693
|
|
|
559
694
|
writeFile(path: string, content: string): void {
|
|
560
|
-
|
|
695
|
+
// A live overwrite keeps the mode.
|
|
696
|
+
const was = this.worktree.get(path)
|
|
697
|
+
this.worktree.set(path, was?.startsWith(EXEC) ? EXEC + content : content)
|
|
561
698
|
}
|
|
562
699
|
|
|
563
700
|
deleteFile(path: string): void {
|
package/src/twins/Layers.ts
CHANGED
|
@@ -119,6 +119,7 @@ export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): Workspa
|
|
|
119
119
|
Effect.succeed(
|
|
120
120
|
new Map([...(base !== undefined ? [base] : []), ...commits].map((h) => [h, treeOf(h)])),
|
|
121
121
|
),
|
|
122
|
+
modeSync: (ref) => repo.modesAt(ref),
|
|
122
123
|
worktreeSync: () =>
|
|
123
124
|
new Map(
|
|
124
125
|
repo
|
package/src/twins/SyncIo.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process"
|
|
2
|
-
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
2
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest"
|
|
@@ -12,6 +12,7 @@ interface Tier {
|
|
|
12
12
|
readonly root: string
|
|
13
13
|
readonly uid: number | undefined
|
|
14
14
|
readonly symlink: (path: string, target: string) => void
|
|
15
|
+
readonly chmod: (path: string, executable: boolean) => void
|
|
15
16
|
/** Commits `files`, repository-relative, as the only change. */
|
|
16
17
|
readonly commit: (files: Record<string, string>) => void
|
|
17
18
|
}
|
|
@@ -29,6 +30,7 @@ const liveTier = (): Tier => {
|
|
|
29
30
|
root,
|
|
30
31
|
uid: process.getuid?.(),
|
|
31
32
|
symlink: (path, target) => symlinkSync(target, path),
|
|
33
|
+
chmod: (path, executable) => chmodSync(path, executable ? 0o755 : 0o644),
|
|
32
34
|
commit: (files) => {
|
|
33
35
|
for (const [path, content] of Object.entries(files))
|
|
34
36
|
nodeSyncIo.writeText(join(root, path), content)
|
|
@@ -48,6 +50,7 @@ const inMemTier = (): Tier => {
|
|
|
48
50
|
root,
|
|
49
51
|
uid: 501,
|
|
50
52
|
symlink: io.symlink,
|
|
53
|
+
chmod: io.setExecutable,
|
|
51
54
|
commit: (files) => {
|
|
52
55
|
for (const [path, content] of Object.entries(files)) {
|
|
53
56
|
io.writeText(`${root}/${path}`, content)
|
|
@@ -106,9 +109,9 @@ describe.each([
|
|
|
106
109
|
symlink(`${root}/link`, "dir")
|
|
107
110
|
symlink(`${root}/dangling`, "nowhere")
|
|
108
111
|
|
|
109
|
-
expect(io.stat(`${root}/dir/f`)).
|
|
110
|
-
expect(io.stat(`${root}/link`)).
|
|
111
|
-
expect(io.lstat(`${root}/link`)).
|
|
112
|
+
expect(io.stat(`${root}/dir/f`)).toMatchObject({ type: "file", uid })
|
|
113
|
+
expect(io.stat(`${root}/link`)).toMatchObject({ type: "directory", uid })
|
|
114
|
+
expect(io.lstat(`${root}/link`)).toMatchObject({ type: "symlink", uid })
|
|
112
115
|
expect(io.lstat(`${root}/missing`)).toBeUndefined()
|
|
113
116
|
expect(io.stat(`${root}/dangling`)).toBeUndefined()
|
|
114
117
|
expect(io.readLink(`${root}/link`)).toBe("dir")
|
|
@@ -117,6 +120,18 @@ describe.each([
|
|
|
117
120
|
expect(() => io.realPath(`${root}/dangling`)).toThrow()
|
|
118
121
|
})
|
|
119
122
|
|
|
123
|
+
it("reports the executable bit of files, kept across an overwrite", () => {
|
|
124
|
+
const { io, root, chmod } = makeTier()
|
|
125
|
+
io.writeText(`${root}/f`, "x")
|
|
126
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(false)
|
|
127
|
+
chmod(`${root}/f`, true)
|
|
128
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(true)
|
|
129
|
+
io.writeText(`${root}/f`, "y")
|
|
130
|
+
expect(io.lstat(`${root}/f`)?.executable).toBe(true)
|
|
131
|
+
chmod(`${root}/f`, false)
|
|
132
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
120
135
|
it("reads committed content through git", () => {
|
|
121
136
|
const { io, root, commit } = makeTier()
|
|
122
137
|
commit({ "a.txt": "committed\n" })
|
package/src/twins/SyncIo.ts
CHANGED
|
@@ -15,6 +15,8 @@ const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
|
|
|
15
15
|
|
|
16
16
|
export interface InMemorySyncIo extends SyncIo {
|
|
17
17
|
readonly symlink: (path: string, target: string) => void
|
|
18
|
+
/** `chmod +x` / `-x` on a file. */
|
|
19
|
+
readonly setExecutable: (path: string, on: boolean) => void
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -27,6 +29,7 @@ export const inMemorySyncIo = (
|
|
|
27
29
|
const files = new Map<string, Uint8Array>()
|
|
28
30
|
const links = new Map<string, string>()
|
|
29
31
|
const dirs = new Set<string>(["/"])
|
|
32
|
+
const execs = new Set<string>()
|
|
30
33
|
|
|
31
34
|
/** `path` with every symlink resolved, the last component's only when `followLast`. */
|
|
32
35
|
const resolve = (path: string, followLast: boolean): string => {
|
|
@@ -68,7 +71,9 @@ export const inMemorySyncIo = (
|
|
|
68
71
|
|
|
69
72
|
const info = (path: string): FileInfo | undefined => {
|
|
70
73
|
const type = typeOf(path)
|
|
71
|
-
return type === undefined
|
|
74
|
+
return type === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: { type, uid: opts.uid, ...(type === "file" ? { executable: execs.has(path) } : {}) }
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const readFile = (path: string): Uint8Array | undefined => {
|
|
@@ -136,6 +141,8 @@ export const inMemorySyncIo = (
|
|
|
136
141
|
links.delete(target)
|
|
137
142
|
files.set(target, bytes)
|
|
138
143
|
files.delete(source)
|
|
144
|
+
execs.delete(target)
|
|
145
|
+
if (execs.delete(source)) execs.add(target)
|
|
139
146
|
},
|
|
140
147
|
stat: (path) => info(resolve(path, true)),
|
|
141
148
|
lstat: (path) => info(resolve(path, false)),
|
|
@@ -152,6 +159,11 @@ export const inMemorySyncIo = (
|
|
|
152
159
|
git,
|
|
153
160
|
gitBytes: (cwd, args) => encoder.encode(git(cwd, args)),
|
|
154
161
|
hash: pureHash,
|
|
162
|
+
setExecutable: (path, on) => {
|
|
163
|
+
const real = resolve(path, true)
|
|
164
|
+
if (on) execs.add(real)
|
|
165
|
+
else execs.delete(real)
|
|
166
|
+
},
|
|
155
167
|
symlink: (path, target) => {
|
|
156
168
|
links.set(resolve(path, false), target)
|
|
157
169
|
},
|
|
@@ -31,6 +31,7 @@ interface DocumentInput {
|
|
|
31
31
|
readonly log?: string
|
|
32
32
|
readonly session?: { readonly id: string; readonly resume: boolean }
|
|
33
33
|
readonly validate?: string
|
|
34
|
+
readonly fresh?: string | null
|
|
34
35
|
readonly changes?: readonly StatusChange[]
|
|
35
36
|
readonly next?: NextMatch | null
|
|
36
37
|
readonly cost?: number
|
|
@@ -47,6 +48,7 @@ const demandInput = (
|
|
|
47
48
|
kind: input.kind,
|
|
48
49
|
...(input.session !== undefined ? { session: input.session } : {}),
|
|
49
50
|
...(input.validate !== undefined ? { validate: input.validate } : {}),
|
|
51
|
+
...(input.fresh !== undefined ? { fresh: input.fresh } : {}),
|
|
50
52
|
})
|
|
51
53
|
|
|
52
54
|
/** `statusOf`'s own input, isolated for the same reason as `demandInput` above. */
|
|
@@ -84,6 +86,7 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
84
86
|
content: "fix it",
|
|
85
87
|
idle: false,
|
|
86
88
|
initial: false,
|
|
89
|
+
fresh: null,
|
|
87
90
|
log: ".git/gtd-loop.log",
|
|
88
91
|
state: "build.fixing",
|
|
89
92
|
actor: "agent",
|
|
@@ -138,6 +141,16 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
138
141
|
expect(parsed.validate).toBe("gtd check qa 'TODO.md'")
|
|
139
142
|
})
|
|
140
143
|
|
|
144
|
+
it("carries fresh at kind prompt and null at every other kind", () => {
|
|
145
|
+
const parsed = JSON.parse(renderJsonLine({ kind: "prompt", fresh: "p\n\nc" })) as {
|
|
146
|
+
fresh: unknown
|
|
147
|
+
}
|
|
148
|
+
expect(parsed.fresh).toBe("p\n\nc")
|
|
149
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
150
|
+
expect((JSON.parse(renderJsonLine({ kind })) as { fresh: unknown }).fresh).toBeNull()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
141
154
|
it("omits session/validate at kind prompt when the caller supplied none, never emitting null", () => {
|
|
142
155
|
const line = renderJsonLine({ kind: "prompt" })
|
|
143
156
|
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
@@ -256,6 +269,7 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
256
269
|
"model",
|
|
257
270
|
"system",
|
|
258
271
|
"validate",
|
|
272
|
+
"fresh",
|
|
259
273
|
"log",
|
|
260
274
|
"state",
|
|
261
275
|
"actor",
|
|
@@ -395,6 +409,7 @@ describe("golden bytes", () => {
|
|
|
395
409
|
content: "fix it",
|
|
396
410
|
idle: false,
|
|
397
411
|
initial: false,
|
|
412
|
+
fresh: null,
|
|
398
413
|
log: ".git/gtd-loop.log",
|
|
399
414
|
state: "build.fixing",
|
|
400
415
|
actor: "agent",
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -33,6 +33,7 @@ const dispatchFieldsOf = (
|
|
|
33
33
|
): {
|
|
34
34
|
readonly session: DemandSession | undefined
|
|
35
35
|
readonly validate: string | undefined
|
|
36
|
+
readonly fresh: string | null
|
|
36
37
|
readonly skills: readonly string[] | undefined
|
|
37
38
|
readonly access: StepAccess | undefined
|
|
38
39
|
readonly pin: PinMark | undefined
|
|
@@ -40,6 +41,7 @@ const dispatchFieldsOf = (
|
|
|
40
41
|
const none = {
|
|
41
42
|
session: undefined,
|
|
42
43
|
validate: undefined,
|
|
44
|
+
fresh: null,
|
|
43
45
|
skills: undefined,
|
|
44
46
|
access: undefined,
|
|
45
47
|
pin: undefined,
|
|
@@ -50,6 +52,7 @@ const dispatchFieldsOf = (
|
|
|
50
52
|
...none,
|
|
51
53
|
session: demand.session,
|
|
52
54
|
validate: demand.validate,
|
|
55
|
+
fresh: demand.fresh,
|
|
53
56
|
skills: demand.skills,
|
|
54
57
|
access: demand.access,
|
|
55
58
|
}
|
|
@@ -75,6 +78,8 @@ export interface BeatDocument {
|
|
|
75
78
|
readonly model: string | undefined
|
|
76
79
|
readonly system: string | undefined
|
|
77
80
|
readonly validate: string | undefined
|
|
81
|
+
/** A validator retry with `session.resume`: the original prompt plus the correction, for a driver that starts a new session. `null` otherwise. */
|
|
82
|
+
readonly fresh: string | null
|
|
78
83
|
/** On a pin or unpin beat only: what its script does to `.gtd/process.gtdpin`. */
|
|
79
84
|
readonly pin: PinMark | undefined
|
|
80
85
|
readonly log: string
|
|
@@ -96,13 +101,13 @@ export interface BeatDocument {
|
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
/**
|
|
99
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
104
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 27-key document
|
|
100
105
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
101
106
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
102
107
|
* (`cost <= 0`).
|
|
103
108
|
*/
|
|
104
109
|
export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument => {
|
|
105
|
-
const { session, validate, skills, access, pin } = dispatchFieldsOf(demand)
|
|
110
|
+
const { session, validate, fresh, skills, access, pin } = dispatchFieldsOf(demand)
|
|
106
111
|
const hasCost = status.cost > 0
|
|
107
112
|
return {
|
|
108
113
|
schema: WIRE_SCHEMA,
|
|
@@ -114,6 +119,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
|
|
|
114
119
|
model: status.model,
|
|
115
120
|
system: status.system,
|
|
116
121
|
validate,
|
|
122
|
+
fresh,
|
|
117
123
|
pin,
|
|
118
124
|
log: status.log,
|
|
119
125
|
state: status.state,
|
package/src/wire/Demand.ts
CHANGED
|
@@ -54,14 +54,24 @@ export const beatKindOf = (input: {
|
|
|
54
54
|
* `stalled at "<state>"` stays a stable substring (see
|
|
55
55
|
* `tests/integration/features/driver-doc.feature`).
|
|
56
56
|
*/
|
|
57
|
-
export const stallDiagnosis = (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
export const stallDiagnosis = (
|
|
58
|
+
state: StateName,
|
|
59
|
+
actor: Actor,
|
|
60
|
+
failure?: string,
|
|
61
|
+
blocked?: string,
|
|
62
|
+
): string =>
|
|
63
|
+
blocked !== undefined
|
|
64
|
+
? `stalled at "${state}": landing this ship step is refused.\n\n${blocked}\n`
|
|
65
|
+
: failure !== undefined
|
|
66
|
+
? `stalled at "${state}": the script exited non-zero and its failure is recorded in the tree. ` +
|
|
67
|
+
`Change the tree to run the script again; the step advances only on a zero exit.\n\n${failure}`
|
|
68
|
+
: `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
|
|
69
|
+
`attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
|
|
70
|
+
`Two ways out:\n` +
|
|
71
|
+
` - sharpen the step's prompt so the turn has something concrete to author\n` +
|
|
72
|
+
` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
|
|
73
|
+
` flow decides what follows — finishing, or an escalation step after N\n` +
|
|
74
|
+
` empty turns\n`
|
|
65
75
|
|
|
66
76
|
/** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
|
|
67
77
|
export interface DemandSession {
|
|
@@ -86,6 +96,7 @@ export type Demand =
|
|
|
86
96
|
readonly content: string
|
|
87
97
|
readonly session: DemandSession | undefined
|
|
88
98
|
readonly validate: string | undefined
|
|
99
|
+
readonly fresh: string | null
|
|
89
100
|
readonly skills: readonly string[] | undefined
|
|
90
101
|
readonly access: StepAccess
|
|
91
102
|
}
|
|
@@ -100,19 +111,22 @@ export type Demand =
|
|
|
100
111
|
export const demandOf = (input: {
|
|
101
112
|
readonly rendered: Pick<
|
|
102
113
|
RenderedDemandSource,
|
|
103
|
-
"actor" | "content" | "state" | "skills" | "access" | "pin"
|
|
114
|
+
"actor" | "content" | "state" | "skills" | "access" | "pin" | "failure" | "blocked"
|
|
104
115
|
>
|
|
105
116
|
readonly kind: BeatKind
|
|
106
117
|
readonly session?: DemandSession
|
|
107
118
|
readonly validate?: string
|
|
119
|
+
readonly fresh?: string | null
|
|
108
120
|
}): Demand => {
|
|
109
|
-
const { rendered, kind, session, validate } = input
|
|
121
|
+
const { rendered, kind, session, validate, fresh = null } = input
|
|
110
122
|
const content =
|
|
111
|
-
kind === "stalled"
|
|
123
|
+
kind === "stalled"
|
|
124
|
+
? stallDiagnosis(rendered.state, rendered.actor, rendered.failure, rendered.blocked)
|
|
125
|
+
: rendered.content
|
|
112
126
|
const skills =
|
|
113
127
|
rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
|
|
114
128
|
const access = rendered.access ?? { read: null, write: null }
|
|
115
|
-
if (kind === "prompt") return { kind, content, session, validate, skills, access }
|
|
129
|
+
if (kind === "prompt") return { kind, content, session, validate, fresh, skills, access }
|
|
116
130
|
if (kind === "script" && rendered.pin !== undefined) return { kind, content, pin: rendered.pin }
|
|
117
131
|
return { kind, content }
|
|
118
132
|
}
|
package/src/wire/constants.ts
CHANGED
|
@@ -23,3 +23,6 @@ export const ACCESS_REFUSAL =
|
|
|
23
23
|
|
|
24
24
|
/** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
|
|
25
25
|
export const PIN_PATH = ".gtd/process.gtdpin"
|
|
26
|
+
|
|
27
|
+
/** Where a `run(…, { onFail: "rest" })` body's failure is recorded in the tree: the exit status and the output. */
|
|
28
|
+
export const RUN_FAILED_PATH = ".gtd/run-failed.txt"
|
package/src/wire/types.ts
CHANGED
|
@@ -55,4 +55,8 @@ export interface RenderedDemandSource {
|
|
|
55
55
|
readonly skills?: readonly string[]
|
|
56
56
|
readonly access?: StepAccess
|
|
57
57
|
readonly pin?: PinMark
|
|
58
|
+
/** The recorded output of a failed `onFail: "rest"` run, shown at the stalled beat. */
|
|
59
|
+
readonly failure?: string
|
|
60
|
+
/** Why landing a ship step is refused, shown at the stalled beat. */
|
|
61
|
+
readonly blocked?: string
|
|
58
62
|
}
|
package/src/workflow/Workflow.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface StepDef {
|
|
|
42
42
|
readonly requireRevert?: boolean
|
|
43
43
|
readonly allowEmpty?: boolean
|
|
44
44
|
readonly keep?: boolean
|
|
45
|
+
/** A validator retry: the step's original prompt, `content` being the correction. */
|
|
46
|
+
readonly original?: string
|
|
47
|
+
readonly onFail?: "rest"
|
|
45
48
|
readonly acceptClean?: boolean
|
|
46
49
|
readonly approve?: boolean
|
|
47
50
|
readonly skills?: readonly string[]
|
package/src/workflow/compile.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface CompiledConfig {
|
|
|
8
8
|
/** The built-in modes (qa/review, with gtd's own validators) merged with `config.modes`, per half. */
|
|
9
9
|
readonly modes: Record<string, ModeDef>
|
|
10
10
|
readonly ui?: UiSettings
|
|
11
|
+
readonly historyRemote?: string
|
|
11
12
|
readonly skills: Readonly<Record<string, readonly string[]>>
|
|
12
13
|
readonly access: GtdConfig["access"] & {}
|
|
13
14
|
readonly diagnostics: readonly Diagnostic[]
|
|
@@ -48,6 +49,7 @@ export const compileConfig = (value: unknown, origin: string): CompiledConfig =>
|
|
|
48
49
|
return {
|
|
49
50
|
modes,
|
|
50
51
|
...(config.ui !== undefined ? { ui: config.ui } : {}),
|
|
52
|
+
...(config.historyRemote !== undefined ? { historyRemote: config.historyRemote } : {}),
|
|
51
53
|
// Null prototype: a `__proto__` key must stay an ordinary (reportable) entry.
|
|
52
54
|
skills: Object.assign(Object.create(null), config.skills),
|
|
53
55
|
access: Object.assign(Object.create(null), config.access),
|
package/src/workflow/load.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface ConfigOperations {
|
|
|
40
40
|
readonly doors: ReadonlyMap<string, Door>
|
|
41
41
|
/** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
|
|
42
42
|
readonly ui?: flows.UiSettings
|
|
43
|
+
readonly historyRemote?: string
|
|
43
44
|
/** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
|
|
44
45
|
readonly warnings: readonly Diagnostic[]
|
|
45
46
|
/** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
|
|
@@ -197,6 +198,7 @@ export const load: Effect.Effect<
|
|
|
197
198
|
return entry === undefined ? undefined : resolve(name, entry)
|
|
198
199
|
},
|
|
199
200
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
201
|
+
...(compiled.historyRemote !== undefined ? { historyRemote: compiled.historyRemote } : {}),
|
|
200
202
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
201
203
|
fingerprint:
|
|
202
204
|
pinned?.fingerprint ??
|
|
@@ -68,3 +68,11 @@ export const removedAssertions = (changes: readonly Change[]): readonly RemovedA
|
|
|
68
68
|
return { path: c.path, lines }
|
|
69
69
|
})
|
|
70
70
|
.filter((r) => r.lines.length > 0)
|
|
71
|
+
|
|
72
|
+
/** Validator form of `removedAssertions`: lists each lost line by path and scenario, or `undefined` when none. */
|
|
73
|
+
export const assertionErrors = (changes: readonly Change[]): string | undefined => {
|
|
74
|
+
const lost = removedAssertions(changes)
|
|
75
|
+
if (lost.length === 0) return undefined
|
|
76
|
+
const lines = lost.flatMap((r) => r.lines.map((a) => ` - ${r.path}: ${a.scenario}: ${a.line}`))
|
|
77
|
+
return `Restore the assertions these .feature files lost:\n${lines.join("\n")}`
|
|
78
|
+
}
|