@pmelab/gtd 27.3.0 → 27.5.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 +22 -1
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +27 -2
- package/dist/gtd.bundle.mjs +218 -32
- package/dist/testkit.mjs +358 -37
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +140 -64
- 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 +346 -107
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +49 -1
- package/src/cli/Cli.ts +25 -8
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +205 -1
- package/src/edge/Edge.ts +105 -12
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flags/index.test.ts +14 -1
- package/src/flags/index.ts +14 -3
- package/src/flows/helpers.ts +34 -2
- 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/judgeDiffShape.test.ts +43 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStart.ts +3 -2
- 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 +12 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/steps.ts +18 -0
- 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/workflow/index.ts +8 -1
- package/src/workflow/vars.test.ts +23 -1
- package/src/workflow/vars.ts +24 -0
- package/src/workflows/feature/feature.ts +9 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam-pin.feature +52 -0
- package/src/workflows/feature/seam.feature +387 -0
- package/src/workflows/feature/seam.test.ts +88 -0
- package/src/workflows/feature/seam.ts +110 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +12 -1
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +20 -2
- package/src/workflows/shared/skills.ts +6 -0
- package/src/workflows/shared/text.test.ts +59 -0
- package/src/workflows/shared/text.ts +86 -0
- package/src/workflows/shared/vars.ts +4 -0
- package/src/workflows/shared/workflows.steps.ts +39 -1
package/src/flags/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Flags are not recorded in history, so replay must not read the live value: flow code may
|
|
2
|
+
// branch on a flag only through its pinned `flag_<name>` setting. No lint rule enforces this.
|
|
3
3
|
|
|
4
4
|
export type FlagKind = "release" | "ops" | "experiment"
|
|
5
5
|
|
|
@@ -13,7 +13,9 @@ export interface FlagDeclaration {
|
|
|
13
13
|
readonly defaultOn?: boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const FLAGS = [
|
|
16
|
+
export const FLAGS = [
|
|
17
|
+
{ name: "naive_pass", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
|
|
18
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
17
19
|
|
|
18
20
|
/** A misspelt name is a compile error. */
|
|
19
21
|
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
@@ -22,6 +24,12 @@ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
|
22
24
|
|
|
23
25
|
const BOOL: Record<string, boolean> = { "0": false, false: false, "1": true, true: true }
|
|
24
26
|
|
|
27
|
+
/** A `flag_<name>` setting's value as the pinned `"1"`/`"0"`, or undefined when garbled. */
|
|
28
|
+
export const flagSettingValue = (raw: string): "0" | "1" | undefined => {
|
|
29
|
+
const key = raw.toLowerCase()
|
|
30
|
+
return Object.hasOwn(BOOL, key) ? (BOOL[key] ? "1" : "0") : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
26
34
|
env: Readonly<Record<string, string | undefined>>,
|
|
27
35
|
registry: R = FLAGS as unknown as R,
|
|
@@ -41,3 +49,6 @@ export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof
|
|
|
41
49
|
}
|
|
42
50
|
return out as Flags<R>
|
|
43
51
|
}
|
|
52
|
+
|
|
53
|
+
export const flagSettings = (flags: Readonly<Record<string, boolean>>): Record<string, string> =>
|
|
54
|
+
Object.fromEntries(Object.entries(flags).map(([name, on]) => [`flag_${name}`, on ? "1" : "0"]))
|
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,
|
|
@@ -151,12 +152,14 @@ export interface DiffShapeOptions {
|
|
|
151
152
|
readonly changes: readonly Change[]
|
|
152
153
|
/** Globs in the `changes(pattern)` / `gtd strays` dialect. */
|
|
153
154
|
readonly allow: readonly string[]
|
|
155
|
+
/** Exact paths never strays; compared by equality, so a `*` in one covers nothing else. */
|
|
156
|
+
readonly exempt?: readonly string[] | undefined
|
|
154
157
|
readonly minP?: number | undefined
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
export interface DiffShapeResult {
|
|
158
161
|
readonly choice: DiffShape
|
|
159
|
-
/** Sorted, equal to `gtd strays` for the same range and globs
|
|
162
|
+
/** Sorted, equal to `gtd strays` for the same range and globs, minus `exempt`. */
|
|
160
163
|
readonly strays: readonly string[]
|
|
161
164
|
/** Strays with a side that could not be read (sorted); `refactor-first` skipped the judge. */
|
|
162
165
|
readonly unreadable: readonly string[]
|
|
@@ -197,7 +200,9 @@ export const judgeDiffShape = async (
|
|
|
197
200
|
options: DiffShapeOptions,
|
|
198
201
|
): Promise<DiffShapeResult> => {
|
|
199
202
|
const { changes: all, allow } = options
|
|
200
|
-
const
|
|
203
|
+
const exempt = options.exempt ?? []
|
|
204
|
+
const isStray = (c: Change): boolean =>
|
|
205
|
+
!exempt.includes(c.path) && !allow.some((pattern) => matches(c.path, pattern))
|
|
201
206
|
const strayChanges: Change[] = all
|
|
202
207
|
.filter(isStray)
|
|
203
208
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
@@ -250,3 +255,30 @@ export const judgeDiffShape = async (
|
|
|
250
255
|
: "refactor-first"
|
|
251
256
|
return { choice, strays, unreadable: [], judgment }
|
|
252
257
|
}
|
|
258
|
+
|
|
259
|
+
export const displayPath = (path: string): string =>
|
|
260
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
261
|
+
/[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path
|
|
262
|
+
|
|
263
|
+
const seamReport = (shape: DiffShapeResult): string => {
|
|
264
|
+
const unreadable = new Set(shape.unreadable)
|
|
265
|
+
const strays = shape.strays.map(
|
|
266
|
+
(path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`,
|
|
267
|
+
)
|
|
268
|
+
return [
|
|
269
|
+
"Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
|
|
270
|
+
"",
|
|
271
|
+
...strays,
|
|
272
|
+
"",
|
|
273
|
+
"Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`.",
|
|
274
|
+
].join("\n")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Rest at an approval gate `name` showing the seam report — only when
|
|
279
|
+
* `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
|
|
280
|
+
*/
|
|
281
|
+
export const seamGate = async (name: string, shape: DiffShapeResult): Promise<void> => {
|
|
282
|
+
if (shape.choice !== "refactor-first") return
|
|
283
|
+
await human(name, { message: seamReport(shape), approve: true })
|
|
284
|
+
}
|
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
|
+
})
|
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,
|