@pmelab/gtd 27.2.0 → 27.4.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 +14 -1
- package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
- package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +24 -1
- package/dist/gtd.bundle.mjs +174 -23
- package/dist/testkit.mjs +327 -27
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +119 -6
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +146 -8
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +160 -0
- package/src/edge/Edge.ts +90 -8
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flows/helpers.ts +28 -0
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +33 -0
- package/src/testkit/EmittedScripts.test.ts +9 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/refactor.test.ts +466 -0
- package/src/workflows/feature/refactor.ts +178 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/access.ts +2 -1
- package/src/workflows/shared/health.test.ts +67 -3
- package/src/workflows/shared/health.ts +5 -3
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +6 -0
- package/src/workflows/shared/skills.ts +5 -1
- package/src/workflows/shared/text.ts +35 -0
- package/src/workflows/shared/vars.ts +1 -0
package/src/edge/Edge.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
GitService,
|
|
5
5
|
Host,
|
|
6
6
|
Workspace,
|
|
7
|
+
type AuthorIdent,
|
|
7
8
|
type GitOperations,
|
|
8
9
|
type HostOps,
|
|
9
10
|
type WorkspaceOps,
|
|
@@ -29,7 +30,9 @@ import {
|
|
|
29
30
|
} from "../workflow/index.js"
|
|
30
31
|
import {
|
|
31
32
|
formatFault,
|
|
33
|
+
formatStepId,
|
|
32
34
|
formatSubject,
|
|
35
|
+
isRecordableApproval,
|
|
33
36
|
parseCommitMessage,
|
|
34
37
|
replay,
|
|
35
38
|
treeFromRecord,
|
|
@@ -41,7 +44,14 @@ import {
|
|
|
41
44
|
} from "../replay/index.js"
|
|
42
45
|
import type { ScopeAccess } from "../flows/index.js"
|
|
43
46
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
judgeJson,
|
|
49
|
+
UNATTRIBUTED_MODEL,
|
|
50
|
+
type ApprovalEntry,
|
|
51
|
+
type ModelCost,
|
|
52
|
+
type StepAccess,
|
|
53
|
+
} from "../wire/index.js"
|
|
54
|
+
|
|
45
55
|
import { GTD_VERSION } from "../install/index.js"
|
|
46
56
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
47
57
|
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
@@ -141,6 +151,8 @@ export interface ProcessRun {
|
|
|
141
151
|
readonly trace: readonly TraceEntry[]
|
|
142
152
|
readonly costEntries: readonly CostEntry[]
|
|
143
153
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
154
|
+
/** Oldest first. */
|
|
155
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
144
156
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
145
157
|
readonly pinnedVars: Record<string, string>
|
|
146
158
|
/** Absent for a process started before recording existed. */
|
|
@@ -228,6 +240,21 @@ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
|
228
240
|
return undefined
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
const approvalEntryOf = (
|
|
244
|
+
hash: string,
|
|
245
|
+
message: ReturnType<typeof parseCommitMessage>,
|
|
246
|
+
): readonly ApprovalEntry[] =>
|
|
247
|
+
message.approved === undefined
|
|
248
|
+
? []
|
|
249
|
+
: [
|
|
250
|
+
{
|
|
251
|
+
step: formatStepId(message.approved.step),
|
|
252
|
+
name: message.approved.name,
|
|
253
|
+
email: message.approved.email,
|
|
254
|
+
commit: hash,
|
|
255
|
+
},
|
|
256
|
+
]
|
|
257
|
+
|
|
231
258
|
const buildRun = (
|
|
232
259
|
history: History,
|
|
233
260
|
location: EpisodeLocation,
|
|
@@ -247,6 +274,7 @@ const buildRun = (
|
|
|
247
274
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
248
275
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
249
276
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
277
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
250
278
|
pinnedVars: { ...first?.vars },
|
|
251
279
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
252
280
|
headTurn: headTurnOf(head),
|
|
@@ -729,7 +757,8 @@ const messageDef = (
|
|
|
729
757
|
...common,
|
|
730
758
|
kind: "message",
|
|
731
759
|
content: request.options.message ?? request.options.label ?? step.name,
|
|
732
|
-
...optional("acceptClean", request.options.acceptClean),
|
|
760
|
+
...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
761
|
+
...optional("approve", request.options.approve),
|
|
733
762
|
})
|
|
734
763
|
|
|
735
764
|
const stepDefOf = (step: ReachedStep): StepDef => {
|
|
@@ -933,6 +962,17 @@ const setupFor = (
|
|
|
933
962
|
return { resolved, def, vars, env, setup }
|
|
934
963
|
})
|
|
935
964
|
|
|
965
|
+
// Only an approval gate counts an index-only edit (one staged, then reverted
|
|
966
|
+
// in the worktree): its approval must cover exactly the judged diff. Every
|
|
967
|
+
// other rest keeps the worktree's view, so a net-zero turn stays an attempt.
|
|
968
|
+
const pendingChanges = (git: GitOperations, withIndex: boolean) =>
|
|
969
|
+
Effect.gen(function* () {
|
|
970
|
+
const worktree = yield* git.changedPaths()
|
|
971
|
+
if (!withIndex) return worktree
|
|
972
|
+
const seen = new Set(worktree.map((e) => e.path))
|
|
973
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
|
|
974
|
+
})
|
|
975
|
+
|
|
936
976
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
937
977
|
export const restAt = (
|
|
938
978
|
ref: string | undefined,
|
|
@@ -974,7 +1014,7 @@ export const restAt = (
|
|
|
974
1014
|
}
|
|
975
1015
|
const changes =
|
|
976
1016
|
ref === undefined
|
|
977
|
-
? (yield* git.
|
|
1017
|
+
? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
978
1018
|
status: normalizeStatus(e.status),
|
|
979
1019
|
path: e.path,
|
|
980
1020
|
}))
|
|
@@ -1107,6 +1147,32 @@ export const restIsIdle = (rest: Rest): boolean =>
|
|
|
1107
1147
|
|
|
1108
1148
|
const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
|
|
1109
1149
|
|
|
1150
|
+
// An approval covers exactly the judged diff, so any edit refuses it; the
|
|
1151
|
+
// approver is read only at such a rest, and must be a name a trailer can carry.
|
|
1152
|
+
const approvalRefusal = (rest: Rest, approver: Option.Option<AuthorIdent>): Landing | undefined => {
|
|
1153
|
+
if (rest.stepDef.approve !== true) return undefined
|
|
1154
|
+
const id = rest.step.id
|
|
1155
|
+
if (rest.changes.length > 0) {
|
|
1156
|
+
return {
|
|
1157
|
+
kind: "refusal",
|
|
1158
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``,
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (Option.isNone(approver)) {
|
|
1162
|
+
return {
|
|
1163
|
+
kind: "refusal",
|
|
1164
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`,
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (!isRecordableApproval({ step: id, ...approver.value })) {
|
|
1168
|
+
return {
|
|
1169
|
+
kind: "refusal",
|
|
1170
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`,
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return undefined
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1110
1176
|
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
1111
1177
|
const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
1112
1178
|
if (rest.changes.length > 0) return undefined
|
|
@@ -1182,7 +1248,15 @@ const keptTurn = (rest: Rest) =>
|
|
|
1182
1248
|
? { keepTurn: keptRef(rest.run.startHash, rest.state) }
|
|
1183
1249
|
: {}
|
|
1184
1250
|
|
|
1185
|
-
const
|
|
1251
|
+
const approvedTrailer = (rest: Rest, approver: Option.Option<AuthorIdent>) =>
|
|
1252
|
+
Option.isSome(approver) ? { approved: { step: rest.step.id, ...approver.value } } : {}
|
|
1253
|
+
|
|
1254
|
+
const landingTo = (
|
|
1255
|
+
rest: Rest,
|
|
1256
|
+
to: StateName,
|
|
1257
|
+
branch: Option.Option<string>,
|
|
1258
|
+
approver: Option.Option<AuthorIdent>,
|
|
1259
|
+
): Landing => {
|
|
1186
1260
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1187
1261
|
// The landing that leaves the flow's first step starts the process: it
|
|
1188
1262
|
// pins the process settings every later invocation reads back.
|
|
@@ -1202,6 +1276,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
|
|
|
1202
1276
|
from: rest.state,
|
|
1203
1277
|
to,
|
|
1204
1278
|
step: rest.step.id,
|
|
1279
|
+
...approvedTrailer(rest, approver),
|
|
1205
1280
|
...(starts ? { vars: rest.vars, config: rest.config } : {}),
|
|
1206
1281
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1207
1282
|
},
|
|
@@ -1219,8 +1294,11 @@ const decideLanding = (
|
|
|
1219
1294
|
rest: Rest,
|
|
1220
1295
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1221
1296
|
branch: Option.Option<string>,
|
|
1297
|
+
approver: Option.Option<AuthorIdent>,
|
|
1222
1298
|
): Effect.Effect<Landing, Error> =>
|
|
1223
1299
|
Effect.gen(function* () {
|
|
1300
|
+
const unapproved = approvalRefusal(rest, approver)
|
|
1301
|
+
if (unapproved !== undefined) return unapproved
|
|
1224
1302
|
const early = cleanLanding(rest)
|
|
1225
1303
|
if (early !== undefined) return early
|
|
1226
1304
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -1239,12 +1317,12 @@ const decideLanding = (
|
|
|
1239
1317
|
)
|
|
1240
1318
|
const to = yield* landingTarget(outcome, rest)
|
|
1241
1319
|
if (typeof to !== "string") return to
|
|
1242
|
-
return landingTo(rest, to, branch)
|
|
1320
|
+
return landingTo(rest, to, branch, approver)
|
|
1243
1321
|
})
|
|
1244
1322
|
|
|
1245
1323
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1246
1324
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1247
|
-
decideLanding(rest, undefined, Option.none()).pipe(
|
|
1325
|
+
decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
|
|
1248
1326
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1249
1327
|
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
1250
1328
|
)
|
|
@@ -1255,8 +1333,10 @@ export const snapshotFromRest = (
|
|
|
1255
1333
|
verdicts?: readonly JudgeVerdict[],
|
|
1256
1334
|
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1257
1335
|
Effect.gen(function* () {
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1336
|
+
const git = yield* GitService
|
|
1337
|
+
const branch = yield* git.currentBranch()
|
|
1338
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
|
|
1339
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver)
|
|
1260
1340
|
return {
|
|
1261
1341
|
stepDef: rest.stepDef,
|
|
1262
1342
|
state: rest.state,
|
|
@@ -1300,6 +1380,7 @@ export interface HistoryStep {
|
|
|
1300
1380
|
readonly from: StateName | undefined
|
|
1301
1381
|
readonly to: StateName
|
|
1302
1382
|
readonly verdicts: readonly JudgeVerdict[]
|
|
1383
|
+
readonly approval?: ApprovalEntry
|
|
1303
1384
|
}
|
|
1304
1385
|
|
|
1305
1386
|
export interface HistoryTrail {
|
|
@@ -1371,6 +1452,7 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
|
|
|
1371
1452
|
from: message.parsed?.from,
|
|
1372
1453
|
to: entry.state,
|
|
1373
1454
|
verdicts: message.judge,
|
|
1455
|
+
...optional("approval", approvalEntryOf(entry.hash, message)[0]),
|
|
1374
1456
|
}
|
|
1375
1457
|
})
|
|
1376
1458
|
}
|
|
@@ -7,6 +7,7 @@ import fc from "fast-check"
|
|
|
7
7
|
import {
|
|
8
8
|
commitAll,
|
|
9
9
|
commitAsIs,
|
|
10
|
+
commitClean,
|
|
10
11
|
deleteRef,
|
|
11
12
|
discardPending,
|
|
12
13
|
fetchHistoryRefs,
|
|
@@ -70,6 +71,7 @@ const runWithFakeGit = (script: string, binDir: string): { status: number | null
|
|
|
70
71
|
const allBuilders: ReadonlyArray<[string, string]> = [
|
|
71
72
|
["commitAll", commitAll("gtd(agent): working")],
|
|
72
73
|
["commitAsIs", commitAsIs("gtd(agent): working")],
|
|
74
|
+
["commitClean", commitClean("gtd(human): gate → again")],
|
|
73
75
|
["softResetTo", softResetTo("HEAD~1")],
|
|
74
76
|
["mixedResetTo", mixedResetTo("HEAD~1")],
|
|
75
77
|
["hardResetTo", hardResetTo("HEAD~1")],
|
|
@@ -104,6 +106,40 @@ describe("commitAsIs", () => {
|
|
|
104
106
|
})
|
|
105
107
|
})
|
|
106
108
|
|
|
109
|
+
describe("commitClean", () => {
|
|
110
|
+
const logGit = (body: string): { binDir: string; log: string } => {
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), "gtd-log-git-"))
|
|
112
|
+
const log = join(dir, "log")
|
|
113
|
+
const gitPath = join(dir, "git")
|
|
114
|
+
writeFileSync(gitPath, `#!/bin/sh\necho "$*" >> '${log}'\n${body}\n`)
|
|
115
|
+
chmodSync(gitPath, 0o755)
|
|
116
|
+
return { binDir: dir, log }
|
|
117
|
+
}
|
|
118
|
+
const readLog = (log: string): string[] =>
|
|
119
|
+
execFileSync("cat", [log], { encoding: "utf8" }).trim().split("\n")
|
|
120
|
+
const cases = `case "$1" in
|
|
121
|
+
rev-parse) echo head0 ;;
|
|
122
|
+
status) ;;
|
|
123
|
+
config) echo \${GTD_CFG:-false} ;;
|
|
124
|
+
commit-tree) echo commit0 ;;
|
|
125
|
+
esac`
|
|
126
|
+
|
|
127
|
+
it("a dirty tree exits 1 before any commit", () => {
|
|
128
|
+
const { binDir, counterFile } = withFakeGit(`echo head0`, `echo " M a.txt"`)
|
|
129
|
+
const result = runWithFakeGit(commitClean("msg"), binDir)
|
|
130
|
+
expect(result.status).toBe(1)
|
|
131
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("2")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("a refused update-ref (HEAD moved) exits 1; the write is guarded by the pinned HEAD", () => {
|
|
135
|
+
const { binDir, log } = logGit(`${cases}\nif [ "$1" = update-ref ]; then exit 1; fi`)
|
|
136
|
+
const result = runWithFakeGit(commitClean("msg"), binDir)
|
|
137
|
+
expect(result.status).toBe(1)
|
|
138
|
+
const lines = readLog(log)
|
|
139
|
+
expect(lines[lines.length - 1]).toBe("update-ref -m commit: msg HEAD commit0 head0")
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
107
143
|
describe("commitAllowEmpty retry discrimination (via commitAsIs)", () => {
|
|
108
144
|
it("retries without hooks on the hook's own empty-git-commit rejection", () => {
|
|
109
145
|
const { binDir, counterFile } = withFakeGit(
|
|
@@ -304,7 +340,7 @@ describe("keepTurn", () => {
|
|
|
304
340
|
` write-tree) echo tree0 ;;`,
|
|
305
341
|
` commit-tree) echo commit0 ;;`,
|
|
306
342
|
` config) echo false ;;`,
|
|
307
|
-
` update-ref) [ "$
|
|
343
|
+
` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
|
|
308
344
|
` *) exit 0 ;;`,
|
|
309
345
|
`esac`,
|
|
310
346
|
].join("\n"),
|
|
@@ -313,7 +349,7 @@ describe("keepTurn", () => {
|
|
|
313
349
|
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
314
350
|
expect(result.status).toBe(1)
|
|
315
351
|
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
316
|
-
expect(calls).toContain("update-ref HEAD commit0 head0")
|
|
352
|
+
expect(calls).toContain("update-ref -m commit: gtd(agent): done HEAD commit0 head0")
|
|
317
353
|
const resetAt = calls.indexOf("reset -q")
|
|
318
354
|
expect(resetAt).toBeGreaterThan(-1)
|
|
319
355
|
expect(calls.slice(resetAt + 1)).toEqual([
|
|
@@ -335,7 +371,7 @@ describe("keepTurn", () => {
|
|
|
335
371
|
` write-tree) echo tree0 ;;`,
|
|
336
372
|
` commit-tree) echo commit0 ;;`,
|
|
337
373
|
` config) echo false ;;`,
|
|
338
|
-
` update-ref) [ "$
|
|
374
|
+
` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
|
|
339
375
|
` reset) exit 1 ;;`,
|
|
340
376
|
` *) exit 0 ;;`,
|
|
341
377
|
`esac`,
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -24,10 +24,32 @@ const commitAllowEmpty = (message: string): string => {
|
|
|
24
24
|
].join("\n")
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export const CHANGED_SINCE_LAND =
|
|
28
|
+
"gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve"
|
|
29
|
+
|
|
27
30
|
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
28
31
|
export const commitAll = (message: string): string =>
|
|
29
32
|
`git add -A || exit 1\n${commitAllowEmpty(message)}`
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
36
|
+
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
37
|
+
* hook runs and none can add to it. A HEAD that moved fails the guarded
|
|
38
|
+
* `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
|
|
39
|
+
*/
|
|
40
|
+
export const commitClean = (message: string): string =>
|
|
41
|
+
[
|
|
42
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
43
|
+
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
44
|
+
` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
|
|
45
|
+
` exit 1`,
|
|
46
|
+
`fi`,
|
|
47
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
48
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
49
|
+
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
50
|
+
`git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`,
|
|
51
|
+
].join("\n")
|
|
52
|
+
|
|
31
53
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
32
54
|
export const commitAsIs = (message: string): string => commitAllowEmpty(message)
|
|
33
55
|
|
|
@@ -70,7 +92,7 @@ export const keepTurn = (refPrefix: string, message: string): string => {
|
|
|
70
92
|
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
71
93
|
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
72
94
|
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
73
|
-
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
95
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
74
96
|
` git reset -q && git update-ref -d ${keptRef}`,
|
|
75
97
|
` exit 1`,
|
|
76
98
|
`fi`,
|
|
@@ -129,6 +151,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
129
151
|
switch (write.kind) {
|
|
130
152
|
case "commitAll":
|
|
131
153
|
return commitAll(write.message)
|
|
154
|
+
case "commitClean":
|
|
155
|
+
return commitClean(write.message)
|
|
132
156
|
case "updateRef":
|
|
133
157
|
return updateRef(write.ref, write.target)
|
|
134
158
|
case "keepTurn":
|
package/src/emit/index.ts
CHANGED
package/src/flows/helpers.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
changes,
|
|
3
3
|
codeThreads,
|
|
4
4
|
head,
|
|
5
|
+
human,
|
|
5
6
|
judge,
|
|
6
7
|
matches,
|
|
7
8
|
openQuestions,
|
|
@@ -250,3 +251,30 @@ export const judgeDiffShape = async (
|
|
|
250
251
|
: "refactor-first"
|
|
251
252
|
return { choice, strays, unreadable: [], judgment }
|
|
252
253
|
}
|
|
254
|
+
|
|
255
|
+
const displayPath = (path: string): string =>
|
|
256
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
257
|
+
/[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path
|
|
258
|
+
|
|
259
|
+
const seamReport = (shape: DiffShapeResult): string => {
|
|
260
|
+
const unreadable = new Set(shape.unreadable)
|
|
261
|
+
const strays = shape.strays.map(
|
|
262
|
+
(path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`,
|
|
263
|
+
)
|
|
264
|
+
return [
|
|
265
|
+
"Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
|
|
266
|
+
"",
|
|
267
|
+
...strays,
|
|
268
|
+
"",
|
|
269
|
+
"Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`.",
|
|
270
|
+
].join("\n")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Rest at an approval gate `name` showing the seam report — only when
|
|
275
|
+
* `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
|
|
276
|
+
*/
|
|
277
|
+
export const seamGate = async (name: string, shape: DiffShapeResult): Promise<void> => {
|
|
278
|
+
if (shape.choice !== "refactor-first") return
|
|
279
|
+
await human(name, { message: seamReport(shape), approve: true })
|
|
280
|
+
}
|
package/src/flows/runtime.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface HumanOptions extends SteeringOptions {
|
|
|
39
39
|
* Without this a clean landing is a no-op: the gate waits for a change.
|
|
40
40
|
*/
|
|
41
41
|
readonly acceptClean?: boolean | undefined
|
|
42
|
+
/**
|
|
43
|
+
* A sign-off gate: only a clean landing completes it, and that commit
|
|
44
|
+
* carries `Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}`.
|
|
45
|
+
* A dirty landing is refused. Implies `acceptClean`. Cannot be combined with
|
|
46
|
+
* `mode` or `file`: an approval records exactly the judged tree.
|
|
47
|
+
*/
|
|
48
|
+
readonly approve?: boolean | undefined
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface RunOptions extends SteeringOptions {}
|
package/src/install/Install.ts
CHANGED
|
@@ -246,7 +246,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
246
246
|
(\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
|
|
247
247
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
248
248
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
249
|
-
\`cost\`, \`costByModel\`.
|
|
249
|
+
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
250
|
+
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
250
251
|
|
|
251
252
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
252
253
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "nod
|
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { NodeContext } from "@effect/platform-node"
|
|
6
|
-
import { Cause, Effect, Exit } from "effect"
|
|
6
|
+
import { Cause, Effect, Exit, Option } from "effect"
|
|
7
7
|
import { describe, expect, it } from "vitest"
|
|
8
8
|
import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
9
9
|
import { Host } from "./index.js"
|
|
@@ -140,3 +140,27 @@ describe("diffPaths [Live]", () => {
|
|
|
140
140
|
}
|
|
141
141
|
})
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
describe("authorIdent [Live]", () => {
|
|
145
|
+
it("is none when git has no author identity", async () => {
|
|
146
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-ident-")))
|
|
147
|
+
try {
|
|
148
|
+
const git = (...args: string[]) =>
|
|
149
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" })
|
|
150
|
+
git("init")
|
|
151
|
+
git("config", "user.useConfigOnly", "true")
|
|
152
|
+
git("config", "user.name", "")
|
|
153
|
+
git("config", "user.email", "")
|
|
154
|
+
const ident = await Effect.runPromise(
|
|
155
|
+
Effect.flatMap(GitService, (g) => g.authorIdent()).pipe(
|
|
156
|
+
Effect.provide(GitService.Live),
|
|
157
|
+
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
158
|
+
Effect.provide(NodeContext.layer),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
expect(Option.isNone(ident)).toBe(true)
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(root, { recursive: true, force: true })
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
package/src/platform/Git.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface GitReaderOperations {
|
|
|
94
94
|
readonly changedPaths: (
|
|
95
95
|
base?: string,
|
|
96
96
|
) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
|
|
97
|
+
/**
|
|
98
|
+
* The index vs `HEAD`, as `{path, status}` pairs — what a commit would carry
|
|
99
|
+
* that `changedPaths` misses when the worktree reverted a staged edit. Empty
|
|
100
|
+
* before the first commit.
|
|
101
|
+
*/
|
|
102
|
+
readonly stagedPaths: () => Effect.Effect<
|
|
103
|
+
ReadonlyArray<{ readonly path: string; readonly status: string }>,
|
|
104
|
+
Error
|
|
105
|
+
>
|
|
97
106
|
/**
|
|
98
107
|
* Paths whose content differs between the trees of `base` and `tip`: added,
|
|
99
108
|
* modified and deleted alike; a rename reports both paths (no rename
|
|
@@ -103,8 +112,12 @@ export interface GitReaderOperations {
|
|
|
103
112
|
* working tree. Fails when either side does not resolve.
|
|
104
113
|
*/
|
|
105
114
|
readonly diffPaths: (base: string, tip: string) => Effect.Effect<ReadonlyArray<string>, Error>
|
|
115
|
+
/** `git var GIT_AUTHOR_IDENT`'s name and email; `None` when git has no identity. */
|
|
116
|
+
readonly authorIdent: () => Effect.Effect<Option.Option<AuthorIdent>, Error>
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
export type AuthorIdent = { readonly name: string; readonly email: string }
|
|
120
|
+
|
|
108
121
|
export interface GitWriterOperations {
|
|
109
122
|
/**
|
|
110
123
|
* `git add -A` then commit. `--allow-empty` is load-bearing: the machine
|
|
@@ -406,6 +419,17 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
406
419
|
"--",
|
|
407
420
|
).pipe(Effect.map(splitNul)),
|
|
408
421
|
|
|
422
|
+
authorIdent: () =>
|
|
423
|
+
exec("git", "var", "GIT_AUTHOR_IDENT").pipe(
|
|
424
|
+
Effect.map((out) => {
|
|
425
|
+
const match = /^(.*?) <([^<>]*)> \d+ [+-]\d{4}$/.exec(out.trim())
|
|
426
|
+
return match === null || match[1] === "" || match[2] === ""
|
|
427
|
+
? Option.none()
|
|
428
|
+
: Option.some({ name: match[1]!, email: match[2]! })
|
|
429
|
+
}),
|
|
430
|
+
Effect.catchAll(() => Effect.succeed(Option.none())),
|
|
431
|
+
),
|
|
432
|
+
|
|
409
433
|
changedPaths: (base?: string) =>
|
|
410
434
|
Effect.gen(function* () {
|
|
411
435
|
const ref = base ?? "HEAD"
|
|
@@ -443,6 +467,12 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
443
467
|
return all
|
|
444
468
|
}),
|
|
445
469
|
|
|
470
|
+
stagedPaths: () =>
|
|
471
|
+
exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
|
|
472
|
+
Effect.catchAll(() => Effect.succeed("")),
|
|
473
|
+
Effect.map((out) => parseNameStatus(splitNul(out))),
|
|
474
|
+
),
|
|
475
|
+
|
|
446
476
|
resolveRef: (ref: string) =>
|
|
447
477
|
exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(
|
|
448
478
|
Effect.map((s) => s.trim()),
|
package/src/platform/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
type GitOperations,
|
|
16
16
|
type GitReaderOperations,
|
|
17
17
|
type GitWriterOperations,
|
|
18
|
+
type AuthorIdent,
|
|
18
19
|
} from "./Git.js"
|
|
19
20
|
export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
|
|
20
21
|
export { CommandRunner } from "./CommandRunner.js"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { formatFault } from "./
|
|
2
|
+
import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
5
|
it("accepts a history entirely in the current format", () => {
|
|
@@ -18,3 +18,18 @@ describe("the history format", () => {
|
|
|
18
18
|
)
|
|
19
19
|
})
|
|
20
20
|
})
|
|
21
|
+
|
|
22
|
+
describe("Gtd-Approved and the history format", () => {
|
|
23
|
+
it("leaves Gtd-Format at 1 on a message carrying it", () => {
|
|
24
|
+
const message = formatCommitMessage({
|
|
25
|
+
actor: "human",
|
|
26
|
+
to: "b",
|
|
27
|
+
approved: { step: { name: "seam", occurrence: 1 }, name: "Test", email: "test@test.com" },
|
|
28
|
+
})
|
|
29
|
+
expect(message).toContain(
|
|
30
|
+
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
31
|
+
)
|
|
32
|
+
expect(message).toContain("Gtd-Format: 1")
|
|
33
|
+
expect(parseCommitMessage(message).format).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -1076,3 +1076,94 @@ describe("replay: access resolution", () => {
|
|
|
1076
1076
|
expect(outcome.kind === "failed" && outcome.message).toContain('scope "a"')
|
|
1077
1077
|
})
|
|
1078
1078
|
})
|
|
1079
|
+
|
|
1080
|
+
describe("human({ approve })", () => {
|
|
1081
|
+
const gate: Flow = async () => {
|
|
1082
|
+
await human("gate", { approve: true })
|
|
1083
|
+
await human("after")
|
|
1084
|
+
}
|
|
1085
|
+
const sign = (step: string, occurrence: number) => ({
|
|
1086
|
+
step: { name: step, occurrence },
|
|
1087
|
+
name: "Test",
|
|
1088
|
+
email: "test@test.com",
|
|
1089
|
+
})
|
|
1090
|
+
const approval = (approved?: ReturnType<typeof sign>): string =>
|
|
1091
|
+
formatCommitMessage({
|
|
1092
|
+
actor: "human",
|
|
1093
|
+
from: "gate",
|
|
1094
|
+
to: "after",
|
|
1095
|
+
step: { name: "gate", occurrence: 1 },
|
|
1096
|
+
...(approved !== undefined ? { approved } : {}),
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it("is accepted: no unknown-option failure, the gate is the rest", async () => {
|
|
1100
|
+
const outcome = await replayOf(gate, new History())
|
|
1101
|
+
expect(outcome.kind).toBe("rest")
|
|
1102
|
+
if (outcome.kind === "rest") expect(outcome.rest.name).toBe("gate")
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it("consumes a commit carrying a matching Gtd-Approved and continues past the gate", async () => {
|
|
1106
|
+
const h = new History().raw(approval(sign("gate", 1)))
|
|
1107
|
+
const outcome = await replayOf(gate, h)
|
|
1108
|
+
expect(outcome.kind).toBe("rest")
|
|
1109
|
+
if (outcome.kind === "rest") expect(outcome.rest.name).toBe("after")
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
it("diverges on a commit without Gtd-Approved", async () => {
|
|
1113
|
+
const outcome = await replayOf(gate, new History().raw(approval()))
|
|
1114
|
+
expect(outcome).toEqual({
|
|
1115
|
+
kind: "divergence",
|
|
1116
|
+
message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
|
|
1117
|
+
})
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
it("diverges on a Gtd-Approved naming another step, or another occurrence", async () => {
|
|
1121
|
+
for (const other of [sign("elsewhere", 1), sign("gate", 2)]) {
|
|
1122
|
+
const outcome = await replayOf(gate, new History().raw(approval(other)))
|
|
1123
|
+
expect(outcome.kind).toBe("divergence")
|
|
1124
|
+
expect(outcome.kind === "divergence" && outcome.message).toContain(
|
|
1125
|
+
'completes approval gate "gate#1" but records no Gtd-Approved for it',
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
describe("refuses mode and file", () => {
|
|
1131
|
+
const tail =
|
|
1132
|
+
"in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing"
|
|
1133
|
+
const outcomeOf = async (options: Record<string, unknown>) =>
|
|
1134
|
+
replayOf(async () => {
|
|
1135
|
+
await human("gate", options as never)
|
|
1136
|
+
}, new History())
|
|
1137
|
+
|
|
1138
|
+
it.each([
|
|
1139
|
+
[{ approve: true, mode: "review" }, "mode"],
|
|
1140
|
+
[{ approve: true, file: ".gtd/REVIEW.md" }, "file"],
|
|
1141
|
+
[{ approve: true, mode: "review", file: ".gtd/REVIEW.md" }, "mode, file"],
|
|
1142
|
+
])("fails on %j naming %s", async (options, keys) => {
|
|
1143
|
+
expect(await outcomeOf(options)).toEqual({
|
|
1144
|
+
kind: "failed",
|
|
1145
|
+
message: `gtd: step "gate": approve cannot be combined with ${keys} ${tail}`,
|
|
1146
|
+
})
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
it("replays approve with label or base, and approve: false with mode", async () => {
|
|
1150
|
+
for (const options of [
|
|
1151
|
+
{ approve: true, label: "x" },
|
|
1152
|
+
{ approve: true, base: "start" },
|
|
1153
|
+
{ approve: false, mode: "review" },
|
|
1154
|
+
{ approve: true, mode: undefined },
|
|
1155
|
+
]) {
|
|
1156
|
+
expect((await outcomeOf(options)).kind).toBe("rest")
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
it("does not demand Gtd-Approved at a plain human step", async () => {
|
|
1162
|
+
const h = new History().land("human", "plain", 1, "after")
|
|
1163
|
+
const outcome = await replayOf(async () => {
|
|
1164
|
+
await human("plain")
|
|
1165
|
+
await human("after")
|
|
1166
|
+
}, h)
|
|
1167
|
+
expect(outcome.kind).toBe("rest")
|
|
1168
|
+
})
|
|
1169
|
+
})
|