@pmelab/gtd 59.0.1 → 60.1.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 +48 -32
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +11285 -19864
- package/dist/gtd.core.mjs +593 -0
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +246 -37
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +7 -7
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +185 -184
- package/src/cli/Cli.ts +219 -224
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +34 -3
- package/src/cli/Program.test.ts +78 -84
- package/src/cli/Program.ts +192 -128
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +72 -62
- package/src/edge/Edge.ts +6 -6
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +189 -55
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +12 -12
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +2 -6
- package/src/twins/EmittedScripts.test.ts +5 -3
- package/src/twins/GitTiers.ts +6 -7
- package/src/twins/InMemRepo.ts +1 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/cliIo.ts +9 -4
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +27 -10
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +316 -119
- package/src/workflow/load.ts +478 -305
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
- package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
package/src/platform/Git.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command, CommandExecutor } from "@effect/platform"
|
|
2
1
|
import { Context, Effect, Layer, Option, Stream } from "effect"
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
3
3
|
import { Host } from "./Host.js"
|
|
4
4
|
|
|
5
5
|
export interface GitReaderOperations {
|
|
@@ -80,7 +80,7 @@ export interface GitReaderOperations {
|
|
|
80
80
|
skip: number,
|
|
81
81
|
head?: string,
|
|
82
82
|
) => Effect.Effect<ReadonlyArray<{ readonly hash: string; readonly subject: string }>, Error>
|
|
83
|
-
/** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `
|
|
83
|
+
/** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catch`. */
|
|
84
84
|
readonly readFileAtRef: (ref: string, path: string) => Effect.Effect<string, Error>
|
|
85
85
|
/**
|
|
86
86
|
* Pending working-tree changes vs `base` (default `HEAD`), as
|
|
@@ -216,7 +216,7 @@ const blobsAtRef = (
|
|
|
216
216
|
paths: ReadonlyArray<string>,
|
|
217
217
|
): Effect.Effect<Map<string, string>, Error> =>
|
|
218
218
|
exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
|
|
219
|
-
Effect.
|
|
219
|
+
Effect.catch(() => Effect.succeed("")),
|
|
220
220
|
Effect.map((out) => {
|
|
221
221
|
const blobs = new Map<string, string>()
|
|
222
222
|
for (const entry of splitNul(out)) {
|
|
@@ -256,7 +256,7 @@ const hashObjects = (
|
|
|
256
256
|
.map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
|
|
257
257
|
),
|
|
258
258
|
),
|
|
259
|
-
Effect.
|
|
259
|
+
Effect.catch(() => Effect.succeed(new Map<string, string>())),
|
|
260
260
|
)
|
|
261
261
|
|
|
262
262
|
/**
|
|
@@ -287,25 +287,28 @@ const classifyUntracked = (
|
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
289
|
* Run a command and return its stdout — FAILING on a non-zero exit code with
|
|
290
|
-
* the command line and stderr in the error message.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* `catchAll`.
|
|
290
|
+
* the command line and stderr in the error message. Ignoring exit codes
|
|
291
|
+
* used to make gtd report success on rejected commits and lose files whose
|
|
292
|
+
* quoted paths broke a swallowed `git add`. Callers that expect a probe to
|
|
293
|
+
* fail handle it with an explicit `catch`.
|
|
295
294
|
*/
|
|
296
295
|
const run = (
|
|
297
296
|
root: string,
|
|
298
297
|
...args: [string, ...Array<string>]
|
|
299
|
-
): Effect.Effect<string, Error,
|
|
298
|
+
): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
300
299
|
Effect.scoped(
|
|
301
300
|
Effect.gen(function* () {
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
301
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
302
|
+
const [command, ...rest] = args
|
|
303
|
+
const process = yield* spawner.spawn(
|
|
304
|
+
// Not detached: the spawner SIGTERMs a detached child's whole group once it
|
|
305
|
+
// exits, which would kill a git hook's background job.
|
|
306
|
+
ChildProcess.make(command, rest, {
|
|
307
|
+
cwd: root,
|
|
308
|
+
stdout: "pipe",
|
|
309
|
+
stderr: "pipe",
|
|
310
|
+
detached: false,
|
|
311
|
+
}),
|
|
309
312
|
)
|
|
310
313
|
const collect = (stream: typeof process.stdout) =>
|
|
311
314
|
stream.pipe(Stream.decodeText(), Stream.mkString)
|
|
@@ -353,11 +356,12 @@ export const resolvedRefOrCorrupted = (
|
|
|
353
356
|
? Effect.succeed(hash)
|
|
354
357
|
: Effect.fail(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]))
|
|
355
358
|
|
|
356
|
-
const makeGitImpl = (
|
|
359
|
+
const makeGitImpl = (
|
|
360
|
+
spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
|
|
361
|
+
root: string,
|
|
362
|
+
): GitOperations => {
|
|
357
363
|
const exec = (...args: [string, ...Array<string>]) =>
|
|
358
|
-
run(root, ...args).pipe(
|
|
359
|
-
Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
|
|
360
|
-
)
|
|
364
|
+
run(root, ...args).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
|
|
361
365
|
|
|
362
366
|
return {
|
|
363
367
|
lastCommitSubject: (ref = "HEAD") =>
|
|
@@ -374,7 +378,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
374
378
|
exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(
|
|
375
379
|
Effect.map((s) => s.trim()),
|
|
376
380
|
Effect.map((name) => Option.some(name)),
|
|
377
|
-
Effect.
|
|
381
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
378
382
|
),
|
|
379
383
|
|
|
380
384
|
listRefs: (prefix: string) =>
|
|
@@ -399,7 +403,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
399
403
|
hasCommits: () =>
|
|
400
404
|
exec("git", "rev-parse", "--verify", "HEAD").pipe(
|
|
401
405
|
Effect.map(() => true),
|
|
402
|
-
Effect.
|
|
406
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
403
407
|
),
|
|
404
408
|
|
|
405
409
|
readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
|
|
@@ -427,7 +431,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
427
431
|
? Option.none()
|
|
428
432
|
: Option.some({ name: match[1]!, email: match[2]! })
|
|
429
433
|
}),
|
|
430
|
-
Effect.
|
|
434
|
+
Effect.catch(() => Effect.succeed(Option.none())),
|
|
431
435
|
),
|
|
432
436
|
|
|
433
437
|
changedPaths: (base?: string) =>
|
|
@@ -436,7 +440,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
436
440
|
// Tolerates the empty-repo case (no HEAD), where the diff has no ref
|
|
437
441
|
// to compare against and reports no tracked changes.
|
|
438
442
|
const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(
|
|
439
|
-
Effect.
|
|
443
|
+
Effect.catch(() => Effect.succeed("")),
|
|
440
444
|
)
|
|
441
445
|
const trackedPaths = parseNameStatus(splitNul(nameStatusOut))
|
|
442
446
|
|
|
@@ -469,7 +473,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
469
473
|
|
|
470
474
|
stagedPaths: () =>
|
|
471
475
|
exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
|
|
472
|
-
Effect.
|
|
476
|
+
Effect.catch(() => Effect.succeed("")),
|
|
473
477
|
Effect.map((out) => parseNameStatus(splitNul(out))),
|
|
474
478
|
),
|
|
475
479
|
|
|
@@ -482,26 +486,26 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
482
486
|
readRefOption: (ref: string) =>
|
|
483
487
|
exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(
|
|
484
488
|
Effect.map((s) => Option.some(s.trim())),
|
|
485
|
-
Effect.
|
|
489
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
486
490
|
),
|
|
487
491
|
|
|
488
492
|
isAncestor: (a: string, b: string) =>
|
|
489
493
|
run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(
|
|
490
|
-
Effect.
|
|
494
|
+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
|
|
491
495
|
Effect.map(() => true),
|
|
492
|
-
Effect.
|
|
496
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
493
497
|
),
|
|
494
498
|
|
|
495
499
|
mergeBase: (a: string, b: string) =>
|
|
496
500
|
exec("git", "merge-base", a, b).pipe(
|
|
497
501
|
Effect.map((s) => Option.some(s.trim())),
|
|
498
|
-
Effect.
|
|
502
|
+
Effect.catch(() => Effect.succeed(Option.none<string>())),
|
|
499
503
|
),
|
|
500
504
|
|
|
501
505
|
defaultBranch: () =>
|
|
502
506
|
exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(
|
|
503
507
|
Effect.map((s) => s.trim()),
|
|
504
|
-
Effect.
|
|
508
|
+
Effect.catch(() => Effect.succeed("")),
|
|
505
509
|
Effect.map((name) => (name === "" ? "main" : name)),
|
|
506
510
|
),
|
|
507
511
|
|
|
@@ -546,7 +550,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
546
550
|
}),
|
|
547
551
|
),
|
|
548
552
|
// Empty repo (no HEAD) makes `git log` fail; treat as no commits.
|
|
549
|
-
Effect.
|
|
553
|
+
Effect.catch(() =>
|
|
550
554
|
Effect.succeed(
|
|
551
555
|
[] as ReadonlyArray<{
|
|
552
556
|
readonly hash: string
|
|
@@ -582,7 +586,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
582
586
|
),
|
|
583
587
|
// Empty repo (no HEAD), or an unresolvable `head`, makes `git log`
|
|
584
588
|
// fail; treat as no commits — the same fold `commitHistory` applies.
|
|
585
|
-
Effect.
|
|
589
|
+
Effect.catch(() =>
|
|
586
590
|
Effect.succeed([] as ReadonlyArray<{ readonly hash: string; readonly subject: string }>),
|
|
587
591
|
),
|
|
588
592
|
)
|
|
@@ -592,7 +596,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
592
596
|
Effect.gen(function* () {
|
|
593
597
|
yield* exec("git", "add", "-A")
|
|
594
598
|
yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(
|
|
595
|
-
Effect.
|
|
599
|
+
Effect.catch((error) =>
|
|
596
600
|
// Hooks like lint-staged block empty commits even with --allow-empty.
|
|
597
601
|
// gtd's workflow commits have nothing for code-quality hooks to validate,
|
|
598
602
|
// so retry without the pre-commit hook when that guard fires.
|
|
@@ -608,7 +612,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
608
612
|
commitAsIs: (message: string) =>
|
|
609
613
|
exec("git", "commit", "--allow-empty", "-m", message)
|
|
610
614
|
.pipe(
|
|
611
|
-
Effect.
|
|
615
|
+
Effect.catch((error) =>
|
|
612
616
|
error.message.includes("empty git commit")
|
|
613
617
|
? exec("git", "commit", "--allow-empty", "--no-verify", "-m", message)
|
|
614
618
|
: Effect.fail(error),
|
|
@@ -634,11 +638,11 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
634
638
|
}
|
|
635
639
|
|
|
636
640
|
const makeLiveEffect = Effect.gen(function* () {
|
|
637
|
-
const
|
|
641
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
638
642
|
const { root } = yield* Host
|
|
639
|
-
return makeGitImpl(
|
|
643
|
+
return makeGitImpl(spawner, root)
|
|
640
644
|
})
|
|
641
645
|
|
|
642
|
-
export class GitService extends Context.
|
|
646
|
+
export class GitService extends Context.Service<GitService, GitOperations>()("GitService") {
|
|
643
647
|
static Live = Layer.effect(GitService, makeLiveEffect)
|
|
644
648
|
}
|
package/src/platform/Host.ts
CHANGED
|
@@ -36,7 +36,7 @@ const makeHostOps = (opts: HostOpsInput): HostOps => ({
|
|
|
36
36
|
((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
export class Host extends Context.
|
|
39
|
+
export class Host extends Context.Service<Host, HostOps>()("Host") {
|
|
40
40
|
static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
|
|
41
41
|
static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
|
|
42
42
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node-shared"
|
|
2
|
+
import { Layer } from "effect"
|
|
3
|
+
|
|
4
|
+
// Not `@effect/platform-node`'s own: that package requires a redis peer, which
|
|
5
|
+
// every install of gtd would then pull in.
|
|
6
|
+
export const layer = Layer.provideMerge(
|
|
7
|
+
NodeChildProcessSpawner.layer,
|
|
8
|
+
Layer.mergeAll(NodeFileSystem.layer, NodePath.layer),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export type NodeServices = Layer.Success<typeof layer>
|
|
@@ -13,8 +13,7 @@ import { tmpdir } from "node:os"
|
|
|
13
13
|
import { join } from "node:path"
|
|
14
14
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
15
15
|
import { Effect, Layer } from "effect"
|
|
16
|
-
import {
|
|
17
|
-
import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
|
|
16
|
+
import { GitService, Host, Workspace, type WorkspaceOps, NodeServices } from "./index.js"
|
|
18
17
|
import { InMemRepo, makeInMemoryWorkspaceOps } from "../twins/index.js"
|
|
19
18
|
|
|
20
19
|
// `execFileSync` is a named export of the built-in `node:child_process` ESM
|
|
@@ -54,7 +53,7 @@ const makeLiveTier = (): WorkspaceTier => {
|
|
|
54
53
|
gitExecIn(root, "config", "commit.gpgsign", "false")
|
|
55
54
|
|
|
56
55
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
57
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
56
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
58
57
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
59
58
|
|
|
60
59
|
return {
|
|
@@ -275,7 +274,7 @@ describe("Workspace [Live] worktreeSync", () => {
|
|
|
275
274
|
Host.layer({ root, home: root, env: {} }),
|
|
276
275
|
GitService.Live.pipe(
|
|
277
276
|
Layer.provide(
|
|
278
|
-
Layer.merge(Host.layer({ root, home: root, env: {} }),
|
|
277
|
+
Layer.merge(Host.layer({ root, home: root, env: {} }), NodeServices.layer),
|
|
279
278
|
),
|
|
280
279
|
),
|
|
281
280
|
),
|
|
@@ -361,7 +360,7 @@ const makeLiveEpisodeTier = () => {
|
|
|
361
360
|
git("config", "commit.gpgsign", "false")
|
|
362
361
|
|
|
363
362
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
364
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
363
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
365
364
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
366
365
|
const ops = (): Promise<WorkspaceOps> =>
|
|
367
366
|
Effect.runPromise(
|
|
@@ -600,7 +600,7 @@ const makeWorkspaceOps = (
|
|
|
600
600
|
committed: (path, ref = "HEAD") =>
|
|
601
601
|
Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
|
|
602
602
|
Effect.flatMap((relPath) =>
|
|
603
|
-
git.readFileAtRef(ref, relPath).pipe(Effect.
|
|
603
|
+
git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
|
604
604
|
),
|
|
605
605
|
),
|
|
606
606
|
readCommittedSync,
|
|
@@ -610,7 +610,7 @@ const makeWorkspaceOps = (
|
|
|
610
610
|
}
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
export class Workspace extends Context.
|
|
613
|
+
export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Workspace") {
|
|
614
614
|
static Live = Layer.effect(
|
|
615
615
|
Workspace,
|
|
616
616
|
Effect.gen(function* () {
|
|
@@ -624,7 +624,7 @@ export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>
|
|
|
624
624
|
// like a full-disk failure does.
|
|
625
625
|
const commonDir = yield* git
|
|
626
626
|
.gitCommonDir()
|
|
627
|
-
.pipe(Effect.
|
|
627
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
628
628
|
return makeWorkspaceOps(root, git, commonDir)
|
|
629
629
|
}),
|
|
630
630
|
)
|
|
@@ -12,10 +12,9 @@ import { mkdtempSync, realpathSync } from "node:fs"
|
|
|
12
12
|
import { execSync } from "node:child_process"
|
|
13
13
|
import { tmpdir } from "node:os"
|
|
14
14
|
import { Effect, Layer } from "effect"
|
|
15
|
-
import { NodeContext } from "@effect/platform-node"
|
|
16
15
|
import { describe, expect, it } from "vitest"
|
|
17
16
|
import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
|
|
18
|
-
import { GitService, Host, Workspace } from "./index.js"
|
|
17
|
+
import { GitService, Host, Workspace, NodeServices } from "./index.js"
|
|
19
18
|
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
20
19
|
|
|
21
20
|
const provide = <A>(
|
|
@@ -112,7 +111,7 @@ describe("worktreeGitDir [real git]", () => {
|
|
|
112
111
|
|
|
113
112
|
const runIn = (root: string) => {
|
|
114
113
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
115
|
-
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer,
|
|
114
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
116
115
|
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
117
116
|
return Effect.runPromise(
|
|
118
117
|
worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
|
|
@@ -17,7 +17,7 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
17
17
|
const workspace = yield* Workspace
|
|
18
18
|
const content = yield* workspace
|
|
19
19
|
.read(".git")
|
|
20
|
-
.pipe(Effect.
|
|
20
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
21
21
|
const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
|
|
22
22
|
if (pointer === null) return ".git"
|
|
23
23
|
const gitdir = pointer[1]!.trim()
|
package/src/platform/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* home, env, scratch). `GitService` moved in alongside `Workspace` since
|
|
5
5
|
* `committed` IS `git.readFileAtRef` — one module owns every read.
|
|
6
6
|
*
|
|
7
|
-
* Seam rule, stated once: a seam is a `Context.
|
|
7
|
+
* Seam rule, stated once: a seam is a `Context.Service` unless the consuming
|
|
8
8
|
* code is synchronous, non-Effect code that cannot `yield*` — replay's flow
|
|
9
9
|
* reads are that exception, and use the `*Sync` members.
|
|
10
10
|
*/
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from "./Git.js"
|
|
20
20
|
export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
|
|
21
21
|
export { CommandRunner } from "./CommandRunner.js"
|
|
22
|
+
export * as NodeServices from "./NodeServices.js"
|
|
22
23
|
export { loopLogPath } from "./WorktreeState.js"
|
|
23
24
|
export {
|
|
24
25
|
HISTORY_REF,
|
|
@@ -702,7 +702,7 @@ describe("replay: keep", () => {
|
|
|
702
702
|
it("rejects keep together with allowEmpty", async () => {
|
|
703
703
|
const flow: Flow = async () => {
|
|
704
704
|
await human("idle")
|
|
705
|
-
await agent("naive", "p", { keep: true, allowEmpty: true })
|
|
705
|
+
await agent("naive", "p", { keep: true, allowEmpty: true } as never)
|
|
706
706
|
}
|
|
707
707
|
const h = new History().land("human", "idle", 1, "naive", {})
|
|
708
708
|
const outcome = await replayOf(flow, h)
|
|
@@ -1166,6 +1166,17 @@ describe("human({ approve })", () => {
|
|
|
1166
1166
|
}
|
|
1167
1167
|
})
|
|
1168
1168
|
|
|
1169
|
+
it.each([
|
|
1170
|
+
["human", async () => human("gate", { mode: "qa" } as never)],
|
|
1171
|
+
["agent", async () => agent("gate", "p", { mode: "qa" } as never)],
|
|
1172
|
+
["run", async () => run("gate", "true", { mode: "qa" } as never)],
|
|
1173
|
+
] as const)("refuses a %s step's mode without a file", async (kind, flow) => {
|
|
1174
|
+
expect(await replayOf(flow, new History())).toEqual({
|
|
1175
|
+
kind: "failed",
|
|
1176
|
+
message: `gtd: step "gate": mode requires file in ${kind}() options — name the steering file the mode checks`,
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1169
1180
|
describe("refuses mode and file", () => {
|
|
1170
1181
|
const tail =
|
|
1171
1182
|
"in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing"
|
|
@@ -1189,7 +1200,7 @@ describe("human({ approve })", () => {
|
|
|
1189
1200
|
for (const options of [
|
|
1190
1201
|
{ approve: true, label: "x" },
|
|
1191
1202
|
{ approve: true, base: "start" },
|
|
1192
|
-
{ approve: false, mode: "review" },
|
|
1203
|
+
{ approve: false, mode: "review", file: ".gtd/REVIEW.md" },
|
|
1193
1204
|
{ approve: true, mode: undefined },
|
|
1194
1205
|
]) {
|
|
1195
1206
|
expect((await outcomeOf(options)).kind).toBe("rest")
|
package/src/replay/Replay.ts
CHANGED
|
@@ -11,14 +11,13 @@ import {
|
|
|
11
11
|
type StepRequest,
|
|
12
12
|
type Flow,
|
|
13
13
|
type Mark,
|
|
14
|
-
} from "../flows/
|
|
14
|
+
} from "../flows/internal.js"
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
headingSections,
|
|
18
|
-
steeringFormatFor,
|
|
16
|
+
openQuestions,
|
|
19
17
|
parseCodeThreads,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
sectionBodies,
|
|
19
|
+
sections,
|
|
20
|
+
threads,
|
|
22
21
|
} from "../steering/index.js"
|
|
23
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
24
23
|
import { globMatches } from "../wire/index.js"
|
|
@@ -291,6 +290,13 @@ const approveConflict = (step: ReachedStep): string | undefined => {
|
|
|
291
290
|
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`
|
|
292
291
|
}
|
|
293
292
|
|
|
293
|
+
// A mode names how a steering file is checked; with no file there is nothing to check.
|
|
294
|
+
const modeWithoutFile = (step: ReachedStep): string | undefined => {
|
|
295
|
+
const options = step.request.options
|
|
296
|
+
if (options.mode === undefined || options.file !== undefined) return undefined
|
|
297
|
+
return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
295
301
|
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
296
302
|
...c,
|
|
@@ -524,7 +530,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
524
530
|
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
|
|
525
531
|
}
|
|
526
532
|
}
|
|
527
|
-
const error =
|
|
533
|
+
const error =
|
|
534
|
+
unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
|
|
528
535
|
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
529
536
|
}
|
|
530
537
|
|
|
@@ -604,7 +611,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
604
611
|
const name = scoped(request.name)
|
|
605
612
|
if (request.options.keep === true) {
|
|
606
613
|
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
|
607
|
-
if (request.options.allowEmpty === true) {
|
|
614
|
+
if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
|
|
608
615
|
return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`
|
|
609
616
|
}
|
|
610
617
|
// "/" would let one step's ref directory collide with another step's ref (git refuses `a` and `a/b` together).
|
|
@@ -667,28 +674,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
667
674
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
668
675
|
changesSince,
|
|
669
676
|
matches: globMatches,
|
|
670
|
-
sections
|
|
671
|
-
sectionBodies
|
|
672
|
-
openQuestions
|
|
673
|
-
|
|
674
|
-
return qa === undefined
|
|
675
|
-
? []
|
|
676
|
-
: unansweredQuestions(qa, text).map((q) => ({
|
|
677
|
-
question: q.question,
|
|
678
|
-
line: q.headingLine + 1,
|
|
679
|
-
}))
|
|
680
|
-
},
|
|
681
|
-
threads: (text) => {
|
|
682
|
-
const { threads, findings } = parseThreadsWithFindings(text)
|
|
683
|
-
return threads.map((t) => ({
|
|
684
|
-
name: t.name,
|
|
685
|
-
line: t.line + 1,
|
|
686
|
-
waitingOn: t.waitingOn,
|
|
687
|
-
faults: findings
|
|
688
|
-
.filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
|
|
689
|
-
.map((f) => f.message),
|
|
690
|
-
}))
|
|
691
|
-
},
|
|
677
|
+
sections,
|
|
678
|
+
sectionBodies,
|
|
679
|
+
openQuestions,
|
|
680
|
+
threads,
|
|
692
681
|
codeThreads: () =>
|
|
693
682
|
changesBetween(
|
|
694
683
|
treeAt(input.start) ?? input.startTree ?? input.episode.base.tree,
|
|
@@ -697,7 +697,7 @@ describe("case-insensitive definition matching", () => {
|
|
|
697
697
|
})
|
|
698
698
|
})
|
|
699
699
|
|
|
700
|
-
import { freeFormFormat, getParseCount, steeringFormatFor } from "./
|
|
700
|
+
import { freeFormFormat, getParseCount, steeringFormatFor } from "./internal.js"
|
|
701
701
|
|
|
702
702
|
const qaDescriptor = steeringFormatFor("qa")!
|
|
703
703
|
const reviewDescriptor = steeringFormatFor("review")!
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { freeFormFormat } from "./freeform.js"
|
|
3
|
-
import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./
|
|
3
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./internal.js"
|
|
4
4
|
|
|
5
5
|
/** Applies edits back-to-front — a local copy of the file's own splice pattern, mirroring `qa.test.ts`/`review.test.ts`'s identical helper. */
|
|
6
6
|
const applyEdits = (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import
|
|
2
|
+
import * as published from "./index.js"
|
|
3
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./internal.js"
|
|
3
4
|
|
|
4
5
|
const qa = steeringFormatFor("qa")!
|
|
5
6
|
const review = steeringFormatFor("review")!
|
|
@@ -60,3 +61,28 @@ describe("viewOf", () => {
|
|
|
60
61
|
expect(view.documentLinks[0]!.line).toBe(0)
|
|
61
62
|
})
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
describe("@pmelab/gtd/steering", () => {
|
|
66
|
+
it("publishes no engine-only name, nor a duplicate of the pure text reads", () => {
|
|
67
|
+
for (const name of [
|
|
68
|
+
"FOOTNOTE_ACTION_TITLE",
|
|
69
|
+
"THREAD_REPLY_ACTION_TITLE",
|
|
70
|
+
"getParseCount",
|
|
71
|
+
"BUILT_IN_MODE_NAMES",
|
|
72
|
+
"steeringFormatOrFreeForm",
|
|
73
|
+
"viewOf",
|
|
74
|
+
"headingSections",
|
|
75
|
+
"headingSectionBodies",
|
|
76
|
+
"unansweredQuestions",
|
|
77
|
+
]) {
|
|
78
|
+
expect(published, name).not.toHaveProperty(name)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("publishes the pure text reads a flow also has", () => {
|
|
83
|
+
expect(published.sections("## A\n\n## B\n")).toEqual(["A", "B"])
|
|
84
|
+
expect(typeof published.threads).toBe("function")
|
|
85
|
+
expect(typeof published.openQuestions).toBe("function")
|
|
86
|
+
expect(typeof published.sectionBodies).toBe("function")
|
|
87
|
+
})
|
|
88
|
+
})
|