@pmelab/gtd 60.1.0 → 61.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 +7 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +1666 -1449
- package/dist/gtd.core.mjs +86 -86
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -19
- package/src/cli/Program.ts +4 -1
- package/src/cli/Strays.ts +3 -2
- package/src/edge/Edge.test.ts +78 -25
- package/src/edge/Edge.ts +161 -28
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +12 -12
- package/src/replay/Trailers.ts +35 -20
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/GitTiers.ts +23 -0
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/index.ts +1 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
package/src/edge/Edge.ts
CHANGED
|
@@ -37,18 +37,23 @@ import {
|
|
|
37
37
|
isRecordableApproval,
|
|
38
38
|
parseCommitMessage,
|
|
39
39
|
replay,
|
|
40
|
+
type CommitMessage,
|
|
40
41
|
scopeRunStart,
|
|
41
42
|
treeFromRecord,
|
|
42
43
|
type EpisodeCommit,
|
|
43
44
|
type JudgeVerdict,
|
|
45
|
+
type PinChange,
|
|
46
|
+
PIN_STATES,
|
|
44
47
|
type ReachedStep,
|
|
45
48
|
type ReplayOutcome,
|
|
46
49
|
type TreeView,
|
|
47
50
|
} from "../replay/index.js"
|
|
48
51
|
import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
|
|
49
52
|
import { steeringFormatFor } from "../steering/index.js"
|
|
53
|
+
import { pinScript, unpinScript } from "../emit/index.js"
|
|
50
54
|
import {
|
|
51
55
|
judgeJson,
|
|
56
|
+
PIN_PATH,
|
|
52
57
|
UNATTRIBUTED_MODEL,
|
|
53
58
|
type ApprovalEntry,
|
|
54
59
|
type ModelCost,
|
|
@@ -87,6 +92,16 @@ interface EpisodeLocation {
|
|
|
87
92
|
readonly processStart: number
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
const isOpening = ({ parsed, step }: CommitMessage): boolean =>
|
|
96
|
+
parsed !== undefined &&
|
|
97
|
+
step === undefined &&
|
|
98
|
+
parsed.from === undefined &&
|
|
99
|
+
parsed.actor === "human"
|
|
100
|
+
|
|
101
|
+
// A pin commit's subject may name the first step; it bounds nothing.
|
|
102
|
+
const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
|
|
103
|
+
pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
|
|
104
|
+
|
|
90
105
|
/**
|
|
91
106
|
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
92
107
|
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
@@ -99,13 +114,7 @@ interface EpisodeLocation {
|
|
|
99
114
|
const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
|
|
100
115
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
101
116
|
const message = parseCommitMessage(history[i]!.message)
|
|
102
|
-
|
|
103
|
-
const opening =
|
|
104
|
-
subject !== undefined &&
|
|
105
|
-
message.step === undefined &&
|
|
106
|
-
subject.from === undefined &&
|
|
107
|
-
subject.actor === "human"
|
|
108
|
-
if (opening) {
|
|
117
|
+
if (isOpening(message)) {
|
|
109
118
|
return {
|
|
110
119
|
workflow: message.workflow,
|
|
111
120
|
legacyOpening: message.workflow === undefined,
|
|
@@ -113,7 +122,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
|
|
|
113
122
|
processStart: i,
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
|
-
if (
|
|
125
|
+
if (boundsEpisode(def, message)) {
|
|
117
126
|
return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
|
|
118
127
|
}
|
|
119
128
|
}
|
|
@@ -156,6 +165,8 @@ export interface ProcessRun {
|
|
|
156
165
|
readonly trace: readonly TraceEntry[]
|
|
157
166
|
readonly costEntries: readonly CostEntry[]
|
|
158
167
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
168
|
+
/** Whether a pin commit has landed in this process. */
|
|
169
|
+
readonly pinCommitted: boolean
|
|
159
170
|
/** Every process commit's `Gtd-Mark`s, oldest first. */
|
|
160
171
|
readonly marks: readonly Mark[]
|
|
161
172
|
/** Oldest first. */
|
|
@@ -186,7 +197,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
|
|
|
186
197
|
if (head === undefined) return undefined
|
|
187
198
|
const message = parseCommitMessage(head.message)
|
|
188
199
|
const subject = message.parsed
|
|
189
|
-
if (subject === undefined || !ACTORS.has(subject.actor)
|
|
200
|
+
if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
|
|
201
|
+
return undefined
|
|
190
202
|
return {
|
|
191
203
|
state: subject.to,
|
|
192
204
|
actor: subject.actor,
|
|
@@ -267,8 +279,11 @@ const buildRun = (
|
|
|
267
279
|
location: EpisodeLocation,
|
|
268
280
|
closingHash: string | undefined,
|
|
269
281
|
): ProcessRun => {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
282
|
+
const allProcessCommits = history.slice(location.processStart)
|
|
283
|
+
const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
|
|
284
|
+
// A pin commit is no turn; the unpin commit stays, as the one that closes the process.
|
|
285
|
+
const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
|
|
286
|
+
const parsed = allParsed.filter((m) => m.pin !== "add")
|
|
272
287
|
const first = parsed[0]
|
|
273
288
|
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
274
289
|
const head = history[history.length - 1]
|
|
@@ -281,6 +296,7 @@ const buildRun = (
|
|
|
281
296
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
282
297
|
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
283
298
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
299
|
+
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
284
300
|
marks: parsed.flatMap((m) => m.marks),
|
|
285
301
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
286
302
|
pinnedVars: { ...first?.vars },
|
|
@@ -320,6 +336,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
320
336
|
const parsed = parseCommitMessage(subject).parsed
|
|
321
337
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
322
338
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
339
|
+
// A pin commit's subject can name the first step; it bounds nothing.
|
|
340
|
+
if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
|
|
323
341
|
return parsed.to === def.initial
|
|
324
342
|
}
|
|
325
343
|
|
|
@@ -849,6 +867,10 @@ interface ResolvedRest {
|
|
|
849
867
|
* before any mutation.
|
|
850
868
|
*/
|
|
851
869
|
interface Rest extends ResolvedRest {
|
|
870
|
+
/** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
|
|
871
|
+
readonly pin: PinChange | undefined
|
|
872
|
+
/** Whether HEAD's tree carries the pin. */
|
|
873
|
+
readonly pinned: boolean
|
|
852
874
|
readonly step: ReachedStep
|
|
853
875
|
readonly trace: readonly ReachedStep[]
|
|
854
876
|
readonly run: ProcessRun
|
|
@@ -985,13 +1007,15 @@ export const restAt = (
|
|
|
985
1007
|
// staying true; passing a pending tree here would make it serve a stale
|
|
986
1008
|
// steering file on every cache hit.
|
|
987
1009
|
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
1010
|
+
const pinned = headHasPin(workspace, run)
|
|
1011
|
+
const pin = pinBeatAt(outcome, run, def, pinned)
|
|
1012
|
+
const resting = restingOn(outcome, pin)
|
|
1013
|
+
if (resting === undefined) {
|
|
1014
|
+
return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
|
|
991
1015
|
}
|
|
992
|
-
const step =
|
|
993
|
-
|
|
994
|
-
|
|
1016
|
+
const { reached, step } = resting
|
|
1017
|
+
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
|
|
1018
|
+
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
|
|
995
1019
|
if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
|
|
996
1020
|
return yield* Effect.fail(
|
|
997
1021
|
new Error(
|
|
@@ -1006,7 +1030,6 @@ export const restAt = (
|
|
|
1006
1030
|
path: e.path,
|
|
1007
1031
|
}))
|
|
1008
1032
|
: []
|
|
1009
|
-
const memory = memoryOf(outcome.trace, run)
|
|
1010
1033
|
const configNow = config.fingerprint
|
|
1011
1034
|
// Only the current rest warns, so a command must read it once — a second
|
|
1012
1035
|
// read warns twice. An earlier ref (restore's tip) would warn about a
|
|
@@ -1015,11 +1038,13 @@ export const restAt = (
|
|
|
1015
1038
|
return {
|
|
1016
1039
|
def,
|
|
1017
1040
|
workflowName: resolved.name,
|
|
1018
|
-
state
|
|
1041
|
+
state,
|
|
1019
1042
|
stepDef,
|
|
1020
|
-
actor:
|
|
1043
|
+
actor: stepDef.actor,
|
|
1044
|
+
pin,
|
|
1045
|
+
pinned,
|
|
1021
1046
|
step,
|
|
1022
|
-
trace:
|
|
1047
|
+
trace: reached.trace,
|
|
1023
1048
|
run,
|
|
1024
1049
|
vars,
|
|
1025
1050
|
env,
|
|
@@ -1035,6 +1060,66 @@ export const restAt = (
|
|
|
1035
1060
|
|
|
1036
1061
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1037
1062
|
|
|
1063
|
+
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
1064
|
+
const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
|
|
1065
|
+
if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
|
|
1066
|
+
if (outcome.kind !== "ended" || pin !== "remove") return undefined
|
|
1067
|
+
const step = outcome.trace.at(-1)
|
|
1068
|
+
return step === undefined ? undefined : { reached: outcome, step }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** A pin or unpin beat has its own script, no step's, and no agent memory. */
|
|
1072
|
+
const restFacts = (
|
|
1073
|
+
pin: PinChange | undefined,
|
|
1074
|
+
step: ReachedStep,
|
|
1075
|
+
trace: readonly ReachedStep[],
|
|
1076
|
+
run: ProcessRun,
|
|
1077
|
+
) =>
|
|
1078
|
+
pin === undefined
|
|
1079
|
+
? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
|
|
1080
|
+
: {
|
|
1081
|
+
stepDef: pinStepDef(pin),
|
|
1082
|
+
state: PIN_STATES[pin],
|
|
1083
|
+
memory: { key: undefined, resumed: false },
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
|
|
1087
|
+
const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
|
|
1088
|
+
return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
|
|
1092
|
+
run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* A started process pins first — again, should a turn have deleted the pin;
|
|
1096
|
+
* a pinned flow that ended unpins before the closing commit.
|
|
1097
|
+
*/
|
|
1098
|
+
const pinBeatAt = (
|
|
1099
|
+
outcome: ReplayOutcome,
|
|
1100
|
+
run: ProcessRun,
|
|
1101
|
+
def: WorkflowDefinition,
|
|
1102
|
+
pinned: boolean,
|
|
1103
|
+
): PinChange | undefined => {
|
|
1104
|
+
if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
|
|
1105
|
+
if (outcome.kind !== "rest" || pinned) return undefined
|
|
1106
|
+
return underwayAt(run, outcome.rest, def) ? "add" : undefined
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// A default-workflow process back at the first step has ended, as a returned flow has.
|
|
1110
|
+
const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
|
|
1111
|
+
outcome.kind === "ended" ||
|
|
1112
|
+
(outcome.kind === "rest" &&
|
|
1113
|
+
run.workflow === undefined &&
|
|
1114
|
+
outcome.rest.name === def.initial &&
|
|
1115
|
+
underwayAt(run, outcome.rest, def))
|
|
1116
|
+
|
|
1117
|
+
const pinStepDef = (pin: PinChange): StepDef => ({
|
|
1118
|
+
actor: "check",
|
|
1119
|
+
kind: "script",
|
|
1120
|
+
content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1038
1123
|
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1039
1124
|
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1040
1125
|
|
|
@@ -1094,6 +1179,7 @@ export interface RenderedRest extends RestHints {
|
|
|
1094
1179
|
readonly content: string
|
|
1095
1180
|
readonly memory?: string
|
|
1096
1181
|
readonly memoryResumed: boolean
|
|
1182
|
+
readonly pin?: PinChange
|
|
1097
1183
|
}
|
|
1098
1184
|
|
|
1099
1185
|
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
@@ -1105,6 +1191,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1105
1191
|
...rest.hints,
|
|
1106
1192
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1107
1193
|
memoryResumed: rest.memoryResumed,
|
|
1194
|
+
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
1108
1195
|
})
|
|
1109
1196
|
|
|
1110
1197
|
/**
|
|
@@ -1122,9 +1209,7 @@ export const stalledAt = (rest: Rest): boolean =>
|
|
|
1122
1209
|
|
|
1123
1210
|
/** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
|
|
1124
1211
|
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
1125
|
-
rest.
|
|
1126
|
-
rest.state === rest.def.initial &&
|
|
1127
|
-
rest.step.id.occurrence === 1
|
|
1212
|
+
rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
|
|
1128
1213
|
|
|
1129
1214
|
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
1130
1215
|
export const restIsIdle = (rest: Rest): boolean =>
|
|
@@ -1254,15 +1339,15 @@ const landingTo = (
|
|
|
1254
1339
|
if (refused !== undefined) return refused
|
|
1255
1340
|
// Landing on the first step while a process is underway ends it (flow
|
|
1256
1341
|
// return and `restart()` alike); the script then points the history ref at
|
|
1257
|
-
// that very commit.
|
|
1258
|
-
const finishes =
|
|
1342
|
+
// that very commit. A pinned process ends one beat later, at the unpin.
|
|
1343
|
+
const { finishes, landsAt } = endingOf(rest, to, underway)
|
|
1259
1344
|
return {
|
|
1260
1345
|
kind: "commit",
|
|
1261
|
-
to,
|
|
1346
|
+
to: landsAt,
|
|
1262
1347
|
spec: {
|
|
1263
1348
|
actor: rest.actor,
|
|
1264
1349
|
from: rest.state,
|
|
1265
|
-
to,
|
|
1350
|
+
to: landsAt,
|
|
1266
1351
|
step: rest.step.id,
|
|
1267
1352
|
...(marks.length > 0 && { marks }),
|
|
1268
1353
|
...approvedTrailer(rest, approver),
|
|
@@ -1274,6 +1359,53 @@ const landingTo = (
|
|
|
1274
1359
|
}
|
|
1275
1360
|
}
|
|
1276
1361
|
|
|
1362
|
+
const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
|
|
1363
|
+
const ends = underway && to === rest.def.initial
|
|
1364
|
+
return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
|
|
1368
|
+
const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
|
|
1369
|
+
const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
|
|
1370
|
+
return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const pinCommit = (rest: Rest, change: string | undefined): Landing => {
|
|
1374
|
+
if (change === undefined || change === "D") return pinRefusal("add")
|
|
1375
|
+
const to = rest.step.name
|
|
1376
|
+
return {
|
|
1377
|
+
kind: "commit",
|
|
1378
|
+
to,
|
|
1379
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
|
|
1380
|
+
only: PIN_PATH,
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const unpinCommit = (
|
|
1385
|
+
rest: Rest,
|
|
1386
|
+
change: string | undefined,
|
|
1387
|
+
branch: Option.Option<string>,
|
|
1388
|
+
): Landing => {
|
|
1389
|
+
if (change !== "D" && rest.pinned) return pinRefusal("remove")
|
|
1390
|
+
const to = rest.def.initial
|
|
1391
|
+
return {
|
|
1392
|
+
kind: "commit",
|
|
1393
|
+
to,
|
|
1394
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
|
|
1395
|
+
keepHistory: processHistoryRef(rest.run.startHash),
|
|
1396
|
+
// A turn already deleted the pin: the closing commit has no path of its own.
|
|
1397
|
+
...(rest.pinned ? { only: PIN_PATH } : {}),
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const pinRefusal = (pin: PinChange): Landing => ({
|
|
1402
|
+
kind: "refusal",
|
|
1403
|
+
message:
|
|
1404
|
+
pin === "add"
|
|
1405
|
+
? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\``
|
|
1406
|
+
: `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1277
1409
|
const startTrailers = (rest: Rest, starts: boolean) =>
|
|
1278
1410
|
starts ? { vars: rest.vars, config: rest.config } : {}
|
|
1279
1411
|
|
|
@@ -1292,6 +1424,7 @@ const decideLanding = (
|
|
|
1292
1424
|
approver: Option.Option<AuthorIdent>,
|
|
1293
1425
|
): Effect.Effect<Landing, Error> =>
|
|
1294
1426
|
Effect.gen(function* () {
|
|
1427
|
+
if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
|
|
1295
1428
|
const unapproved = approvalRefusal(rest, approver)
|
|
1296
1429
|
if (unapproved !== undefined) return unapproved
|
|
1297
1430
|
const early = cleanLanding(rest)
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -12,12 +12,13 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
|
|
|
12
12
|
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
13
13
|
* error, a commit-msg hook) still fails.
|
|
14
14
|
*/
|
|
15
|
-
const commitAllowEmpty = (message: string): string => {
|
|
15
|
+
const commitAllowEmpty = (message: string, only?: string): string => {
|
|
16
16
|
const m = shellQuote(message)
|
|
17
|
+
const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
|
|
17
18
|
return [
|
|
18
|
-
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
19
|
+
`if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
|
|
19
20
|
` case "$out" in`,
|
|
20
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
21
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
|
|
21
22
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
22
23
|
` esac`,
|
|
23
24
|
`fi`,
|
|
@@ -31,6 +32,21 @@ export const CHANGED_SINCE_LAND =
|
|
|
31
32
|
export const commitAll = (message: string): string =>
|
|
32
33
|
`git add -A || exit 1\n${commitAllowEmpty(message)}`
|
|
33
34
|
|
|
35
|
+
/** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
|
|
36
|
+
export const commitPath = (path: string, message: string): string =>
|
|
37
|
+
`git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
|
|
38
|
+
|
|
39
|
+
/** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
|
|
40
|
+
export const pinScript = (path: string): string => {
|
|
41
|
+
const p = shellQuote(path)
|
|
42
|
+
const slash = path.lastIndexOf("/")
|
|
43
|
+
const mkdir = slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`
|
|
44
|
+
return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
|
|
48
|
+
export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
|
|
49
|
+
|
|
34
50
|
/**
|
|
35
51
|
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
36
52
|
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
@@ -151,6 +167,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
151
167
|
switch (write.kind) {
|
|
152
168
|
case "commitAll":
|
|
153
169
|
return commitAll(write.message)
|
|
170
|
+
case "commitPath":
|
|
171
|
+
return commitPath(write.path, write.message)
|
|
154
172
|
case "commitClean":
|
|
155
173
|
return commitClean(write.message)
|
|
156
174
|
case "updateRef":
|
package/src/emit/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ Feature: steps for checks, stalls, beat fields, commit bodies and the environmen
|
|
|
23
23
|
|
|
24
24
|
Scenario: the resting step's fields are read by name
|
|
25
25
|
When gtd starts workflow "tested"
|
|
26
|
+
And the process is driven
|
|
26
27
|
Then the process rests at "build.plan"
|
|
27
28
|
And the resting step's model is "smart"
|
|
28
29
|
And the resting step's idle is "false"
|
|
@@ -43,6 +43,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
43
43
|
And the commit subjects end with:
|
|
44
44
|
"""
|
|
45
45
|
gtd(human): plan
|
|
46
|
+
gtd(check): pin → plan
|
|
46
47
|
gtd(agent): plan → check
|
|
47
48
|
gtd(check): check → ready
|
|
48
49
|
gtd(judge): ready → ship
|
|
@@ -106,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
106
107
|
When gtd starts workflow "tiny"
|
|
107
108
|
And the process is driven
|
|
108
109
|
Then the process rests at "plan"
|
|
109
|
-
And the last commit subject is "gtd(
|
|
110
|
+
And the last commit subject is "gtd(check): pin → plan"
|
|
110
111
|
|
|
111
112
|
Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
|
|
112
113
|
Given step "check" is prompted with "npm test"
|
package/src/install/Install.ts
CHANGED
|
@@ -240,7 +240,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
|
|
|
240
240
|
Land it immediately, no display needed.
|
|
241
241
|
- \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
|
|
242
242
|
\`--json=log\`, and ignore its exit code — the outcome lives in the tree,
|
|
243
|
-
not in the exit code.
|
|
243
|
+
not in the exit code. Two script beats carry a \`pin\` mark: the **pin
|
|
244
|
+
beat** right after a process starts (\`pin: "add"\`, its script writes
|
|
245
|
+
\`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
|
|
246
|
+
once the flow has ended (\`pin: "remove"\`, its script deletes that file).
|
|
247
|
+
Both run unattended, like any script beat. A driver may write or delete the
|
|
248
|
+
file its own way instead of running the script; either way it then lands,
|
|
249
|
+
and the unpin's landing is the commit that closes the process.
|
|
244
250
|
- \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
|
|
245
251
|
stdin (it's the bare content at this kind, so no separate \`--json=content\`
|
|
246
252
|
read is needed), using the accompanying \`session.id\`/\`session.resume\`/
|
|
@@ -284,7 +290,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
284
290
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
285
291
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
286
292
|
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
287
|
-
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
293
|
+
\`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
|
|
294
|
+
beat: \`pin\` (\`"add"\` or \`"remove"\`).
|
|
288
295
|
|
|
289
296
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
290
297
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
|
@@ -2,25 +2,31 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
|
-
it("accepts a history
|
|
6
|
-
expect(formatFault([{ hash: "a".repeat(40), format:
|
|
5
|
+
it("accepts a history in the current format and in the one before it", () => {
|
|
6
|
+
expect(formatFault([{ hash: "a".repeat(40), format: 2 }])).toBeUndefined()
|
|
7
|
+
expect(
|
|
8
|
+
formatFault([
|
|
9
|
+
{ hash: "a".repeat(40), format: 1 },
|
|
10
|
+
{ hash: "b".repeat(40), format: 2 },
|
|
11
|
+
]),
|
|
12
|
+
).toBeUndefined()
|
|
7
13
|
expect(formatFault([])).toBeUndefined()
|
|
8
14
|
})
|
|
9
15
|
|
|
10
16
|
it("names the first foreign commit, the found and readable formats, and what to do", () => {
|
|
11
17
|
const fault = formatFault([
|
|
12
18
|
{ hash: "a".repeat(40), format: 1 },
|
|
13
|
-
{ hash: "b".repeat(40), format:
|
|
14
|
-
{ hash: "c".repeat(40), format:
|
|
19
|
+
{ hash: "b".repeat(40), format: 3 },
|
|
20
|
+
{ hash: "c".repeat(40), format: 4 },
|
|
15
21
|
])
|
|
16
22
|
expect(fault).toBe(
|
|
17
|
-
"gtd: commit bbbbbbb is written in history format
|
|
23
|
+
"gtd: commit bbbbbbb is written in history format 3, but this gtd reads formats 1 to 2 — run a gtd release that reads format 3 to continue this process",
|
|
18
24
|
)
|
|
19
25
|
})
|
|
20
26
|
})
|
|
21
27
|
|
|
22
28
|
describe("Gtd-Approved and the history format", () => {
|
|
23
|
-
it("
|
|
29
|
+
it("writes the current format on a message carrying it", () => {
|
|
24
30
|
const message = formatCommitMessage({
|
|
25
31
|
actor: "human",
|
|
26
32
|
to: "b",
|
|
@@ -29,7 +35,28 @@ describe("Gtd-Approved and the history format", () => {
|
|
|
29
35
|
expect(message).toContain(
|
|
30
36
|
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
31
37
|
)
|
|
32
|
-
expect(message).toContain("Gtd-Format:
|
|
33
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
38
|
+
expect(message).toContain("Gtd-Format: 2")
|
|
39
|
+
expect(parseCommitMessage(message).format).toBe(2)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("Gtd-Pin", () => {
|
|
44
|
+
it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
|
|
45
|
+
const message = formatCommitMessage({
|
|
46
|
+
actor: "check",
|
|
47
|
+
from: "unpin",
|
|
48
|
+
to: "idle",
|
|
49
|
+
pin: "remove",
|
|
50
|
+
branch: "main",
|
|
51
|
+
})
|
|
52
|
+
expect(message).toBe(
|
|
53
|
+
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 2",
|
|
54
|
+
)
|
|
55
|
+
expect(parseCommitMessage(message).pin).toBe("remove")
|
|
56
|
+
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("reads an unknown value as no pin", () => {
|
|
60
|
+
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
34
61
|
})
|
|
35
62
|
})
|
package/src/replay/Format.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// Bump only when what replay reads back out of git changes incompatibly — the
|
|
2
2
|
// package version must not stand in for it, since most majors leave history
|
|
3
|
-
// untouched.
|
|
4
|
-
export const HISTORY_FORMAT =
|
|
3
|
+
// untouched. Format 2 adds pin commits, which format 1 would replay as turns.
|
|
4
|
+
export const HISTORY_FORMAT = 2
|
|
5
|
+
const OLDEST_READABLE_FORMAT = 1
|
|
5
6
|
|
|
6
7
|
const short = (hash: string): string => hash.slice(0, 7)
|
|
7
8
|
|
|
8
9
|
export const formatFault = (
|
|
9
10
|
commits: readonly { readonly hash: string; readonly format: number }[],
|
|
10
11
|
): string | undefined => {
|
|
11
|
-
const foreign = commits.find(
|
|
12
|
+
const foreign = commits.find(
|
|
13
|
+
(c) => c.format < OLDEST_READABLE_FORMAT || c.format > HISTORY_FORMAT,
|
|
14
|
+
)
|
|
12
15
|
if (foreign === undefined) return undefined
|
|
13
|
-
return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads
|
|
16
|
+
return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
|
|
14
17
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
changes,
|
|
7
7
|
changesSince,
|
|
8
8
|
codeThreads,
|
|
9
|
+
head,
|
|
9
10
|
human,
|
|
10
11
|
judge,
|
|
11
12
|
kept,
|
|
@@ -1217,3 +1218,56 @@ describe("human({ approve })", () => {
|
|
|
1217
1218
|
expect(outcome.kind).toBe("rest")
|
|
1218
1219
|
})
|
|
1219
1220
|
})
|
|
1221
|
+
|
|
1222
|
+
describe("pin commits", () => {
|
|
1223
|
+
const PIN = ".gtd/process.gtdpin"
|
|
1224
|
+
const pinned = () =>
|
|
1225
|
+
new History()
|
|
1226
|
+
.land("human", "idle", 1, "build", { "NOTE.md": "n" })
|
|
1227
|
+
.raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1228
|
+
|
|
1229
|
+
it("are never a turn: the next step answers from the commit after the pin", async () => {
|
|
1230
|
+
const flow = async () => {
|
|
1231
|
+
await human("idle")
|
|
1232
|
+
await agent("build", "b")
|
|
1233
|
+
await agent("check", "c")
|
|
1234
|
+
}
|
|
1235
|
+
const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
|
|
1236
|
+
expect(restName(await replayOf(flow, h))).toBe("check#1")
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it("hide the pin from every tree flow code reads, so a turn's changes leave it out", async () => {
|
|
1240
|
+
let seen: { readonly changed: readonly string[]; readonly pin: string | undefined } | undefined
|
|
1241
|
+
const flow = async () => {
|
|
1242
|
+
await human("idle")
|
|
1243
|
+
await agent("build", "b")
|
|
1244
|
+
seen = { changed: changes().map((c) => c.path), pin: read(PIN) }
|
|
1245
|
+
await agent("check", "c")
|
|
1246
|
+
}
|
|
1247
|
+
const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
|
|
1248
|
+
await replayOf(flow, h)
|
|
1249
|
+
expect(seen).toEqual({ changed: ["a.ts"], pin: undefined })
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it("are never what head() names: a flow reverting the start reverts the turn", async () => {
|
|
1253
|
+
let named: string | undefined
|
|
1254
|
+
const flow = async () => {
|
|
1255
|
+
await human("idle")
|
|
1256
|
+
named = head()
|
|
1257
|
+
await agent("build", "b")
|
|
1258
|
+
}
|
|
1259
|
+
await replayOf(flow, pinned())
|
|
1260
|
+
expect(named).toBe(hashOf(1))
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it("trailing an ended flow are no divergence", async () => {
|
|
1264
|
+
const flow = async () => {
|
|
1265
|
+
await human("idle")
|
|
1266
|
+
await agent("build", "b")
|
|
1267
|
+
}
|
|
1268
|
+
const h = pinned()
|
|
1269
|
+
.land("agent", "build", 1, "unpin", { "a.ts": "a" })
|
|
1270
|
+
.raw("gtd(check): unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
|
|
1271
|
+
expect((await replayOf(flow, h)).kind).toBe("ended")
|
|
1272
|
+
})
|
|
1273
|
+
})
|