@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/replay/Replay.ts
CHANGED
|
@@ -236,7 +236,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
|
236
236
|
// type check, so a misspelt key would otherwise be silently ignored.
|
|
237
237
|
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
238
238
|
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
|
|
239
|
-
human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
|
|
239
|
+
human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
|
|
240
240
|
run: new Set(STEERING_OPTIONS),
|
|
241
241
|
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
242
242
|
}
|
|
@@ -249,6 +249,14 @@ const unknownOptions = (step: ReachedStep): string | undefined => {
|
|
|
249
249
|
return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
const approveConflict = (step: ReachedStep): string | undefined => {
|
|
253
|
+
const request = step.request
|
|
254
|
+
if (request.kind !== "human" || request.options.approve !== true) return undefined
|
|
255
|
+
const keys = (["mode", "file"] as const).filter((key) => request.options[key] !== undefined)
|
|
256
|
+
if (keys.length === 0) return undefined
|
|
257
|
+
return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`
|
|
258
|
+
}
|
|
259
|
+
|
|
252
260
|
export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
253
261
|
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
254
262
|
...c,
|
|
@@ -485,10 +493,20 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
485
493
|
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
|
|
486
494
|
}
|
|
487
495
|
}
|
|
488
|
-
const error = unknownOptions(step) ?? checkIdentity(step)
|
|
496
|
+
const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step)
|
|
489
497
|
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
490
498
|
}
|
|
491
499
|
|
|
500
|
+
const missingApproval = (step: ReachedStep, commit: ParsedCommit, expected: string) => {
|
|
501
|
+
if (step.request.kind !== "human" || step.request.options.approve !== true) return undefined
|
|
502
|
+
const approved = commit.parsed.approved
|
|
503
|
+
if (approved !== undefined && formatStepId(approved.step) === expected) return undefined
|
|
504
|
+
return {
|
|
505
|
+
kind: "divergence" as const,
|
|
506
|
+
message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`,
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
|
|
493
511
|
const recorded = commit.parsed.step
|
|
494
512
|
const expected = formatStepId(step.id)
|
|
@@ -500,6 +518,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
500
518
|
message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`,
|
|
501
519
|
})
|
|
502
520
|
}
|
|
521
|
+
const unapproved = missingApproval(step, commit, expected)
|
|
522
|
+
if (unapproved !== undefined) return finish(unapproved)
|
|
503
523
|
cursor++
|
|
504
524
|
advance(commit)
|
|
505
525
|
if (commit.parsed.kept !== undefined) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
formatCommitMessage,
|
|
4
|
+
isRecordableApproval,
|
|
5
|
+
parseCommitMessage,
|
|
6
|
+
parseSubject,
|
|
7
|
+
} from "./Trailers.js"
|
|
3
8
|
|
|
4
9
|
describe("the commit-message codec", () => {
|
|
5
10
|
it("round-trips a step commit's subject and trailers", () => {
|
|
@@ -173,3 +178,107 @@ describe("Gtd-Kept", () => {
|
|
|
173
178
|
expect(parsed.format).toBe(1)
|
|
174
179
|
})
|
|
175
180
|
})
|
|
181
|
+
|
|
182
|
+
describe("Gtd-Approved", () => {
|
|
183
|
+
const approved = {
|
|
184
|
+
step: { name: "seam", occurrence: 2 },
|
|
185
|
+
name: "Ada Lovelace",
|
|
186
|
+
email: "ada@x.io",
|
|
187
|
+
}
|
|
188
|
+
const line = (value: string) => `gtd(human): a → b\n\nGtd-Approved: ${value}`
|
|
189
|
+
|
|
190
|
+
it("is written as one JSON line between Gtd-Judge and Gtd-Branch and round-trips", () => {
|
|
191
|
+
const message = formatCommitMessage({
|
|
192
|
+
actor: "human",
|
|
193
|
+
to: "next",
|
|
194
|
+
from: "seam",
|
|
195
|
+
step: { name: "seam", occurrence: 2 },
|
|
196
|
+
judge: [{ id: "v", answer: "yes", p: 1 }],
|
|
197
|
+
branch: "feat",
|
|
198
|
+
approved,
|
|
199
|
+
})
|
|
200
|
+
const lines = message.split("\n")
|
|
201
|
+
const at = lines.indexOf(
|
|
202
|
+
'Gtd-Approved: {"step":"seam#2","name":"Ada Lovelace","email":"ada@x.io"}',
|
|
203
|
+
)
|
|
204
|
+
expect(at).toBeGreaterThan(-1)
|
|
205
|
+
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
206
|
+
expect(lines[at + 1]).toBe("Gtd-Branch: feat")
|
|
207
|
+
expect(parseCommitMessage(message).approved).toEqual(approved)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it.each([
|
|
211
|
+
["a plain step", "seam"],
|
|
212
|
+
["a step with a space and hash", "phase#1 review"],
|
|
213
|
+
["a step with angle brackets", "a <b> c"],
|
|
214
|
+
["a scoped step", "pkg.seam"],
|
|
215
|
+
["a quote/backslash approver name", "seam", 'Ada "A" \\ L'],
|
|
216
|
+
])("round-trips %s", (_label, stepName, approver = "Ada") => {
|
|
217
|
+
const a = { step: { name: stepName, occurrence: 3 }, name: approver, email: "a@x.io" }
|
|
218
|
+
expect(
|
|
219
|
+
parseCommitMessage(formatCommitMessage({ actor: "human", to: "b", approved: a })).approved,
|
|
220
|
+
).toEqual(a)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("reads undefined for the old shape, non-objects and bad fields", () => {
|
|
224
|
+
for (const value of [
|
|
225
|
+
"seam#1 Ada <a@x.io>",
|
|
226
|
+
"",
|
|
227
|
+
"[]",
|
|
228
|
+
'"x"',
|
|
229
|
+
"null",
|
|
230
|
+
'{"step":"seam#1","name":"Ada"}',
|
|
231
|
+
'{"step":"seam#1","name":"Ada","email":1}',
|
|
232
|
+
'{"step":"seam","name":"Ada","email":"a@x.io"}',
|
|
233
|
+
'{"step":"seam#0","name":"Ada","email":"a@x.io"}',
|
|
234
|
+
]) {
|
|
235
|
+
expect(parseCommitMessage(line(value)).approved).toBe(undefined)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("reads undefined for a control character in any field", () => {
|
|
240
|
+
for (const json of [
|
|
241
|
+
'{"step":"se\\u001bam#1","name":"Ada","email":"a@x.io"}',
|
|
242
|
+
'{"step":"seam#1","name":"Ada\\u0007","email":"a@x.io"}',
|
|
243
|
+
'{"step":"seam#1","name":"Ada","email":"a\\u0085@x.io"}',
|
|
244
|
+
]) {
|
|
245
|
+
expect(parseCommitMessage(line(json)).approved).toBe(undefined)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("reads undefined when absent", () => {
|
|
250
|
+
expect(parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" })).approved).toBe(
|
|
251
|
+
undefined,
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe("isRecordableApproval", () => {
|
|
257
|
+
const of = (name: string, email: string, occurrence = 1) => ({
|
|
258
|
+
step: { name: "gate", occurrence },
|
|
259
|
+
name,
|
|
260
|
+
email,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it("accepts ordinary, quoted, backslashed and angle-bracketed identities", () => {
|
|
264
|
+
for (const name of ["Ada", 'A "B"', "A\\B", "A <B>"]) {
|
|
265
|
+
expect(isRecordableApproval(of(name, "a@x.io"))).toBe(true)
|
|
266
|
+
}
|
|
267
|
+
expect(isRecordableApproval(of("Ada", "a<b>@x.io"))).toBe(true)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("refuses a C1 control character in name or email", () => {
|
|
271
|
+
expect(isRecordableApproval(of("Ada\u0085", "a@x.io"))).toBe(false)
|
|
272
|
+
expect(isRecordableApproval(of("Ada", "a\u0085@x.io"))).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("refuses a JS line terminator that would split the trailer on read", () => {
|
|
276
|
+
const name = "x\u2028Gtd-Branch: forged\u2028"
|
|
277
|
+
expect(isRecordableApproval(of(name, "a@x.io"))).toBe(false)
|
|
278
|
+
expect(isRecordableApproval(of("Ada", "a\u2029@x.io"))).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("refuses an occurrence that does not read back", () => {
|
|
282
|
+
expect(isRecordableApproval(of("Ada", "a@x.io", 0))).toBe(false)
|
|
283
|
+
})
|
|
284
|
+
})
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface CostEntry {
|
|
|
60
60
|
readonly model: string | undefined
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export interface Approval {
|
|
64
|
+
readonly step: StepId
|
|
65
|
+
readonly name: string
|
|
66
|
+
readonly email: string
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
export interface CommitMessage {
|
|
64
70
|
readonly subject: string
|
|
65
71
|
readonly parsed: ParsedSubject | undefined
|
|
@@ -77,6 +83,8 @@ export interface CommitMessage {
|
|
|
77
83
|
readonly version: string | undefined
|
|
78
84
|
/** The off-branch tip a keep step set aside; a non-hash value is skipped. */
|
|
79
85
|
readonly kept: string | undefined
|
|
86
|
+
/** `Gtd-Approved`; a malformed or control-character value is skipped. */
|
|
87
|
+
readonly approved: Approval | undefined
|
|
80
88
|
readonly format: number
|
|
81
89
|
}
|
|
82
90
|
|
|
@@ -106,7 +114,7 @@ const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
|
|
|
106
114
|
// JSON string. A control character is refused on read: history written by
|
|
107
115
|
// someone else must never put escape sequences on a terminal.
|
|
108
116
|
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
109
|
-
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
|
|
117
|
+
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
|
|
110
118
|
const encodePath = (path: string): string =>
|
|
111
119
|
path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
|
|
112
120
|
? JSON.stringify(path)
|
|
@@ -115,6 +123,24 @@ const decodePath = (raw: string): string | undefined => {
|
|
|
115
123
|
const path = raw.startsWith('"') ? parseJson(raw) : raw
|
|
116
124
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
117
125
|
}
|
|
126
|
+
const decodeApproval = (raw: string): Approval | undefined => {
|
|
127
|
+
const parsed = parseJson(raw)
|
|
128
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
129
|
+
const { step, name, email } = parsed as Record<string, unknown>
|
|
130
|
+
if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string")
|
|
131
|
+
return undefined
|
|
132
|
+
if ([step, name, email].some((field) => CONTROL_CHARACTER.test(field))) return undefined
|
|
133
|
+
const id = parseStepId(step)
|
|
134
|
+
return id === undefined ? undefined : { step: id, name, email }
|
|
135
|
+
}
|
|
136
|
+
const encodeApproval = ({ step, name, email }: Approval): string =>
|
|
137
|
+
JSON.stringify({ step: formatStepId(step), name, email })
|
|
138
|
+
// The land-time refusal relies on this: an approval that would not read back
|
|
139
|
+
// unchanged must never be written.
|
|
140
|
+
export const isRecordableApproval = (approval: Approval): boolean => {
|
|
141
|
+
const back = decodeApproval(encodeApproval(approval))
|
|
142
|
+
return back !== undefined && encodeApproval(back) === encodeApproval(approval)
|
|
143
|
+
}
|
|
118
144
|
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
119
145
|
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
120
146
|
|
|
@@ -125,6 +151,7 @@ interface Trailers {
|
|
|
125
151
|
branch: string | undefined
|
|
126
152
|
version: string | undefined
|
|
127
153
|
kept: string | undefined
|
|
154
|
+
approved: Approval | undefined
|
|
128
155
|
readonly judge: JudgeVerdict[]
|
|
129
156
|
readonly vars: Record<string, string>
|
|
130
157
|
readonly config: Record<string, string>
|
|
@@ -164,6 +191,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
164
191
|
"Gtd-Kept": (value, into) => {
|
|
165
192
|
if (HASH_RE.test(value)) into.kept ??= value
|
|
166
193
|
},
|
|
194
|
+
"Gtd-Approved": (value, into) => {
|
|
195
|
+
into.approved ??= decodeApproval(value)
|
|
196
|
+
},
|
|
167
197
|
"Gtd-Format": (value, into) => {
|
|
168
198
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
169
199
|
},
|
|
@@ -183,6 +213,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
183
213
|
branch: undefined,
|
|
184
214
|
version: undefined,
|
|
185
215
|
kept: undefined,
|
|
216
|
+
approved: undefined,
|
|
186
217
|
judge: [],
|
|
187
218
|
vars: {},
|
|
188
219
|
config: {},
|
|
@@ -208,6 +239,8 @@ export interface CommitSpec {
|
|
|
208
239
|
readonly config?: Readonly<Record<string, string>>
|
|
209
240
|
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
210
241
|
readonly judge?: readonly JudgeVerdict[]
|
|
242
|
+
/** Written after `Gtd-Judge`, before `Gtd-Branch`. */
|
|
243
|
+
readonly approved?: Approval
|
|
211
244
|
readonly branch?: string
|
|
212
245
|
readonly version?: string
|
|
213
246
|
}
|
|
@@ -230,6 +263,9 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
230
263
|
)
|
|
231
264
|
}
|
|
232
265
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
|
|
266
|
+
if (spec.approved !== undefined) {
|
|
267
|
+
lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
|
|
268
|
+
}
|
|
233
269
|
if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
|
|
234
270
|
if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
|
|
235
271
|
lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
|
package/src/replay/index.ts
CHANGED
|
@@ -2,7 +2,9 @@ export { replay, type EpisodeCommit, type ReachedStep, type ReplayOutcome } from
|
|
|
2
2
|
export { treeFromRecord, type TreeView } from "./Tree.js"
|
|
3
3
|
export {
|
|
4
4
|
formatCommitMessage,
|
|
5
|
+
formatStepId,
|
|
5
6
|
formatSubject,
|
|
7
|
+
isRecordableApproval,
|
|
6
8
|
parseCommitMessage,
|
|
7
9
|
type CommitSpec,
|
|
8
10
|
type JudgeVerdict,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { human, seamGate, type DiffShape, type DiffShapeResult, type Flow } from "../flows/index.js"
|
|
3
|
+
import { replay, treeFromRecord } from "./index.js"
|
|
4
|
+
|
|
5
|
+
// Lives in replay, which drives a flow to its rest (flows tests may not import replay).
|
|
6
|
+
const shape = (
|
|
7
|
+
choice: DiffShape,
|
|
8
|
+
strays: string[] = [],
|
|
9
|
+
unreadable: string[] = [],
|
|
10
|
+
): DiffShapeResult => ({ choice, strays, unreadable, judgment: undefined })
|
|
11
|
+
|
|
12
|
+
const restOf = async (result: DiffShapeResult) => {
|
|
13
|
+
const flow: Flow = async () => {
|
|
14
|
+
await seamGate("seam", result)
|
|
15
|
+
await human("next")
|
|
16
|
+
}
|
|
17
|
+
const outcome = await replay({
|
|
18
|
+
flow,
|
|
19
|
+
episode: { base: { hash: "0".repeat(40), tree: treeFromRecord({}) }, commits: [] },
|
|
20
|
+
vars: {},
|
|
21
|
+
env: {},
|
|
22
|
+
start: "0".repeat(40),
|
|
23
|
+
budgetBytes: 1024,
|
|
24
|
+
})
|
|
25
|
+
if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${outcome.kind}`)
|
|
26
|
+
return outcome.rest
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("seamGate", () => {
|
|
30
|
+
for (const choice of ["land", "reject"] as const) {
|
|
31
|
+
it(`reaches no step on ${choice}: the flow's next step is the rest`, async () => {
|
|
32
|
+
const rest = await restOf(shape(choice, ["a.ts"]))
|
|
33
|
+
expect(rest.name).toBe("next")
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("rests at an approve human step named after the caller on refactor-first", async () => {
|
|
38
|
+
const rest = await restOf(shape("refactor-first", ["a.ts"]))
|
|
39
|
+
expect(rest.name).toBe("seam")
|
|
40
|
+
expect(rest.request).toMatchObject({ kind: "human", options: { approve: true } })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("lists every stray in order, marks only the unreadable, names gtd land and gtd abandon", async () => {
|
|
44
|
+
const rest = await restOf(shape("refactor-first", ["a.ts", "b/c.ts", "z.bin"], ["b/c.ts"]))
|
|
45
|
+
if (rest.request.kind !== "human") throw new Error("expected a human step")
|
|
46
|
+
const message = rest.request.options.message ?? ""
|
|
47
|
+
const lines = message.split("\n")
|
|
48
|
+
const strays = lines.filter((line) => line.startsWith("- "))
|
|
49
|
+
expect(strays).toEqual(["- a.ts", "- b/c.ts (unreadable — not judged)", "- z.bin"])
|
|
50
|
+
expect(message).toMatch(/`gtd land`[^\n]*approve/)
|
|
51
|
+
expect(message).toContain("`gtd abandon`")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("quotes a stray path with a control character", async () => {
|
|
55
|
+
const rest = await restOf(shape("refactor-first", ["a\nb", "c\u001b[2K"]))
|
|
56
|
+
if (rest.request.kind !== "human") throw new Error("expected a human step")
|
|
57
|
+
const strays = (rest.request.options.message ?? "")
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter((line) => line.startsWith("- "))
|
|
60
|
+
expect(strays).toEqual(['- "a\\nb"', '- "c\\u001b[2K"'])
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/step/LandStep.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { StateName } from "../workflow/index.js"
|
|
|
8
8
|
*/
|
|
9
9
|
export type GitWrite =
|
|
10
10
|
| { readonly kind: "commitAll"; readonly message: string }
|
|
11
|
+
| { readonly kind: "commitClean"; readonly message: string }
|
|
11
12
|
| { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
|
|
12
13
|
| { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
|
|
13
14
|
|
|
@@ -131,6 +131,38 @@ describe("planStep — keepHistory", () => {
|
|
|
131
131
|
})
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
describe("planStep — approval landing", () => {
|
|
135
|
+
const approved = { step: { name: "gate", occurrence: 1 }, name: "Ada", email: "ada@x.io" }
|
|
136
|
+
|
|
137
|
+
it("commits with commitClean when the landing records an approval", () => {
|
|
138
|
+
const base = commitTo("gate", "again")
|
|
139
|
+
const s = snapshot({
|
|
140
|
+
state: "gate",
|
|
141
|
+
stepDef: {},
|
|
142
|
+
landing: { ...base, spec: { ...base.spec, approved } },
|
|
143
|
+
})
|
|
144
|
+
const outcome = planStep(s)
|
|
145
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
146
|
+
expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitClean" } })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("a finishing approval still points the history ref after the commitClean write", () => {
|
|
150
|
+
const base = commitTo("gate", "idle")
|
|
151
|
+
const s = snapshot({
|
|
152
|
+
state: "gate",
|
|
153
|
+
stepDef: {},
|
|
154
|
+
landing: { ...base, spec: { ...base.spec, approved }, keepHistory: "refs/gtd/history/abc" },
|
|
155
|
+
})
|
|
156
|
+
const outcome = planStep(s)
|
|
157
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
158
|
+
expect(outcome.steps.map((x) => (x.kind === "gitWrite" ? x.write.kind : x.kind))).toEqual([
|
|
159
|
+
"commitClean",
|
|
160
|
+
"updateRef",
|
|
161
|
+
"outcome",
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
134
166
|
describe("planStep — keepTurn", () => {
|
|
135
167
|
const kept = "refs/gtd/kept/abc/building"
|
|
136
168
|
|
package/src/step/planStep.ts
CHANGED
|
@@ -87,7 +87,9 @@ export const planStep = (
|
|
|
87
87
|
write:
|
|
88
88
|
landing.keepTurn !== undefined
|
|
89
89
|
? { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
90
|
-
:
|
|
90
|
+
: landing.spec.approved !== undefined
|
|
91
|
+
? { kind: "commitClean", message }
|
|
92
|
+
: { kind: "commitAll", message },
|
|
91
93
|
},
|
|
92
94
|
...(landing.keepHistory !== undefined
|
|
93
95
|
? [
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import {
|
|
3
3
|
commitAll,
|
|
4
4
|
commitAsIs,
|
|
5
|
+
commitClean,
|
|
5
6
|
deleteRef,
|
|
6
7
|
discardPending,
|
|
7
8
|
hardResetTo,
|
|
@@ -664,3 +665,29 @@ describe("applyEmittedScript — keepTurn", () => {
|
|
|
664
665
|
expect(repo.resolveRef(REF)).toBeNull()
|
|
665
666
|
})
|
|
666
667
|
})
|
|
668
|
+
|
|
669
|
+
describe("applyEmittedScript — commitClean", () => {
|
|
670
|
+
const MESSAGE = 'gtd(human): gate → again\n\nGtd-Approved: {"step":"gate#1"}'
|
|
671
|
+
|
|
672
|
+
it("applies the rendered block as an empty commit over HEAD", () => {
|
|
673
|
+
const repo = new InMemRepo()
|
|
674
|
+
repo.writeFile("base.txt", "0")
|
|
675
|
+
repo.seedCommit("chore: first")
|
|
676
|
+
const result = applyEmittedScript(repo, NO_COMMANDS, commitClean(MESSAGE))
|
|
677
|
+
expect(result).toMatchObject({ ok: true })
|
|
678
|
+
expect(repo.lastCommitMessage()).toBe(MESSAGE)
|
|
679
|
+
expect(repo.statusPorcelain()).toBe("")
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it("fails loudly on a one-character near-miss and applies nothing", () => {
|
|
683
|
+
const repo = new InMemRepo()
|
|
684
|
+
repo.writeFile("base.txt", "0")
|
|
685
|
+
repo.seedCommit("chore: first")
|
|
686
|
+
const before = snapshot(repo)
|
|
687
|
+
const nearMiss = commitClean(MESSAGE).replace("update-ref -m", "update-ref -n")
|
|
688
|
+
const result = applyEmittedScript(repo, NO_COMMANDS, nearMiss)
|
|
689
|
+
expect(result.ok).toBe(false)
|
|
690
|
+
expect(result.error).toContain("unrecognized script block")
|
|
691
|
+
expect(snapshot(repo)).toEqual(before)
|
|
692
|
+
})
|
|
693
|
+
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
commitAll,
|
|
8
8
|
commitAsIs,
|
|
9
|
+
commitClean,
|
|
9
10
|
deleteRef,
|
|
10
11
|
discardPending,
|
|
11
12
|
hardResetTo,
|
|
@@ -185,6 +186,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
185
186
|
return undefined
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
|
|
190
|
+
const [message] = extractQuotedTokens(block.slice(block.indexOf('"$gtd_head" -m ')))
|
|
191
|
+
if (message !== undefined && commitClean(message) === block) {
|
|
192
|
+
const failure = repo.commitClean(message)
|
|
193
|
+
return failure === undefined
|
|
194
|
+
? { kind: "applied" }
|
|
195
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
189
201
|
const [message] = extractQuotedTokens(conditionLine)
|
|
190
202
|
if (message !== undefined && commitAsIs(message) === block) {
|
|
@@ -667,6 +679,26 @@ const recognizeDeferNotes = (repo: InMemRepo, block: string): BlockOutcome | und
|
|
|
667
679
|
return { kind: "applied" }
|
|
668
680
|
}
|
|
669
681
|
|
|
682
|
+
/** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
|
|
683
|
+
const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
684
|
+
const [mkdir, printf, ...rest] = block.split("\n")
|
|
685
|
+
if (
|
|
686
|
+
rest.length > 0 ||
|
|
687
|
+
!mkdir?.startsWith("mkdir -p ") ||
|
|
688
|
+
!printf?.startsWith("printf '%s\\n' ")
|
|
689
|
+
) {
|
|
690
|
+
return undefined
|
|
691
|
+
}
|
|
692
|
+
const tokens = extractQuotedTokens(printf.slice("printf '%s\\n' ".length))
|
|
693
|
+
const path = tokens.pop()
|
|
694
|
+
if (path === undefined || !path.includes("/")) return undefined
|
|
695
|
+
const dir = path.slice(0, path.lastIndexOf("/"))
|
|
696
|
+
const rendered = `mkdir -p ${dir}\nprintf '%s\\n' ${tokens.map(shellQuote).join(" ")} > ${shellQuote(path)}`
|
|
697
|
+
if (rendered !== block) return undefined
|
|
698
|
+
repo.writeFile(path, `${tokens.join("\n")}\n`)
|
|
699
|
+
return { kind: "applied" }
|
|
700
|
+
}
|
|
701
|
+
|
|
670
702
|
/** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
|
|
671
703
|
const recognizersFor = (
|
|
672
704
|
repo: InMemRepo,
|
|
@@ -679,6 +711,7 @@ const recognizersFor = (
|
|
|
679
711
|
(block) => recognizeBinaryGuard(commands, block),
|
|
680
712
|
(block) => recognizeGitBuilders(repo, block),
|
|
681
713
|
(block) => recognizeDeferNotes(repo, block),
|
|
714
|
+
(block) => recognizeWriteLines(repo, block),
|
|
682
715
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
683
716
|
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
684
717
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -275,6 +275,15 @@ for (const makeTier of gitTiers) {
|
|
|
275
275
|
})
|
|
276
276
|
})
|
|
277
277
|
|
|
278
|
+
it("a mkdir + printf write lands one line per token, whatever the token holds", () => {
|
|
279
|
+
const lines = String.raw`mkdir -p .gtd/packages
|
|
280
|
+
printf '%s\n' 'a' '' 'it'\''s' '100%' '-x' 'a\b' '.gtd/packages/00-x.md' > '.gtd/packages/00-x.md'`
|
|
281
|
+
expect(run(lines).ok).toBe(true)
|
|
282
|
+
expect(read(".gtd/packages/00-x.md")).toBe(
|
|
283
|
+
"a\n\nit's\n100%\n-x\na\\b\n.gtd/packages/00-x.md\n",
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
278
287
|
describe("commitAll", () => {
|
|
279
288
|
it("a staging hook's edit lands in the commit", () => {
|
|
280
289
|
t.seed.stagingHook("readme.txt", "hooked")
|
|
@@ -77,7 +77,12 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
77
77
|
|
|
78
78
|
changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
|
|
79
79
|
|
|
80
|
+
stagedPaths: () => Effect.succeed(repo.stagedPaths()),
|
|
81
|
+
|
|
80
82
|
diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
|
|
83
|
+
|
|
84
|
+
// The identity `GitTiers`' live tier configures; the fake commits as no one in particular.
|
|
85
|
+
authorIdent: () => Effect.succeed(Option.some({ name: "Test", email: "test@test.com" })),
|
|
81
86
|
})
|
|
82
87
|
|
|
83
88
|
const failOn = (output: string | undefined): void => {
|