@pmelab/gtd 65.0.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 +3 -2
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +2 -2
- package/claude/hooks/pr.ts +80 -20
- package/claude/hooks/register.tsx +19 -4
- package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
- package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +430 -100
- package/dist/gtd.core.mjs +147 -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 +1 -1
- package/src/cli/Program.ts +18 -2
- package/src/edge/Edge.ts +93 -11
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +3 -0
- package/src/flows/index.ts +5 -0
- package/src/flows/runtime.ts +123 -16
- 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/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/replay/Replay.ts +33 -1
- package/src/replay/Trailers.ts +3 -0
- package/src/replay/Tree.ts +5 -0
- 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/RepoSnapshot.ts +4 -0
- package/src/step/planStep.ts +6 -6
- package/src/twins/EmittedScriptRecognizer.ts +1 -1
- package/src/twins/InMemRepo.ts +24 -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 +4 -2
- 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/workflow/Workflow.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/src/edge/Edge.ts
CHANGED
|
@@ -761,6 +761,7 @@ const promptDef = (
|
|
|
761
761
|
...optional("system", request.options.system),
|
|
762
762
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
763
763
|
...optional("keep", request.options.keep),
|
|
764
|
+
...optional("original", request.original),
|
|
764
765
|
...optional("skills", skills),
|
|
765
766
|
...optional("access", access),
|
|
766
767
|
})
|
|
@@ -1288,6 +1289,7 @@ export interface RenderedRest extends RestHints {
|
|
|
1288
1289
|
readonly content: string
|
|
1289
1290
|
readonly memory?: string
|
|
1290
1291
|
readonly memoryResumed: boolean
|
|
1292
|
+
readonly original?: string
|
|
1291
1293
|
readonly pin?: PinChange
|
|
1292
1294
|
readonly failure?: string
|
|
1293
1295
|
readonly blocked?: string
|
|
@@ -1306,6 +1308,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1306
1308
|
...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
|
|
1307
1309
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1308
1310
|
memoryResumed: rest.memoryResumed,
|
|
1311
|
+
...(rest.stepDef.original !== undefined ? { original: rest.stepDef.original } : {}),
|
|
1309
1312
|
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
1310
1313
|
})
|
|
1311
1314
|
|
|
@@ -1383,7 +1386,7 @@ const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
|
1383
1386
|
return undefined
|
|
1384
1387
|
}
|
|
1385
1388
|
|
|
1386
|
-
/** At the human review gate the pending tree lands with every tick cleared
|
|
1389
|
+
/** At the human review gate the pending tree lands with every tick cleared; an agent-bound landing keeps them only if the unrewritten tree replays to the same step. */
|
|
1387
1390
|
const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
|
|
1388
1391
|
const reviewFormat = steeringFormatFor("review")
|
|
1389
1392
|
if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
|
|
@@ -1392,6 +1395,16 @@ const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
|
|
|
1392
1395
|
return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
|
|
1393
1396
|
}
|
|
1394
1397
|
|
|
1398
|
+
/** Whether the pending review file differs from HEAD's once both have their ticks cleared. */
|
|
1399
|
+
const reviewEditedBeyondTicks = (rest: Rest): boolean => {
|
|
1400
|
+
const rewrite = reviewGateRewrite(rest.stepDef)
|
|
1401
|
+
if (rewrite === undefined) return false
|
|
1402
|
+
const pending = rest.setup.workspace.readSync(rewrite.path)
|
|
1403
|
+
const committed = rest.setup.workspace.readCommittedSync(rewrite.path)
|
|
1404
|
+
if (pending === undefined || committed === undefined) return true
|
|
1405
|
+
return rewrite.apply(pending) !== rewrite.apply(committed)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1395
1408
|
const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
|
|
1396
1409
|
const multiline = multilineSetting(vars)
|
|
1397
1410
|
return multiline === undefined
|
|
@@ -1579,7 +1592,28 @@ const decideLanding = (
|
|
|
1579
1592
|
)
|
|
1580
1593
|
const to = yield* landingTarget(outcome, rest)
|
|
1581
1594
|
if (typeof to !== "string") return to
|
|
1582
|
-
|
|
1595
|
+
const landed = landingTo(rest, to, branch, approver, outcomeMarks(outcome))
|
|
1596
|
+
// Ticks survive an agent-bound landing only when the review file changed beyond ticks
|
|
1597
|
+
// (a tick-only sign-off lands clean) and the unrewritten tree replays to the same step.
|
|
1598
|
+
if (
|
|
1599
|
+
landed.kind !== "commit" ||
|
|
1600
|
+
!isHumanReviewGate(rest.stepDef) ||
|
|
1601
|
+
outcome.kind !== "rest" ||
|
|
1602
|
+
outcome.rest.request.kind !== "agent" ||
|
|
1603
|
+
!reviewEditedBeyondTicks(rest)
|
|
1604
|
+
) {
|
|
1605
|
+
return landed
|
|
1606
|
+
}
|
|
1607
|
+
const ticked = yield* Effect.promise(() =>
|
|
1608
|
+
replayFor(rest.setup, {
|
|
1609
|
+
tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined),
|
|
1610
|
+
...(verdicts !== undefined ? { verdicts } : {}),
|
|
1611
|
+
...(shipped !== undefined ? { shipped } : {}),
|
|
1612
|
+
}),
|
|
1613
|
+
)
|
|
1614
|
+
return ticked.kind === "rest" && ticked.rest.name === outcome.rest.name
|
|
1615
|
+
? { ...landed, keepTicks: true }
|
|
1616
|
+
: landed
|
|
1583
1617
|
})
|
|
1584
1618
|
|
|
1585
1619
|
/** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
|
|
@@ -1620,7 +1654,13 @@ const withHistory = (landing: Landing & { kind: "commit" }) =>
|
|
|
1620
1654
|
let out = landing
|
|
1621
1655
|
const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
|
|
1622
1656
|
if (back !== undefined) out = { ...out, leaveBranch: back }
|
|
1623
|
-
if (
|
|
1657
|
+
if (
|
|
1658
|
+
landing.keepHistory === undefined &&
|
|
1659
|
+
landing.ship === undefined &&
|
|
1660
|
+
landing.recordedHistory === undefined
|
|
1661
|
+
) {
|
|
1662
|
+
return out
|
|
1663
|
+
}
|
|
1624
1664
|
const remote = (yield* (yield* ConfigService).load).historyRemote
|
|
1625
1665
|
return remote === undefined ? out : { ...out, pushHistory: remote }
|
|
1626
1666
|
})
|
|
@@ -1659,9 +1699,16 @@ const SHIP_EXCLUDED = ".gtd/"
|
|
|
1659
1699
|
|
|
1660
1700
|
type ShipDecision =
|
|
1661
1701
|
| { readonly kind: "ship"; readonly branch: string; readonly parent: string }
|
|
1702
|
+
| { readonly kind: "recorded"; readonly hash: string }
|
|
1662
1703
|
| { readonly kind: "skip"; readonly note: string }
|
|
1663
1704
|
| { readonly kind: "blocked"; readonly message: string }
|
|
1664
1705
|
|
|
1706
|
+
/** `tree` with file modes read from `ref` (the working tree when `undefined`), for the ship decision only: reading them costs a git call. */
|
|
1707
|
+
const withModes = (workspace: WorkspaceOps, tree: TreeView, ref: string | undefined): TreeView => {
|
|
1708
|
+
let modes: ReadonlyMap<string, string> | undefined
|
|
1709
|
+
return { ...tree, mode: (path) => (modes ??= workspace.modeSync(ref)).get(path) }
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1665
1712
|
const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
|
|
1666
1713
|
const diff = diffTrees(before, after)
|
|
1667
1714
|
return [...diff.added, ...diff.modified, ...diff.deleted].filter(
|
|
@@ -1700,6 +1747,7 @@ const revertedPaths = (
|
|
|
1700
1747
|
parent: string,
|
|
1701
1748
|
tree: TreeView,
|
|
1702
1749
|
changed: ReadonlySet<string>,
|
|
1750
|
+
treeRef: string | undefined,
|
|
1703
1751
|
) =>
|
|
1704
1752
|
Effect.gen(function* () {
|
|
1705
1753
|
const head = yield* git.resolveRef("HEAD")
|
|
@@ -1708,9 +1756,18 @@ const revertedPaths = (
|
|
|
1708
1756
|
const fork = yield* git.mergeBase(parent, head)
|
|
1709
1757
|
const foreign = Option.isNone(fork)
|
|
1710
1758
|
? [...changed]
|
|
1711
|
-
: shippedPaths(
|
|
1712
|
-
|
|
1713
|
-
|
|
1759
|
+
: shippedPaths(
|
|
1760
|
+
withModes(workspace, commitTree(workspace, fork.value), fork.value),
|
|
1761
|
+
withModes(workspace, commitTree(workspace, parent), parent),
|
|
1762
|
+
)
|
|
1763
|
+
const committed = withModes(workspace, commitTree(workspace, head), head)
|
|
1764
|
+
const moded = withModes(workspace, tree, treeRef)
|
|
1765
|
+
return foreign.filter(
|
|
1766
|
+
(path) =>
|
|
1767
|
+
changed.has(path) &&
|
|
1768
|
+
moded.read(path) === committed.read(path) &&
|
|
1769
|
+
moded.mode!(path) === committed.mode!(path),
|
|
1770
|
+
)
|
|
1714
1771
|
})
|
|
1715
1772
|
|
|
1716
1773
|
/**
|
|
@@ -1722,14 +1779,31 @@ const revertedPaths = (
|
|
|
1722
1779
|
const decideShip = (
|
|
1723
1780
|
workspace: WorkspaceOps,
|
|
1724
1781
|
tree: TreeView,
|
|
1782
|
+
/** The commit `tree` is of; `undefined` for the working tree. */
|
|
1783
|
+
treeRef: string | undefined,
|
|
1725
1784
|
): Effect.Effect<ShipDecision, Error, GitService> =>
|
|
1726
1785
|
Effect.gen(function* () {
|
|
1727
1786
|
const git = yield* GitService
|
|
1728
1787
|
const target = yield* shipTarget(git)
|
|
1729
1788
|
if (target.kind !== "ship") return target
|
|
1730
1789
|
const { branch, parent } = target
|
|
1731
|
-
const changed = new Set(
|
|
1732
|
-
|
|
1790
|
+
const changed = new Set(
|
|
1791
|
+
shippedPaths(
|
|
1792
|
+
withModes(workspace, commitTree(workspace, parent), parent),
|
|
1793
|
+
withModes(workspace, tree, treeRef),
|
|
1794
|
+
),
|
|
1795
|
+
)
|
|
1796
|
+
// A landing whose hook rejected after the ship moved `<b>`: its tip is a ship of this very HEAD.
|
|
1797
|
+
// An edit since (say, to satisfy that hook) ships again rather than reusing the stale ship.
|
|
1798
|
+
const pending = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
|
|
1799
|
+
if (
|
|
1800
|
+
changed.size === 0 &&
|
|
1801
|
+
pending !== undefined &&
|
|
1802
|
+
pending === (yield* git.resolveRef("HEAD"))
|
|
1803
|
+
) {
|
|
1804
|
+
return { kind: "recorded", hash: parent }
|
|
1805
|
+
}
|
|
1806
|
+
const reverted = yield* revertedPaths(git, workspace, parent, tree, changed, treeRef)
|
|
1733
1807
|
if (reverted.length > 0) {
|
|
1734
1808
|
return {
|
|
1735
1809
|
kind: "blocked",
|
|
@@ -1750,7 +1824,7 @@ const shipBlockedAt = (
|
|
|
1750
1824
|
Effect.gen(function* () {
|
|
1751
1825
|
if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
|
|
1752
1826
|
const head = yield* (yield* GitService).resolveRef("HEAD")
|
|
1753
|
-
const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head))
|
|
1827
|
+
const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head)
|
|
1754
1828
|
return decision.kind === "blocked" ? decision.message : undefined
|
|
1755
1829
|
})
|
|
1756
1830
|
|
|
@@ -1763,7 +1837,7 @@ const shipSnapshot = (
|
|
|
1763
1837
|
) =>
|
|
1764
1838
|
Effect.gen(function* () {
|
|
1765
1839
|
const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
|
|
1766
|
-
const decision = yield* decideShip(rest.setup.workspace, tree)
|
|
1840
|
+
const decision = yield* decideShip(rest.setup.workspace, tree, undefined)
|
|
1767
1841
|
if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
|
|
1768
1842
|
const shipping = decision.kind === "ship"
|
|
1769
1843
|
const decided = yield* decideLanding(
|
|
@@ -1771,9 +1845,17 @@ const shipSnapshot = (
|
|
|
1771
1845
|
verdicts,
|
|
1772
1846
|
branch,
|
|
1773
1847
|
approver,
|
|
1774
|
-
shipping ? SHIPPED_STAND_IN : undefined,
|
|
1848
|
+
decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : undefined,
|
|
1775
1849
|
)
|
|
1776
1850
|
if (decided.kind !== "commit") return snapshotOf(rest, decided)
|
|
1851
|
+
if (decision.kind === "recorded") {
|
|
1852
|
+
const recorded = {
|
|
1853
|
+
...decided,
|
|
1854
|
+
spec: { ...decided.spec, shipped: decision.hash },
|
|
1855
|
+
recordedHistory: processHistoryRef(rest.run.startHash),
|
|
1856
|
+
}
|
|
1857
|
+
return snapshotOf(rest, yield* withBranchAndPush(recorded))
|
|
1858
|
+
}
|
|
1777
1859
|
const landing: Landing = shipping
|
|
1778
1860
|
? {
|
|
1779
1861
|
...decided,
|
package/src/flows/helpers.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
settings,
|
|
13
13
|
threads,
|
|
14
14
|
type Change,
|
|
15
|
+
type Changes,
|
|
15
16
|
type JudgeAnswer,
|
|
16
17
|
type Judgment,
|
|
17
18
|
} from "./runtime.js"
|
|
@@ -78,15 +79,19 @@ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".
|
|
|
78
79
|
/** The last step's changes outside `.gtd/`. */
|
|
79
80
|
export const codeChanges = (): readonly Change[] => changes().filter((c) => isCode(c.path))
|
|
80
81
|
|
|
82
|
+
/** Validator form of `requireProgress`: the agent-addressed error, or `undefined` when the turn is valid. */
|
|
83
|
+
export const progressErrors = (file: string, turnChanges: Changes): string | undefined => {
|
|
84
|
+
const change = turnChanges.get(file)
|
|
85
|
+
if (change?.status !== "deleted") return undefined
|
|
86
|
+
if (turnChanges.some((c) => isCode(c.path))) return undefined
|
|
87
|
+
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return undefined
|
|
88
|
+
return `${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file. Its content was:\n${change.before ?? ""}`
|
|
89
|
+
}
|
|
90
|
+
|
|
81
91
|
/** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
|
|
82
92
|
export const requireProgress = (file: string): void => {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
if (codeChanges().length > 0) return
|
|
86
|
-
if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return
|
|
87
|
-
refuse(
|
|
88
|
-
`gtd land: feedback-progress: ${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file.`,
|
|
89
|
-
)
|
|
93
|
+
const error = progressErrors(file, changes())
|
|
94
|
+
if (error !== undefined) refuse(`gtd land: feedback-progress: ${error}`)
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
/** Refuse a turn that leaves a question in `file` unanswered. An untouched tree is silence, and allowed. */
|
|
@@ -135,23 +140,36 @@ export const requireThreadsClosed = (file: string): void => {
|
|
|
135
140
|
)
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
/** Validator form of `requireReplies`: the agent-addressed error, or `undefined` when every thread is answered. */
|
|
144
|
+
export const repliesErrors = (file: string): string | undefined => {
|
|
145
|
+
const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")]
|
|
146
|
+
if (open.length === 0) return undefined
|
|
147
|
+
return `${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
/** Refuse an agent turn that leaves a thread in `file`, or a code thread in a changed file, waiting on the agent (its last entry is `H:`) or with a syntax fault. */
|
|
139
151
|
export const requireReplies = (file: string): void => {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
refuse(
|
|
143
|
-
`gtd land: unanswered-threads: ${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`,
|
|
144
|
-
)
|
|
152
|
+
const error = repliesErrors(file)
|
|
153
|
+
if (error !== undefined) refuse(`gtd land: unanswered-threads: ${error}`)
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
export const requireRevert = (edited: readonly Change[], base: string): void => {
|
|
156
|
+
const revertFix = (edited: readonly Change[], base: string): string | undefined => {
|
|
149
157
|
const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path)
|
|
150
|
-
if (residue.length === 0) return
|
|
158
|
+
if (residue.length === 0) return undefined
|
|
151
159
|
const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ")
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
return `${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\``
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Validator form of `requireRevert`: reads the tree, not the turn's changes. */
|
|
164
|
+
export const revertErrors = (edited: readonly Change[], base: string): string | undefined => {
|
|
165
|
+
const fix = revertFix(edited, base)
|
|
166
|
+
return fix === undefined ? undefined : `${fix}.`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
|
|
170
|
+
export const requireRevert = (edited: readonly Change[], base: string): void => {
|
|
171
|
+
const fix = revertFix(edited, base)
|
|
172
|
+
if (fix !== undefined) refuse(`gtd land: require-revert: ${fix}, then \`gtd land\` again.`)
|
|
155
173
|
}
|
|
156
174
|
|
|
157
175
|
const SHAPES = ["land", "refactor-first", "reject"] as const
|
package/src/flows/index.test.ts
CHANGED
|
@@ -34,10 +34,12 @@ describe("@pmelab/gtd/flows", () => {
|
|
|
34
34
|
"moveScript",
|
|
35
35
|
"numeric",
|
|
36
36
|
"openQuestions",
|
|
37
|
+
"progressErrors",
|
|
37
38
|
"quote",
|
|
38
39
|
"read",
|
|
39
40
|
"refuse",
|
|
40
41
|
"removeScript",
|
|
42
|
+
"repliesErrors",
|
|
41
43
|
"requireAnswers",
|
|
42
44
|
"requireProgress",
|
|
43
45
|
"requireReplies",
|
|
@@ -45,6 +47,7 @@ describe("@pmelab/gtd/flows", () => {
|
|
|
45
47
|
"requireThreadsClosed",
|
|
46
48
|
"restart",
|
|
47
49
|
"restoreScript",
|
|
50
|
+
"revertErrors",
|
|
48
51
|
"revertScript",
|
|
49
52
|
"run",
|
|
50
53
|
"scope",
|
package/src/flows/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export {
|
|
|
48
48
|
type JudgeQuestion,
|
|
49
49
|
type JudgeQuestionInput,
|
|
50
50
|
type JudgeSpec,
|
|
51
|
+
type ValidateResult,
|
|
52
|
+
type ValidateTurn,
|
|
51
53
|
type Judgment,
|
|
52
54
|
type KeptTip,
|
|
53
55
|
type Mark,
|
|
@@ -78,11 +80,14 @@ export {
|
|
|
78
80
|
hasThreadFor,
|
|
79
81
|
judgeDiffShape,
|
|
80
82
|
numeric,
|
|
83
|
+
progressErrors,
|
|
84
|
+
repliesErrors,
|
|
81
85
|
requireAnswers,
|
|
82
86
|
requireProgress,
|
|
83
87
|
requireReplies,
|
|
84
88
|
requireRevert,
|
|
85
89
|
requireThreadsClosed,
|
|
90
|
+
revertErrors,
|
|
86
91
|
seamGate,
|
|
87
92
|
wrote,
|
|
88
93
|
type Check,
|
package/src/flows/runtime.ts
CHANGED
|
@@ -37,9 +37,25 @@ type SteeringFile =
|
|
|
37
37
|
/** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
|
|
38
38
|
export type SteeringOptions = SteeringCommon & SteeringFile
|
|
39
39
|
|
|
40
|
+
/** What a validator is told about the work so far. */
|
|
41
|
+
export interface ValidateTurn {
|
|
42
|
+
/** Every change since before the first try, minus the files the validator's own steps wrote. */
|
|
43
|
+
readonly changes: Changes
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ValidateResult = string | readonly string[] | undefined
|
|
47
|
+
|
|
40
48
|
interface AgentCommon {
|
|
41
49
|
readonly model?: string | undefined
|
|
42
50
|
readonly system?: string | undefined
|
|
51
|
+
/** Runs after each landed turn; errors it returns become the correction the next try gets. Only `check`, `run` and `judge` may run inside. */
|
|
52
|
+
readonly validate?: ((turn: ValidateTurn) => Promise<ValidateResult>) | undefined
|
|
53
|
+
/** Retries before `exhausted` runs. Default 3. */
|
|
54
|
+
readonly cap?: number | undefined
|
|
55
|
+
/** Runs once `cap` retries failed; default rests at a `<name>.invalid` gate. The validator then runs again. */
|
|
56
|
+
readonly exhausted?: ((errors: readonly string[]) => Promise<void>) | undefined
|
|
57
|
+
/** The retry's `content`, instead of the default lead-in plus the errors. */
|
|
58
|
+
readonly correction?: ((errors: readonly string[]) => string) | undefined
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export type AgentOptions = SteeringOptions &
|
|
@@ -209,6 +225,8 @@ export type StepRequest =
|
|
|
209
225
|
readonly name: string
|
|
210
226
|
readonly prompt: string
|
|
211
227
|
readonly options: AgentOptions
|
|
228
|
+
/** On a validator retry: the step's original prompt (`prompt` is then the correction). */
|
|
229
|
+
readonly original?: string
|
|
212
230
|
}
|
|
213
231
|
| { readonly kind: "human"; readonly name: string; readonly options: HumanOptions }
|
|
214
232
|
| {
|
|
@@ -331,9 +349,84 @@ const ctx = (): FlowContext => {
|
|
|
331
349
|
|
|
332
350
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
|
333
351
|
|
|
352
|
+
// While a validator runs: its step's name, and the paths its own steps changed. Keyed by the
|
|
353
|
+
// replay's context: a validator suspended at a rest never unwinds, so a global would leak.
|
|
354
|
+
type Validating = { readonly name: string; readonly paths: Set<string> }
|
|
355
|
+
const validatingBy = new WeakMap<FlowContext, Validating>()
|
|
356
|
+
|
|
357
|
+
const DEFAULT_CAP = 3
|
|
358
|
+
|
|
359
|
+
const noValidatorStep = (kind: string, name: string): void => {
|
|
360
|
+
const validating = validatingBy.get(ctx())
|
|
361
|
+
if (validating !== undefined) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`gtd: step "${name}": ${kind}() cannot run inside the validator of "${validating.name}" — a validator may only run check, run and judge steps`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const noteValidatorPaths = (): void => {
|
|
369
|
+
const validating = validatingBy.get(ctx())
|
|
370
|
+
if (validating !== undefined) for (const c of ctx().changes()) validating.paths.add(c.path)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const errorList = (result: ValidateResult): readonly string[] =>
|
|
374
|
+
result === undefined ? [] : typeof result === "string" ? [result] : result
|
|
375
|
+
|
|
334
376
|
/** An agent turn: `prompt` is emitted, the driver lands the diff. */
|
|
335
|
-
export const agent = (
|
|
336
|
-
|
|
377
|
+
export const agent = async (
|
|
378
|
+
name: string,
|
|
379
|
+
prompt: string,
|
|
380
|
+
options: AgentOptions = {},
|
|
381
|
+
): Promise<void> => {
|
|
382
|
+
noValidatorStep("agent", name)
|
|
383
|
+
const context = ctx()
|
|
384
|
+
const { validate, cap = DEFAULT_CAP, exhausted, correction } = options
|
|
385
|
+
const base = typeof validate === "function" ? context.head() : ""
|
|
386
|
+
await context.step({ kind: "agent", name, prompt, options })
|
|
387
|
+
if (typeof validate !== "function") return
|
|
388
|
+
|
|
389
|
+
const own = new Set<string>()
|
|
390
|
+
// Paths an agent turn or a person changed. Whatever changed since the last validator returned
|
|
391
|
+
// is theirs — even on a path a validator `run` (lint --fix) wrote in an earlier round.
|
|
392
|
+
const agentPaths = new Set<string>()
|
|
393
|
+
let validated = base
|
|
394
|
+
const validateNow = async (): Promise<readonly string[]> => {
|
|
395
|
+
for (const c of context.changesSince(validated)) agentPaths.add(c.path)
|
|
396
|
+
const turn: ValidateTurn = {
|
|
397
|
+
get changes() {
|
|
398
|
+
return toChanges(
|
|
399
|
+
context.changesSince(base).filter((c) => !own.has(c.path) || agentPaths.has(c.path)),
|
|
400
|
+
)
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
validatingBy.set(context, { name, paths: own })
|
|
404
|
+
try {
|
|
405
|
+
return errorList(await scope(`${name}.validate`, () => validate(turn)))
|
|
406
|
+
} finally {
|
|
407
|
+
validatingBy.delete(context)
|
|
408
|
+
validated = context.head()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let errors = await validateNow()
|
|
413
|
+
let retries = 0
|
|
414
|
+
while (errors.length > 0) {
|
|
415
|
+
if (retries >= cap) {
|
|
416
|
+
if (exhausted !== undefined) await exhausted(errors)
|
|
417
|
+
else await human(`${name}.invalid`, errors.join("\n"), { acceptClean: true })
|
|
418
|
+
retries = 0
|
|
419
|
+
} else {
|
|
420
|
+
retries++
|
|
421
|
+
const text =
|
|
422
|
+
correction !== undefined
|
|
423
|
+
? correction(errors)
|
|
424
|
+
: `The result did not pass validation. Fix these errors:\n\n${errors.join("\n")}\n`
|
|
425
|
+
await context.step({ kind: "agent", name, prompt: text, options, original: prompt })
|
|
426
|
+
}
|
|
427
|
+
errors = await validateNow()
|
|
428
|
+
}
|
|
429
|
+
}
|
|
337
430
|
|
|
338
431
|
// `Omit` over a union collapses it; this keeps each member's own rules.
|
|
339
432
|
type OmitEach<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
@@ -349,8 +442,9 @@ export const human: Human = (
|
|
|
349
442
|
name: string,
|
|
350
443
|
messageOrOptions?: string | HumanOptions,
|
|
351
444
|
options?: OmitEach<HumanOptions, "message">,
|
|
352
|
-
): Promise<void> =>
|
|
353
|
-
|
|
445
|
+
): Promise<void> => {
|
|
446
|
+
noValidatorStep("human", name)
|
|
447
|
+
return ctx().step({
|
|
354
448
|
kind: "human",
|
|
355
449
|
name,
|
|
356
450
|
options:
|
|
@@ -358,10 +452,13 @@ export const human: Human = (
|
|
|
358
452
|
? { ...options, message: messageOrOptions }
|
|
359
453
|
: (messageOrOptions ?? {}),
|
|
360
454
|
}) as Promise<void>
|
|
455
|
+
}
|
|
361
456
|
|
|
362
457
|
/** A check: `body` runs at the edge and its effect on the tree is committed. */
|
|
363
|
-
export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
|
|
364
|
-
ctx().step({ kind: "run", name, body, options })
|
|
458
|
+
export const run = async (name: string, body: string, options: RunOptions = {}): Promise<void> => {
|
|
459
|
+
await ctx().step({ kind: "run", name, body, options })
|
|
460
|
+
noteValidatorPaths()
|
|
461
|
+
}
|
|
365
462
|
|
|
366
463
|
/**
|
|
367
464
|
* Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
|
|
@@ -372,8 +469,10 @@ export const ship = (
|
|
|
372
469
|
name: string,
|
|
373
470
|
message: string,
|
|
374
471
|
options: ShipOptions = {},
|
|
375
|
-
): Promise<string | undefined> =>
|
|
376
|
-
|
|
472
|
+
): Promise<string | undefined> => {
|
|
473
|
+
noValidatorStep("ship", name)
|
|
474
|
+
return ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
|
|
475
|
+
}
|
|
377
476
|
|
|
378
477
|
/** A judge gate: resolves to what the judge answered, for the flow to decide on. */
|
|
379
478
|
export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
|
|
@@ -385,17 +484,25 @@ export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly J
|
|
|
385
484
|
const canonical = questions.map((q) =>
|
|
386
485
|
q.primitive === "yesno" ? { ...q, primitive: "noul" as const } : (q as JudgeQuestion),
|
|
387
486
|
)
|
|
388
|
-
return ctx()
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
487
|
+
return ctx()
|
|
488
|
+
.step({
|
|
489
|
+
kind: "judge",
|
|
490
|
+
name,
|
|
491
|
+
questions: canonical,
|
|
492
|
+
evidence,
|
|
493
|
+
options,
|
|
494
|
+
})
|
|
495
|
+
.then((judgment) => {
|
|
496
|
+
noteValidatorPaths()
|
|
497
|
+
return judgment as Judgment<Q[number]["id"]>
|
|
498
|
+
})
|
|
395
499
|
}
|
|
396
500
|
|
|
397
501
|
/** End the episode from any depth. The next episode starts the flow afresh. */
|
|
398
|
-
export const restart = (): Promise<never> =>
|
|
502
|
+
export const restart = (): Promise<never> => {
|
|
503
|
+
noValidatorStep("restart", "restart")
|
|
504
|
+
return ctx().step({ kind: "restart" }) as Promise<never>
|
|
505
|
+
}
|
|
399
506
|
|
|
400
507
|
/** Refuse the pending turn: nothing lands, the process stays where it rests. */
|
|
401
508
|
export const refuse = (message: string): never => ctx().refuse(message)
|
package/src/install/Install.ts
CHANGED
|
@@ -83,6 +83,7 @@ while :; do
|
|
|
83
83
|
model="$(gtd next --json=model)"
|
|
84
84
|
system="$(gtd next --json=system)"
|
|
85
85
|
validate="$(gtd next --json=validate)"
|
|
86
|
+
fresh="$(gtd next --json=fresh)"
|
|
86
87
|
log="$(gtd next --json=log)"
|
|
87
88
|
# \`model\` is a hint, not a model name — gtd-workflows' default ships
|
|
88
89
|
# \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
|
|
@@ -125,8 +126,14 @@ while :; do
|
|
|
125
126
|
then first=--resume second=--session-id
|
|
126
127
|
else first=--session-id second=--resume
|
|
127
128
|
fi
|
|
129
|
+
# A validator retry resumes with the correction alone; a session that
|
|
130
|
+
# cannot resume starts over with \`fresh\`, the original prompt plus it.
|
|
131
|
+
second_prompt="$log.prompt"
|
|
132
|
+
if [ "$second" = --session-id ] && [ -n "\${fresh:-}" ]
|
|
133
|
+
then printf '%s\\n' "$fresh" >"$log.fresh" && second_prompt="$log.fresh"
|
|
134
|
+
fi
|
|
128
135
|
turn "$first" "$session_id" <"$log.prompt" ||
|
|
129
|
-
{ [ "$ran" = false ] && turn "$second" "$session_id" <"$
|
|
136
|
+
{ [ "$ran" = false ] && turn "$second" "$session_id" <"$second_prompt"; } || {
|
|
130
137
|
echo "agent turn failed — see $log" >&2
|
|
131
138
|
exit 1
|
|
132
139
|
}
|
|
@@ -140,7 +147,7 @@ while :; do
|
|
|
140
147
|
exit 1
|
|
141
148
|
}
|
|
142
149
|
done
|
|
143
|
-
rm -f "$log.prompt" ;;
|
|
150
|
+
rm -f "$log.prompt" "$log.fresh" ;;
|
|
144
151
|
esac
|
|
145
152
|
|
|
146
153
|
# Read every value the landing needs BEFORE piping the script to \`sh\`:
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -87,17 +87,13 @@ describe("diffPaths [Live]", () => {
|
|
|
87
87
|
})
|
|
88
88
|
|
|
89
89
|
// An exported GIT_DIR/GIT_WORK_TREE would send this test's commits to the real repo;
|
|
90
|
-
// the
|
|
91
|
-
const cleanEnv =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
GIT_CONFIG_VALUE_1: "test@test.com",
|
|
98
|
-
GIT_CONFIG_KEY_2: "user.name",
|
|
99
|
-
GIT_CONFIG_VALUE_2: "Test",
|
|
100
|
-
}
|
|
90
|
+
// the suite's isolated global config keeps a developer's signing setup out.
|
|
91
|
+
const cleanEnv = Object.fromEntries(
|
|
92
|
+
Object.entries(process.env).filter(
|
|
93
|
+
([key]) =>
|
|
94
|
+
!key.startsWith("GIT_") || key === "GIT_CONFIG_GLOBAL" || key === "GIT_CONFIG_NOSYSTEM",
|
|
95
|
+
),
|
|
96
|
+
)
|
|
101
97
|
|
|
102
98
|
it("reports a submodule pointer bump whatever diff.ignoreSubmodules and .gitmodules ignore say", async () => {
|
|
103
99
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-sm-")))
|
package/src/platform/NodeSync.ts
CHANGED
|
@@ -39,6 +39,8 @@ const infoOf = (stats: Stats): FileInfo => ({
|
|
|
39
39
|
? "file"
|
|
40
40
|
: "other",
|
|
41
41
|
uid: process.platform === "win32" ? undefined : stats.uid,
|
|
42
|
+
// git records 100755 from the owner execute bit alone.
|
|
43
|
+
...(process.platform === "win32" ? {} : { executable: (stats.mode & 0o100) !== 0 }),
|
|
42
44
|
})
|
|
43
45
|
|
|
44
46
|
export const nodeSyncIo: SyncIo = {
|
package/src/platform/SyncIo.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface FileInfo {
|
|
|
4
4
|
readonly type: "file" | "directory" | "symlink" | "other"
|
|
5
5
|
/** `undefined` where the platform has no POSIX user ids. */
|
|
6
6
|
readonly uid: number | undefined
|
|
7
|
+
/** Any execute bit set; `undefined` where the platform has none. */
|
|
8
|
+
readonly executable?: boolean
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/**
|