@pmelab/gtd 60.1.0 → 62.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 +16 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +991 -627
- package/dist/gtd.core.mjs +117 -117
- 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/Cli.ts +6 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -20
- package/src/cli/Program.ts +4 -2
- package/src/cli/Strays.ts +3 -2
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +79 -26
- package/src/edge/Edge.ts +178 -99
- 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/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- 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 +11 -48
- package/src/replay/Trailers.ts +36 -42
- 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 -3
- package/src/step/planStart.ts +1 -5
- 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/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +2 -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/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
package/src/edge/Edge.ts
CHANGED
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
ConfigDiscovery,
|
|
14
14
|
ConfigService,
|
|
15
|
-
changedConfigFiles,
|
|
16
|
-
contentHash,
|
|
17
15
|
multilineSetting,
|
|
18
16
|
resolveVars,
|
|
19
17
|
resolveStartVars,
|
|
@@ -37,18 +35,23 @@ import {
|
|
|
37
35
|
isRecordableApproval,
|
|
38
36
|
parseCommitMessage,
|
|
39
37
|
replay,
|
|
38
|
+
type CommitMessage,
|
|
40
39
|
scopeRunStart,
|
|
41
40
|
treeFromRecord,
|
|
42
41
|
type EpisodeCommit,
|
|
43
42
|
type JudgeVerdict,
|
|
43
|
+
type PinChange,
|
|
44
|
+
PIN_STATES,
|
|
44
45
|
type ReachedStep,
|
|
45
46
|
type ReplayOutcome,
|
|
46
47
|
type TreeView,
|
|
47
48
|
} from "../replay/index.js"
|
|
48
49
|
import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
|
|
49
50
|
import { steeringFormatFor } from "../steering/index.js"
|
|
51
|
+
import { pinScript, unpinScript } from "../emit/index.js"
|
|
50
52
|
import {
|
|
51
53
|
judgeJson,
|
|
54
|
+
PIN_PATH,
|
|
52
55
|
UNATTRIBUTED_MODEL,
|
|
53
56
|
type ApprovalEntry,
|
|
54
57
|
type ModelCost,
|
|
@@ -87,6 +90,16 @@ interface EpisodeLocation {
|
|
|
87
90
|
readonly processStart: number
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
const isOpening = ({ parsed, step }: CommitMessage): boolean =>
|
|
94
|
+
parsed !== undefined &&
|
|
95
|
+
step === undefined &&
|
|
96
|
+
parsed.from === undefined &&
|
|
97
|
+
parsed.actor === "human"
|
|
98
|
+
|
|
99
|
+
// A pin commit's subject may name the first step; it bounds nothing.
|
|
100
|
+
const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
|
|
101
|
+
pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
|
|
102
|
+
|
|
90
103
|
/**
|
|
91
104
|
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
92
105
|
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
@@ -99,13 +112,7 @@ interface EpisodeLocation {
|
|
|
99
112
|
const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
|
|
100
113
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
101
114
|
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) {
|
|
115
|
+
if (isOpening(message)) {
|
|
109
116
|
return {
|
|
110
117
|
workflow: message.workflow,
|
|
111
118
|
legacyOpening: message.workflow === undefined,
|
|
@@ -113,7 +120,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
|
|
|
113
120
|
processStart: i,
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
|
-
if (
|
|
123
|
+
if (boundsEpisode(def, message)) {
|
|
117
124
|
return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
|
|
118
125
|
}
|
|
119
126
|
}
|
|
@@ -156,14 +163,14 @@ export interface ProcessRun {
|
|
|
156
163
|
readonly trace: readonly TraceEntry[]
|
|
157
164
|
readonly costEntries: readonly CostEntry[]
|
|
158
165
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
166
|
+
/** Whether a pin commit has landed in this process. */
|
|
167
|
+
readonly pinCommitted: boolean
|
|
159
168
|
/** Every process commit's `Gtd-Mark`s, oldest first. */
|
|
160
169
|
readonly marks: readonly Mark[]
|
|
161
170
|
/** Oldest first. */
|
|
162
171
|
readonly approvals: readonly ApprovalEntry[]
|
|
163
172
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
164
173
|
readonly pinnedVars: Record<string, string>
|
|
165
|
-
/** Absent for a process started before recording existed. */
|
|
166
|
-
readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
|
|
167
174
|
/** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
|
|
168
175
|
readonly headTurn:
|
|
169
176
|
| {
|
|
@@ -186,7 +193,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
|
|
|
186
193
|
if (head === undefined) return undefined
|
|
187
194
|
const message = parseCommitMessage(head.message)
|
|
188
195
|
const subject = message.parsed
|
|
189
|
-
if (subject === undefined || !ACTORS.has(subject.actor)
|
|
196
|
+
if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
|
|
197
|
+
return undefined
|
|
190
198
|
return {
|
|
191
199
|
state: subject.to,
|
|
192
200
|
actor: subject.actor,
|
|
@@ -236,17 +244,6 @@ const runOf = (
|
|
|
236
244
|
: Effect.fail(new Error(fault))
|
|
237
245
|
}
|
|
238
246
|
|
|
239
|
-
// The newest recording, not the first process commit's: the episode is
|
|
240
|
-
// located by the CURRENT config's first step, so a config that renamed it can
|
|
241
|
-
// move the boundary onto the start commit itself, or behind it.
|
|
242
|
-
const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
243
|
-
for (let i = history.length - 1; i >= 0; i--) {
|
|
244
|
-
const files = parseCommitMessage(history[i]!.message).config
|
|
245
|
-
if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
|
|
246
|
-
}
|
|
247
|
-
return undefined
|
|
248
|
-
}
|
|
249
|
-
|
|
250
247
|
const approvalEntryOf = (
|
|
251
248
|
hash: string,
|
|
252
249
|
message: ReturnType<typeof parseCommitMessage>,
|
|
@@ -267,8 +264,11 @@ const buildRun = (
|
|
|
267
264
|
location: EpisodeLocation,
|
|
268
265
|
closingHash: string | undefined,
|
|
269
266
|
): ProcessRun => {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
267
|
+
const allProcessCommits = history.slice(location.processStart)
|
|
268
|
+
const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
|
|
269
|
+
// A pin commit is no turn; the unpin commit stays, as the one that closes the process.
|
|
270
|
+
const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
|
|
271
|
+
const parsed = allParsed.filter((m) => m.pin !== "add")
|
|
272
272
|
const first = parsed[0]
|
|
273
273
|
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
274
274
|
const head = history[history.length - 1]
|
|
@@ -281,10 +281,10 @@ const buildRun = (
|
|
|
281
281
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
282
282
|
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
283
283
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
284
|
+
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
284
285
|
marks: parsed.flatMap((m) => m.marks),
|
|
285
286
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
286
287
|
pinnedVars: { ...first?.vars },
|
|
287
|
-
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
288
288
|
headTurn: headTurnOf(head),
|
|
289
289
|
closingHash,
|
|
290
290
|
episode: {
|
|
@@ -320,6 +320,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
320
320
|
const parsed = parseCommitMessage(subject).parsed
|
|
321
321
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
322
322
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
323
|
+
// A pin commit's subject can name the first step; it bounds nothing.
|
|
324
|
+
if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
|
|
323
325
|
return parsed.to === def.initial
|
|
324
326
|
}
|
|
325
327
|
|
|
@@ -402,37 +404,6 @@ const computeProcessRun = (
|
|
|
402
404
|
runOf(history, locateEpisode(def, history)),
|
|
403
405
|
)
|
|
404
406
|
|
|
405
|
-
// A file matching its copy in the recording commit is unchanged: the hashes
|
|
406
|
-
// are taken before the commit's own hooks run, and a hook may reformat it.
|
|
407
|
-
const changedSinceStart = (
|
|
408
|
-
pinned: ProcessRun["pinnedConfig"],
|
|
409
|
-
current: ConfigFingerprint,
|
|
410
|
-
workspace: WorkspaceOps,
|
|
411
|
-
): readonly string[] => {
|
|
412
|
-
if (pinned === undefined) return []
|
|
413
|
-
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
414
|
-
const committed = workspace.readCommittedSync(path, pinned.commit)
|
|
415
|
-
return (
|
|
416
|
-
current[path] === undefined ||
|
|
417
|
-
committed === undefined ||
|
|
418
|
-
contentHash(committed) !== current[path]
|
|
419
|
-
)
|
|
420
|
-
})
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const warnConfigChanges = (
|
|
424
|
-
run: ProcessRun,
|
|
425
|
-
current: ConfigFingerprint,
|
|
426
|
-
workspace: WorkspaceOps,
|
|
427
|
-
): Effect.Effect<void, never, Narrator> =>
|
|
428
|
-
Effect.gen(function* () {
|
|
429
|
-
const changed = changedSinceStart(run.pinnedConfig, current, workspace)
|
|
430
|
-
if (changed.length === 0) return
|
|
431
|
-
yield* (yield* Narrator).warn(
|
|
432
|
-
`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
|
|
433
|
-
)
|
|
434
|
-
})
|
|
435
|
-
|
|
436
407
|
type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
|
|
437
408
|
|
|
438
409
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
@@ -849,13 +820,15 @@ interface ResolvedRest {
|
|
|
849
820
|
* before any mutation.
|
|
850
821
|
*/
|
|
851
822
|
interface Rest extends ResolvedRest {
|
|
823
|
+
/** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
|
|
824
|
+
readonly pin: PinChange | undefined
|
|
825
|
+
/** Whether HEAD's tree carries the pin. */
|
|
826
|
+
readonly pinned: boolean
|
|
852
827
|
readonly step: ReachedStep
|
|
853
828
|
readonly trace: readonly ReachedStep[]
|
|
854
829
|
readonly run: ProcessRun
|
|
855
830
|
readonly vars: Record<string, string>
|
|
856
831
|
readonly env: Record<string, string>
|
|
857
|
-
/** What a landing that starts the process records. */
|
|
858
|
-
readonly config: ConfigFingerprint
|
|
859
832
|
readonly changes: readonly PendingChange[]
|
|
860
833
|
readonly memory: string | undefined
|
|
861
834
|
readonly memoryResumed: boolean
|
|
@@ -985,13 +958,15 @@ export const restAt = (
|
|
|
985
958
|
// staying true; passing a pending tree here would make it serve a stale
|
|
986
959
|
// steering file on every cache hit.
|
|
987
960
|
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
961
|
+
const pinned = headHasPin(workspace, run)
|
|
962
|
+
const pin = pinBeatAt(outcome, run, def, pinned)
|
|
963
|
+
const resting = restingOn(outcome, pin)
|
|
964
|
+
if (resting === undefined) {
|
|
965
|
+
return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
|
|
991
966
|
}
|
|
992
|
-
const step =
|
|
993
|
-
|
|
994
|
-
|
|
967
|
+
const { reached, step } = resting
|
|
968
|
+
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
|
|
969
|
+
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
|
|
995
970
|
if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
|
|
996
971
|
return yield* Effect.fail(
|
|
997
972
|
new Error(
|
|
@@ -1006,24 +981,26 @@ export const restAt = (
|
|
|
1006
981
|
path: e.path,
|
|
1007
982
|
}))
|
|
1008
983
|
: []
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
984
|
+
// Only the current rest notices, so a command must read it once — a
|
|
985
|
+
// second read notices twice. An earlier ref (restore's tip) is not the
|
|
986
|
+
// process the command acts on.
|
|
987
|
+
if (ref === undefined && config.drift.length > 0)
|
|
988
|
+
yield* (yield* Narrator).warn(
|
|
989
|
+
`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
|
|
990
|
+
)
|
|
1015
991
|
return {
|
|
1016
992
|
def,
|
|
1017
993
|
workflowName: resolved.name,
|
|
1018
|
-
state
|
|
994
|
+
state,
|
|
1019
995
|
stepDef,
|
|
1020
|
-
actor:
|
|
996
|
+
actor: stepDef.actor,
|
|
997
|
+
pin,
|
|
998
|
+
pinned,
|
|
1021
999
|
step,
|
|
1022
|
-
trace:
|
|
1000
|
+
trace: reached.trace,
|
|
1023
1001
|
run,
|
|
1024
1002
|
vars,
|
|
1025
1003
|
env,
|
|
1026
|
-
config: configNow,
|
|
1027
1004
|
changes,
|
|
1028
1005
|
memory: memory.key,
|
|
1029
1006
|
memoryResumed: memory.resumed,
|
|
@@ -1035,6 +1012,66 @@ export const restAt = (
|
|
|
1035
1012
|
|
|
1036
1013
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1037
1014
|
|
|
1015
|
+
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
1016
|
+
const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
|
|
1017
|
+
if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
|
|
1018
|
+
if (outcome.kind !== "ended" || pin !== "remove") return undefined
|
|
1019
|
+
const step = outcome.trace.at(-1)
|
|
1020
|
+
return step === undefined ? undefined : { reached: outcome, step }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** A pin or unpin beat has its own script, no step's, and no agent memory. */
|
|
1024
|
+
const restFacts = (
|
|
1025
|
+
pin: PinChange | undefined,
|
|
1026
|
+
step: ReachedStep,
|
|
1027
|
+
trace: readonly ReachedStep[],
|
|
1028
|
+
run: ProcessRun,
|
|
1029
|
+
) =>
|
|
1030
|
+
pin === undefined
|
|
1031
|
+
? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
|
|
1032
|
+
: {
|
|
1033
|
+
stepDef: pinStepDef(pin),
|
|
1034
|
+
state: PIN_STATES[pin],
|
|
1035
|
+
memory: { key: undefined, resumed: false },
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
|
|
1039
|
+
const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
|
|
1040
|
+
return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
|
|
1044
|
+
run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* A started process pins first — again, should a turn have deleted the pin;
|
|
1048
|
+
* a pinned flow that ended unpins before the closing commit.
|
|
1049
|
+
*/
|
|
1050
|
+
const pinBeatAt = (
|
|
1051
|
+
outcome: ReplayOutcome,
|
|
1052
|
+
run: ProcessRun,
|
|
1053
|
+
def: WorkflowDefinition,
|
|
1054
|
+
pinned: boolean,
|
|
1055
|
+
): PinChange | undefined => {
|
|
1056
|
+
if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
|
|
1057
|
+
if (outcome.kind !== "rest" || pinned) return undefined
|
|
1058
|
+
return underwayAt(run, outcome.rest, def) ? "add" : undefined
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// A default-workflow process back at the first step has ended, as a returned flow has.
|
|
1062
|
+
const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
|
|
1063
|
+
outcome.kind === "ended" ||
|
|
1064
|
+
(outcome.kind === "rest" &&
|
|
1065
|
+
run.workflow === undefined &&
|
|
1066
|
+
outcome.rest.name === def.initial &&
|
|
1067
|
+
underwayAt(run, outcome.rest, def))
|
|
1068
|
+
|
|
1069
|
+
const pinStepDef = (pin: PinChange): StepDef => ({
|
|
1070
|
+
actor: "check",
|
|
1071
|
+
kind: "script",
|
|
1072
|
+
content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1038
1075
|
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1039
1076
|
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1040
1077
|
|
|
@@ -1094,6 +1131,7 @@ export interface RenderedRest extends RestHints {
|
|
|
1094
1131
|
readonly content: string
|
|
1095
1132
|
readonly memory?: string
|
|
1096
1133
|
readonly memoryResumed: boolean
|
|
1134
|
+
readonly pin?: PinChange
|
|
1097
1135
|
}
|
|
1098
1136
|
|
|
1099
1137
|
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
@@ -1105,6 +1143,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1105
1143
|
...rest.hints,
|
|
1106
1144
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1107
1145
|
memoryResumed: rest.memoryResumed,
|
|
1146
|
+
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
1108
1147
|
})
|
|
1109
1148
|
|
|
1110
1149
|
/**
|
|
@@ -1122,9 +1161,7 @@ export const stalledAt = (rest: Rest): boolean =>
|
|
|
1122
1161
|
|
|
1123
1162
|
/** 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
1163
|
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
1125
|
-
rest.
|
|
1126
|
-
rest.state === rest.def.initial &&
|
|
1127
|
-
rest.step.id.occurrence === 1
|
|
1164
|
+
rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
|
|
1128
1165
|
|
|
1129
1166
|
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
1130
1167
|
export const restIsIdle = (rest: Rest): boolean =>
|
|
@@ -1254,15 +1291,15 @@ const landingTo = (
|
|
|
1254
1291
|
if (refused !== undefined) return refused
|
|
1255
1292
|
// Landing on the first step while a process is underway ends it (flow
|
|
1256
1293
|
// return and `restart()` alike); the script then points the history ref at
|
|
1257
|
-
// that very commit.
|
|
1258
|
-
const finishes =
|
|
1294
|
+
// that very commit. A pinned process ends one beat later, at the unpin.
|
|
1295
|
+
const { finishes, landsAt } = endingOf(rest, to, underway)
|
|
1259
1296
|
return {
|
|
1260
1297
|
kind: "commit",
|
|
1261
|
-
to,
|
|
1298
|
+
to: landsAt,
|
|
1262
1299
|
spec: {
|
|
1263
1300
|
actor: rest.actor,
|
|
1264
1301
|
from: rest.state,
|
|
1265
|
-
to,
|
|
1302
|
+
to: landsAt,
|
|
1266
1303
|
step: rest.step.id,
|
|
1267
1304
|
...(marks.length > 0 && { marks }),
|
|
1268
1305
|
...approvedTrailer(rest, approver),
|
|
@@ -1274,8 +1311,54 @@ const landingTo = (
|
|
|
1274
1311
|
}
|
|
1275
1312
|
}
|
|
1276
1313
|
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1314
|
+
const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
|
|
1315
|
+
const ends = underway && to === rest.def.initial
|
|
1316
|
+
return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
|
|
1320
|
+
const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
|
|
1321
|
+
const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
|
|
1322
|
+
return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const pinCommit = (rest: Rest, change: string | undefined): Landing => {
|
|
1326
|
+
if (change === undefined || change === "D") return pinRefusal("add")
|
|
1327
|
+
const to = rest.step.name
|
|
1328
|
+
return {
|
|
1329
|
+
kind: "commit",
|
|
1330
|
+
to,
|
|
1331
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
|
|
1332
|
+
only: PIN_PATH,
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const unpinCommit = (
|
|
1337
|
+
rest: Rest,
|
|
1338
|
+
change: string | undefined,
|
|
1339
|
+
branch: Option.Option<string>,
|
|
1340
|
+
): Landing => {
|
|
1341
|
+
if (change !== "D" && rest.pinned) return pinRefusal("remove")
|
|
1342
|
+
const to = rest.def.initial
|
|
1343
|
+
return {
|
|
1344
|
+
kind: "commit",
|
|
1345
|
+
to,
|
|
1346
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
|
|
1347
|
+
keepHistory: processHistoryRef(rest.run.startHash),
|
|
1348
|
+
// A turn already deleted the pin: the closing commit has no path of its own.
|
|
1349
|
+
...(rest.pinned ? { only: PIN_PATH } : {}),
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const pinRefusal = (pin: PinChange): Landing => ({
|
|
1354
|
+
kind: "refusal",
|
|
1355
|
+
message:
|
|
1356
|
+
pin === "add"
|
|
1357
|
+
? `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\``
|
|
1358
|
+
: `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
const startTrailers = (rest: Rest, starts: boolean) => (starts ? { vars: rest.vars } : {})
|
|
1279
1362
|
|
|
1280
1363
|
const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
|
|
1281
1364
|
outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
|
|
@@ -1292,6 +1375,7 @@ const decideLanding = (
|
|
|
1292
1375
|
approver: Option.Option<AuthorIdent>,
|
|
1293
1376
|
): Effect.Effect<Landing, Error> =>
|
|
1294
1377
|
Effect.gen(function* () {
|
|
1378
|
+
if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
|
|
1295
1379
|
const unapproved = approvalRefusal(rest, approver)
|
|
1296
1380
|
if (unapproved !== undefined) return unapproved
|
|
1297
1381
|
const early = cleanLanding(rest)
|
|
@@ -1410,13 +1494,13 @@ export interface HistoryTrail {
|
|
|
1410
1494
|
readonly versionWarning?: string
|
|
1411
1495
|
}
|
|
1412
1496
|
|
|
1413
|
-
/**
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
): readonly string[] => {
|
|
1419
|
-
const paths = new Set<string>(
|
|
1497
|
+
/**
|
|
1498
|
+
* Repository-relative paths of today's config files; one outside the
|
|
1499
|
+
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1500
|
+
* config read needs no check: whichever file imported it differs too.
|
|
1501
|
+
*/
|
|
1502
|
+
const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
|
|
1503
|
+
const paths = new Set<string>()
|
|
1420
1504
|
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
|
|
1421
1505
|
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1422
1506
|
}
|
|
@@ -1436,16 +1520,11 @@ const differingConfigPaths = (
|
|
|
1436
1520
|
workspace: Workspace["Service"],
|
|
1437
1521
|
root: string,
|
|
1438
1522
|
config: { readonly fingerprint: ConfigFingerprint },
|
|
1439
|
-
run: ProcessRun,
|
|
1440
1523
|
hash: string,
|
|
1441
1524
|
) =>
|
|
1442
1525
|
Effect.gen(function* () {
|
|
1443
1526
|
const differing: string[] = []
|
|
1444
|
-
for (const path of inRepoConfigPaths(
|
|
1445
|
-
root,
|
|
1446
|
-
Object.keys(config.fingerprint),
|
|
1447
|
-
run.pinnedConfig?.files ?? {},
|
|
1448
|
-
)) {
|
|
1527
|
+
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
|
|
1449
1528
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1450
1529
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1451
1530
|
}
|
|
@@ -1531,7 +1610,7 @@ const replayFinishedProcess = (
|
|
|
1531
1610
|
// A refusal is often caused by the version change, so it carries the warning.
|
|
1532
1611
|
const withWarning = (err: Error): Error =>
|
|
1533
1612
|
versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
|
|
1534
|
-
const differing = yield* differingConfigPaths(git, workspace, host.root, config,
|
|
1613
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash)
|
|
1535
1614
|
if (differing.length > 0)
|
|
1536
1615
|
return yield* Effect.fail(
|
|
1537
1616
|
withWarning(configRefusal(how.command, hash, differing, how.configReason)),
|
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
|