@pmelab/gtd 58.2.0 → 59.0.1
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 +120 -395
- package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +484 -497
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +3 -42
- package/skills/authoring/SKILL.md +34 -27
- package/src/cli/Cli.test.ts +148 -71
- package/src/cli/Cli.ts +384 -463
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +127 -33
- package/src/cli/Program.ts +70 -25
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flags/index.test.ts +0 -13
- package/src/flags/index.ts +1 -3
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/install/Install.test.ts +14 -11
- package/src/install/Install.ts +38 -15
- package/src/install/index.ts +1 -2
- package/src/replay/Replay.test.ts +1 -1
- package/src/replay/index.ts +1 -1
- package/src/step/planStart.ts +6 -1
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/twins/FlowScripts.ts +1 -1
- package/src/wire/Land.ts +1 -1
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +138 -113
- package/src/workflow/load.ts +7 -7
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- package/src/workflow/vars.test.ts +30 -3
- package/src/workflow/vars.ts +34 -2
- package/src/workflow-kit/index.ts +1 -1
- package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
- package/dist/chunks/review-Bfaror38.mjs +0 -427
- package/dist/chunks/tail-Bd7L940p.mjs +0 -355
- package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
- package/dist/workflows/cleanup.mjs +0 -36
- package/dist/workflows/feature.mjs +0 -1018
- package/dist/workflows/fix.mjs +0 -41
- package/dist/workflows/review.mjs +0 -45
- package/dist/workflows/rollout.mjs +0 -48
- package/dist/workflows.mjs +0 -40651
- package/src/install/ConfigScaffold.ts +0 -22
- package/src/workflows/cleanup/cleanup-path.feature +0 -94
- package/src/workflows/cleanup/cleanup.feature +0 -315
- package/src/workflows/cleanup/cleanup.test.ts +0 -276
- package/src/workflows/cleanup/cleanup.ts +0 -52
- package/src/workflows/cleanup/index.ts +0 -6
- package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
- package/src/workflows/feature/acceptance-criteria.feature +0 -389
- package/src/workflows/feature/criteria-coverage.feature +0 -102
- package/src/workflows/feature/criteria.test.ts +0 -147
- package/src/workflows/feature/criteria.ts +0 -267
- package/src/workflows/feature/fast-path.feature +0 -93
- package/src/workflows/feature/feature.test.ts +0 -338
- package/src/workflows/feature/feature.ts +0 -156
- package/src/workflows/feature/green.ts +0 -19
- package/src/workflows/feature/index.ts +0 -18
- package/src/workflows/feature/lens-models.feature +0 -69
- package/src/workflows/feature/lens-review.feature +0 -566
- package/src/workflows/feature/naive-acceptance.feature +0 -147
- package/src/workflows/feature/naive-test.feature +0 -263
- package/src/workflows/feature/one-step-land.feature +0 -93
- package/src/workflows/feature/planning.ts +0 -41
- package/src/workflows/feature/refactor-absorbs.feature +0 -131
- package/src/workflows/feature/refactor-first.feature +0 -158
- package/src/workflows/feature/refactor-lens-models.feature +0 -79
- package/src/workflows/feature/refactor-rebuild.feature +0 -273
- package/src/workflows/feature/refactor-review.feature +0 -1528
- package/src/workflows/feature/refactor.test.ts +0 -1220
- package/src/workflows/feature/refactor.ts +0 -368
- package/src/workflows/feature/release-flag.feature +0 -167
- package/src/workflows/feature/release-flag.test.ts +0 -49
- package/src/workflows/feature/release-flag.ts +0 -46
- package/src/workflows/feature/review-gate.feature +0 -241
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
- package/src/workflows/feature/risk-tier.feature +0 -351
- package/src/workflows/feature/seam-rejection.feature +0 -146
- package/src/workflows/feature/seam.feature +0 -297
- package/src/workflows/feature/seam.test.ts +0 -527
- package/src/workflows/feature/seam.ts +0 -426
- package/src/workflows/feature/standards.feature +0 -354
- package/src/workflows/feature/steps.test.ts +0 -65
- package/src/workflows/feature/steps.ts +0 -45
- package/src/workflows/feature/tier-pin.feature +0 -66
- package/src/workflows/feature/tier.test.ts +0 -166
- package/src/workflows/feature/tier.ts +0 -56
- package/src/workflows/fix/fix-lens-origin.feature +0 -114
- package/src/workflows/fix/fix-quality.feature +0 -212
- package/src/workflows/fix/fix.feature +0 -79
- package/src/workflows/fix/fix.test.ts +0 -74
- package/src/workflows/fix/fix.ts +0 -52
- package/src/workflows/fix/index.ts +0 -6
- package/src/workflows/index.ts +0 -47
- package/src/workflows/review/index.ts +0 -6
- package/src/workflows/review/review-door.feature +0 -199
- package/src/workflows/review/review.test.ts +0 -27
- package/src/workflows/review/review.ts +0 -60
- package/src/workflows/rollout/index.ts +0 -6
- package/src/workflows/rollout/rollout.feature +0 -510
- package/src/workflows/rollout/rollout.test.ts +0 -263
- package/src/workflows/rollout/rollout.ts +0 -82
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
- package/src/workflows/shared/access.test.ts +0 -62
- package/src/workflows/shared/access.ts +0 -24
- package/src/workflows/shared/config.fixture.ts +0 -12
- package/src/workflows/shared/fences.ts +0 -24
- package/src/workflows/shared/health.test.ts +0 -148
- package/src/workflows/shared/health.ts +0 -191
- package/src/workflows/shared/learn.test.ts +0 -140
- package/src/workflows/shared/learn.ts +0 -201
- package/src/workflows/shared/lenses.test.ts +0 -39
- package/src/workflows/shared/lenses.ts +0 -294
- package/src/workflows/shared/options.test.ts +0 -157
- package/src/workflows/shared/options.ts +0 -92
- package/src/workflows/shared/progress.feature +0 -124
- package/src/workflows/shared/prose.ts +0 -263
- package/src/workflows/shared/recheck.feature +0 -60
- package/src/workflows/shared/registry.test.ts +0 -345
- package/src/workflows/shared/registry.ts +0 -183
- package/src/workflows/shared/reserved.ts +0 -11
- package/src/workflows/shared/review.test.ts +0 -1062
- package/src/workflows/shared/review.ts +0 -446
- package/src/workflows/shared/skills.test.ts +0 -121
- package/src/workflows/shared/skills.ts +0 -49
- package/src/workflows/shared/standards.test.ts +0 -90
- package/src/workflows/shared/standards.ts +0 -156
- package/src/workflows/shared/steps.test.ts +0 -219
- package/src/workflows/shared/steps.ts +0 -201
- package/src/workflows/shared/tail.test.ts +0 -454
- package/src/workflows/shared/tail.ts +0 -264
- package/src/workflows/shared/text.fixture.ts +0 -128
- package/src/workflows/shared/text.test.ts +0 -1430
- package/src/workflows/shared/text.ts +0 -1700
- package/src/workflows/shared/threads.test.ts +0 -129
- package/src/workflows/shared/vars.ts +0 -47
- package/src/workflows/shared/workflows.steps.ts +0 -54
package/src/flows/factory.ts
CHANGED
|
@@ -52,7 +52,9 @@ export const defineWorkflow = <S extends StandardSchemaV1>(
|
|
|
52
52
|
// A factory call used to be a flow; awaiting one in a composed flow must fail, not no-op.
|
|
53
53
|
// oxlint-disable-next-line unicorn/no-thenable -- awaiting a call must fail
|
|
54
54
|
then: () => {
|
|
55
|
-
throw new Error(
|
|
55
|
+
throw new Error(
|
|
56
|
+
"a configured workflow is not a flow — await the flow functions it is built from instead",
|
|
57
|
+
)
|
|
56
58
|
},
|
|
57
59
|
})
|
|
58
60
|
return Object.assign(factory, { [FACTORY]: internals }) as never
|
package/src/gherkin/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
type TestRepo,
|
|
22
22
|
type WorkflowModule,
|
|
23
23
|
} from "../testkit/index.js"
|
|
24
|
+
import { assertContains, assertLacks, refusal, refusedWith, restDescription } from "./messages.js"
|
|
25
|
+
|
|
26
|
+
// Kept off the class so the exported world's type stays as it is.
|
|
27
|
+
const usedActors = new WeakMap<TestkitWorld, Set<string>>()
|
|
24
28
|
|
|
25
29
|
export class TestkitWorld extends QuickPickleWorld {
|
|
26
30
|
// Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
|
|
@@ -88,6 +92,8 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
88
92
|
|
|
89
93
|
async drive(): Promise<void> {
|
|
90
94
|
const actors: Record<string, Actor> = {}
|
|
95
|
+
const used = usedActors.get(this) ?? new Set<string>()
|
|
96
|
+
usedActors.set(this, used)
|
|
91
97
|
for (const [step, actions] of this.actions) {
|
|
92
98
|
actors[step] = async (beat, repo) => {
|
|
93
99
|
for (const action of actions) await action(beat, repo)
|
|
@@ -115,7 +121,14 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
115
121
|
return hit[1]
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
|
-
|
|
124
|
+
const tracked: Record<string, Actor> = {}
|
|
125
|
+
for (const [step, actor] of Object.entries(actors)) {
|
|
126
|
+
tracked[step] = (beat, repo) => {
|
|
127
|
+
used.add(step)
|
|
128
|
+
return actor(beat, repo)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await this.repo().drive(tracked)
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -125,6 +138,29 @@ const withNewline = (content: string): string => (content.endsWith("\n") ? conte
|
|
|
125
138
|
|
|
126
139
|
const args = (list: string): string[] => list.split(" ").filter((arg) => arg !== "")
|
|
127
140
|
|
|
141
|
+
const neverUsed = (world: TestkitWorld): string[] => {
|
|
142
|
+
const used = usedActors.get(world) ?? new Set<string>()
|
|
143
|
+
const scripted = new Set([
|
|
144
|
+
...world.actions.keys(),
|
|
145
|
+
...world.verdicts.keys(),
|
|
146
|
+
...world.conditionalVerdicts.keys(),
|
|
147
|
+
])
|
|
148
|
+
return [...scripted].filter((step) => !used.has(step))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An outcome step: a failure ends with where the process rests. */
|
|
152
|
+
const outcome =
|
|
153
|
+
<A extends unknown[]>(check: (world: TestkitWorld, ...rest: A) => unknown) =>
|
|
154
|
+
async (world: TestkitWorld, ...rest: A): Promise<void> => {
|
|
155
|
+
try {
|
|
156
|
+
await check(world, ...rest)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
159
|
+
failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`
|
|
160
|
+
throw failure
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
128
164
|
Given("a gtd config with:", (world: TestkitWorld, source: string) => {
|
|
129
165
|
world.configure(withNewline(source))
|
|
130
166
|
})
|
|
@@ -337,74 +373,86 @@ When("the process is driven", async (world: TestkitWorld) => {
|
|
|
337
373
|
|
|
338
374
|
Then(
|
|
339
375
|
"opening door {string} is refused with {string}",
|
|
340
|
-
async (world
|
|
341
|
-
await
|
|
342
|
-
},
|
|
376
|
+
outcome(async (world, door: string, message: string) => {
|
|
377
|
+
await refusedWith(world.repo().door(door), message)
|
|
378
|
+
}),
|
|
343
379
|
)
|
|
344
380
|
|
|
345
381
|
Then(
|
|
346
382
|
"starting workflow {string} with {string} is refused with {string}",
|
|
347
|
-
async (world
|
|
348
|
-
await
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
},
|
|
383
|
+
outcome(async (world, workflow: string, list: string, message: string) => {
|
|
384
|
+
await refusedWith(world.repo().start(workflow, ...args(list)), message)
|
|
385
|
+
}),
|
|
352
386
|
)
|
|
353
387
|
|
|
354
|
-
Then(
|
|
355
|
-
|
|
356
|
-
|
|
388
|
+
Then(
|
|
389
|
+
"landing is refused with {string}",
|
|
390
|
+
outcome(async (world, message: string) => {
|
|
391
|
+
await refusedWith(world.repo().land(), message)
|
|
392
|
+
}),
|
|
393
|
+
)
|
|
357
394
|
|
|
358
395
|
Then(
|
|
359
396
|
"driving the process is refused with {string}",
|
|
360
|
-
async (world
|
|
361
|
-
await
|
|
362
|
-
},
|
|
397
|
+
outcome(async (world, message: string) => {
|
|
398
|
+
await refusedWith(world.drive(), message)
|
|
399
|
+
}),
|
|
363
400
|
)
|
|
364
401
|
|
|
365
402
|
Then(
|
|
366
403
|
"driving the process is refused without {string}",
|
|
367
|
-
async (world
|
|
368
|
-
await
|
|
369
|
-
|
|
404
|
+
outcome(async (world, message: string) => {
|
|
405
|
+
const got = await refusal(world.drive(), `without "${message}"`)
|
|
406
|
+
if (got.includes(message))
|
|
407
|
+
assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`)
|
|
408
|
+
}),
|
|
370
409
|
)
|
|
371
410
|
|
|
372
|
-
Then(
|
|
373
|
-
|
|
374
|
-
|
|
411
|
+
Then(
|
|
412
|
+
"the process rests at {string}",
|
|
413
|
+
outcome(async (world, step: string) => {
|
|
414
|
+
assert.strictEqual((await world.repo().next()).state, step)
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
375
417
|
|
|
376
|
-
Then(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
418
|
+
Then(
|
|
419
|
+
"the resting step's content contains {string}",
|
|
420
|
+
outcome(async (world, text: string) => {
|
|
421
|
+
assertContains("the resting step's content", text, (await world.repo().next()).content)
|
|
422
|
+
}),
|
|
423
|
+
)
|
|
380
424
|
|
|
381
425
|
Then(
|
|
382
426
|
"the resting step's content contains the hash of the commit {string}",
|
|
383
|
-
async (world
|
|
427
|
+
outcome(async (world, subject: string) => {
|
|
384
428
|
const repo = world.repo()
|
|
385
429
|
const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
|
|
386
430
|
assert.ok(hash !== undefined, `no commit "${subject}"`)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
},
|
|
431
|
+
assertContains("the resting step's content", hash, (await repo.next()).content)
|
|
432
|
+
}),
|
|
390
433
|
)
|
|
391
434
|
|
|
392
435
|
Then(
|
|
393
436
|
"the resting step's content does not contain {string}",
|
|
394
|
-
async (world
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
},
|
|
437
|
+
outcome(async (world, text: string) => {
|
|
438
|
+
assertLacks("the resting step's content", text, (await world.repo().next()).content)
|
|
439
|
+
}),
|
|
398
440
|
)
|
|
399
441
|
|
|
400
|
-
Then(
|
|
401
|
-
|
|
402
|
-
|
|
442
|
+
Then(
|
|
443
|
+
"the last commit subject is {string}",
|
|
444
|
+
outcome((world, subject: string) => {
|
|
445
|
+
assert.strictEqual(world.repo().subjects().at(-1), subject)
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
403
448
|
|
|
404
|
-
Then(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
449
|
+
Then(
|
|
450
|
+
"the commit subjects end with:",
|
|
451
|
+
outcome((world, list: string) => {
|
|
452
|
+
const expected = list.split("\n").filter((line) => line.trim() !== "")
|
|
453
|
+
assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
|
|
454
|
+
}),
|
|
455
|
+
)
|
|
408
456
|
|
|
409
457
|
const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
|
|
410
458
|
const result = await world.repo().gtd(...args(list))
|
|
@@ -414,61 +462,77 @@ const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
|
|
|
414
462
|
|
|
415
463
|
Then(
|
|
416
464
|
"the output of gtd {string} contains {string}",
|
|
417
|
-
async (world
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
},
|
|
465
|
+
outcome(async (world, list: string, text: string) => {
|
|
466
|
+
assertContains(`the output of gtd ${list}`, text, await outputOf(world, list))
|
|
467
|
+
}),
|
|
421
468
|
)
|
|
422
469
|
|
|
423
470
|
Then(
|
|
424
471
|
"the output of gtd {string} does not contain {string}",
|
|
425
|
-
async (world
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
},
|
|
472
|
+
outcome(async (world, list: string, text: string) => {
|
|
473
|
+
assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list))
|
|
474
|
+
}),
|
|
429
475
|
)
|
|
430
476
|
|
|
431
477
|
Then(
|
|
432
478
|
"the output lines of gtd {string} start with:",
|
|
433
|
-
async (world
|
|
479
|
+
outcome(async (world, list: string, prefixes: string) => {
|
|
434
480
|
const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "")
|
|
435
481
|
const expected = prefixes.split("\n").filter((line) => line.trim() !== "")
|
|
436
|
-
|
|
437
|
-
out.
|
|
438
|
-
|
|
482
|
+
const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`
|
|
483
|
+
assert.strictEqual(out.length, expected.length, sides)
|
|
484
|
+
out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), sides))
|
|
485
|
+
}),
|
|
439
486
|
)
|
|
440
487
|
|
|
441
|
-
Then(
|
|
442
|
-
|
|
443
|
-
|
|
488
|
+
Then(
|
|
489
|
+
"{string} contains {string}",
|
|
490
|
+
outcome((world, path: string, text: string) => {
|
|
491
|
+
assertContains(`"${path}"`, text, world.repo().read(path))
|
|
492
|
+
}),
|
|
493
|
+
)
|
|
444
494
|
|
|
445
|
-
Then(
|
|
446
|
-
|
|
447
|
-
|
|
495
|
+
Then(
|
|
496
|
+
"{string} contains:",
|
|
497
|
+
outcome((world, path: string, text: string) => {
|
|
498
|
+
assertContains(`"${path}"`, text, world.repo().read(path))
|
|
499
|
+
}),
|
|
500
|
+
)
|
|
448
501
|
|
|
449
502
|
Then(
|
|
450
503
|
"{string} contains the short hash of the commit {string}",
|
|
451
|
-
(world
|
|
504
|
+
outcome((world, path: string, subject: string) => {
|
|
452
505
|
const repo = world.repo()
|
|
453
506
|
const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
|
|
454
507
|
assert.ok(hash !== undefined, `no commit "${subject}"`)
|
|
455
|
-
|
|
456
|
-
},
|
|
508
|
+
assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path))
|
|
509
|
+
}),
|
|
457
510
|
)
|
|
458
511
|
|
|
459
512
|
Then(
|
|
460
513
|
"{string} contains {string} exactly once",
|
|
461
|
-
(world
|
|
514
|
+
outcome((world, path: string, text: string) => {
|
|
462
515
|
const content = world.repo().read(path) ?? ""
|
|
463
|
-
|
|
464
|
-
|
|
516
|
+
const times = content.split(text).length - 1
|
|
517
|
+
assert.strictEqual(
|
|
518
|
+
times,
|
|
519
|
+
1,
|
|
520
|
+
`"${path}" contains "${text}" ${times} times; it holds:\n${content}`,
|
|
521
|
+
)
|
|
522
|
+
}),
|
|
465
523
|
)
|
|
466
524
|
|
|
467
|
-
Then(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
})
|
|
525
|
+
Then(
|
|
526
|
+
"{string} does not contain {string}",
|
|
527
|
+
outcome((world, path: string, text: string) => {
|
|
528
|
+
assertLacks(`"${path}"`, text, world.repo().read(path))
|
|
529
|
+
}),
|
|
530
|
+
)
|
|
471
531
|
|
|
472
|
-
Then(
|
|
473
|
-
|
|
474
|
-
|
|
532
|
+
Then(
|
|
533
|
+
"{string} does not exist",
|
|
534
|
+
outcome((world, path: string) => {
|
|
535
|
+
const content = world.repo().read(path)
|
|
536
|
+
assert.strictEqual(content, undefined, `"${path}" exists; it holds:\n${content}`)
|
|
537
|
+
}),
|
|
538
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { agent, human } from "../flows/index.js"
|
|
3
|
+
import { testRepo } from "../testkit/index.js"
|
|
4
|
+
import { assertContains, assertLacks, refusedWith, restDescription } from "./messages.js"
|
|
5
|
+
|
|
6
|
+
const flow = async (): Promise<void> => {
|
|
7
|
+
await agent("plan", "Write PLAN.md.\nKeep it short.\n3\n4\n5\n6\n7")
|
|
8
|
+
await human("sign-off")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const thrown = async (fn: () => unknown): Promise<string> => {
|
|
12
|
+
try {
|
|
13
|
+
await fn()
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return (error as Error).message
|
|
16
|
+
}
|
|
17
|
+
throw new Error("did not throw")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("restDescription", () => {
|
|
21
|
+
it("names the step, its kind and actor, the content's head, the last commits and the unused actors", async () => {
|
|
22
|
+
const repo = testRepo({ workflow: { default: flow } })
|
|
23
|
+
repo.write("go", "\n")
|
|
24
|
+
await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
25
|
+
expect(await restDescription(() => repo, ["repair", "ready"])).toBe(
|
|
26
|
+
[
|
|
27
|
+
"where the process rests:",
|
|
28
|
+
' step "sign-off" (message, human)',
|
|
29
|
+
" content:",
|
|
30
|
+
" | sign-off",
|
|
31
|
+
" last commits:",
|
|
32
|
+
" chore: initial commit",
|
|
33
|
+
" gtd(agent): plan → sign-off",
|
|
34
|
+
' actors never used: "repair", "ready"',
|
|
35
|
+
].join("\n"),
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("cuts long content to its first lines", async () => {
|
|
40
|
+
const repo = testRepo({ workflow: { default: flow } })
|
|
41
|
+
repo.write("go", "\n")
|
|
42
|
+
const description = await restDescription(() => repo, [])
|
|
43
|
+
expect(description).toContain('step "plan" (prompt, agent)')
|
|
44
|
+
expect(description).toContain(" | 5\n | … 2 more lines")
|
|
45
|
+
expect(description).not.toContain("actors never used")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("says why the rest is unknown instead of throwing", async () => {
|
|
49
|
+
const description = await restDescription(() => {
|
|
50
|
+
throw new Error("no gtd config")
|
|
51
|
+
}, [])
|
|
52
|
+
expect(description).toBe("where the process rests:\n unknown — no gtd config")
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("refusedWith", () => {
|
|
57
|
+
it("says nothing was refused when the attempt succeeds", async () => {
|
|
58
|
+
expect(await thrown(() => refusedWith(Promise.resolve(), "nope"))).toBe(
|
|
59
|
+
'expected a refusal with "nope", but nothing was refused',
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("prints the refusal it got when the text is missing", async () => {
|
|
64
|
+
expect(await thrown(() => refusedWith(Promise.reject(new Error("other")), "nope"))).toBe(
|
|
65
|
+
'expected a refusal with "nope", but it was refused with:\nother',
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("passes on a refusal carrying the text", async () => {
|
|
70
|
+
await refusedWith(Promise.reject(new Error("a nope b")), "nope")
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe("contains", () => {
|
|
75
|
+
it("prints the text looked for and what was there", async () => {
|
|
76
|
+
expect(await thrown(() => assertContains('"a.md"', "x", "y\n"))).toBe(
|
|
77
|
+
'"a.md" does not contain "x"; it holds:\ny\n',
|
|
78
|
+
)
|
|
79
|
+
expect(await thrown(() => assertLacks('"a.md"', "y", "y\n"))).toBe(
|
|
80
|
+
'"a.md" contains "y"; it holds:\ny\n',
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("says a missing file does not exist", async () => {
|
|
85
|
+
expect(await thrown(() => assertContains('"a.md"', "x", undefined))).toBe(
|
|
86
|
+
'"a.md" does not exist, so it cannot contain "x"',
|
|
87
|
+
)
|
|
88
|
+
expect(await thrown(() => assertLacks('"a.md"', "x", undefined))).toBe('"a.md" does not exist')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert"
|
|
2
|
+
import type { TestRepo } from "../testkit/index.js"
|
|
3
|
+
|
|
4
|
+
const CONTENT_LINES = 5
|
|
5
|
+
const SUBJECTS = 5
|
|
6
|
+
|
|
7
|
+
const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
8
|
+
|
|
9
|
+
const beatLines = async (repo: TestRepo): Promise<string[]> => {
|
|
10
|
+
try {
|
|
11
|
+
const beat = await repo.next()
|
|
12
|
+
const content = beat.content.trimEnd().split("\n")
|
|
13
|
+
const more = content.length - CONTENT_LINES
|
|
14
|
+
return [
|
|
15
|
+
`step "${beat.state}" (${beat.kind}, ${beat.actor}${beat.idle ? ", idle" : ""})`,
|
|
16
|
+
"content:",
|
|
17
|
+
...content.slice(0, CONTENT_LINES).map((line) => ` | ${line}`),
|
|
18
|
+
...(more > 0 ? [` | … ${more} more lines`] : []),
|
|
19
|
+
]
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return [`unknown — gtd next failed: ${reason(error)}`]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
|
|
26
|
+
export const restDescription = async (
|
|
27
|
+
repo: () => TestRepo,
|
|
28
|
+
neverUsed: readonly string[],
|
|
29
|
+
): Promise<string> => {
|
|
30
|
+
let lines: string[]
|
|
31
|
+
try {
|
|
32
|
+
const r = repo()
|
|
33
|
+
lines = [
|
|
34
|
+
...(await beatLines(r)),
|
|
35
|
+
"last commits:",
|
|
36
|
+
...r
|
|
37
|
+
.subjects()
|
|
38
|
+
.slice(-SUBJECTS)
|
|
39
|
+
.map((subject) => ` ${subject}`),
|
|
40
|
+
]
|
|
41
|
+
} catch (error) {
|
|
42
|
+
lines = [`unknown — ${reason(error)}`]
|
|
43
|
+
}
|
|
44
|
+
if (neverUsed.length > 0)
|
|
45
|
+
lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`)
|
|
46
|
+
return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Both sides of a failed `contains`: the text looked for, and what was there. */
|
|
50
|
+
const containsFailure = (
|
|
51
|
+
where: string,
|
|
52
|
+
text: string,
|
|
53
|
+
content: string | undefined,
|
|
54
|
+
expected: boolean,
|
|
55
|
+
): string =>
|
|
56
|
+
content === undefined
|
|
57
|
+
? `${where} does not exist${expected ? `, so it cannot contain "${text}"` : ""}`
|
|
58
|
+
: `${where} ${expected ? "does not contain" : "contains"} "${text}"; it holds:\n${content}`
|
|
59
|
+
|
|
60
|
+
/** What `attempt` was refused with; fails when it was not refused at all. */
|
|
61
|
+
export const refusal = async (attempt: Promise<unknown>, expectation: string): Promise<string> => {
|
|
62
|
+
try {
|
|
63
|
+
await attempt
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error instanceof Error ? error.message : String(error)
|
|
66
|
+
}
|
|
67
|
+
assert.fail(`expected a refusal ${expectation}, but nothing was refused`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const refusedWith = async (attempt: Promise<unknown>, message: string): Promise<void> => {
|
|
71
|
+
const got = await refusal(attempt, `with "${message}"`)
|
|
72
|
+
if (!got.includes(message))
|
|
73
|
+
assert.fail(`expected a refusal with "${message}", but it was refused with:\n${got}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const assertContains = (where: string, text: string, content: string | undefined): void => {
|
|
77
|
+
if (content === undefined || !content.includes(text))
|
|
78
|
+
assert.fail(containsFailure(where, text, content, true))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const assertLacks = (where: string, text: string, content: string | undefined): void => {
|
|
82
|
+
if (content === undefined || content.includes(text))
|
|
83
|
+
assert.fail(containsFailure(where, text, content, false))
|
|
84
|
+
}
|
|
@@ -9,8 +9,13 @@ import { readFileSync } from "node:fs"
|
|
|
9
9
|
import { resolve } from "node:path"
|
|
10
10
|
import { createRequire } from "node:module"
|
|
11
11
|
import { describe, expect, it } from "vitest"
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
EDIT_COMMAND,
|
|
14
|
+
DOOR_COMMAND,
|
|
15
|
+
MINIMAL_DRIVER,
|
|
16
|
+
renderBriefing,
|
|
17
|
+
WORKFLOWS_REPO,
|
|
18
|
+
} from "./Install.js"
|
|
14
19
|
|
|
15
20
|
const SUITE_PATHS = ["gtd-build", "gtd-edit", "gtd-door"]
|
|
16
21
|
const SUPERSEDED_PATHS = ["gtd-fix", "gtd-review"]
|
|
@@ -110,6 +115,13 @@ describe("renderBriefing", () => {
|
|
|
110
115
|
expect(briefing).not.toContain(".gtdrc")
|
|
111
116
|
})
|
|
112
117
|
|
|
118
|
+
it("says gtd ships no workflow, offering gtd-workflows or the user's own", () => {
|
|
119
|
+
const briefing = renderBriefing()
|
|
120
|
+
expect(briefing).toContain("gtd ships no")
|
|
121
|
+
expect(briefing).toContain(`git clone ${WORKFLOWS_REPO}`)
|
|
122
|
+
expect(briefing).toMatch(/\*\*Their own workflow\.\*\*/)
|
|
123
|
+
})
|
|
124
|
+
|
|
113
125
|
it("instructs committing the config before the first drive", () => {
|
|
114
126
|
expect(renderBriefing()).toMatch(/commit (it|the config).{0,40}before.{0,20}drive/is)
|
|
115
127
|
})
|
|
@@ -425,12 +437,3 @@ describe("DOOR_COMMAND", () => {
|
|
|
425
437
|
expect(DOOR_COMMAND).toMatch(/usage: gtd-door <name> \[args\.\.\.\]" >&2\n\s*exit 2/)
|
|
426
438
|
})
|
|
427
439
|
})
|
|
428
|
-
|
|
429
|
-
describe("bundled door lists", () => {
|
|
430
|
-
it("the scaffold's exports stay feature, review, fix, never rollout or cleanup", () => {
|
|
431
|
-
const code = CONFIG_SCAFFOLD.split("\n")
|
|
432
|
-
.filter((l) => !l.startsWith("//"))
|
|
433
|
-
.join("\n")
|
|
434
|
-
expect(code).not.toMatch(/rollout|cleanup/)
|
|
435
|
-
})
|
|
436
|
-
})
|
package/src/install/Install.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `MINIMAL_DRIVER` is pinned equal to https://github.com/pmelab/gtd/blob/main/docs/driver.md's "A complete minimal driver" fenced bash block by `Install.test.ts`, so the two can never drift.
|
|
2
2
|
import { GTD_VERSION } from "./Version.js"
|
|
3
3
|
|
|
4
|
+
export const WORKFLOWS_REPO = "https://github.com/pmelab/gtd-workflows"
|
|
5
|
+
|
|
4
6
|
export const MINIMAL_DRIVER = `#!/usr/bin/env sh
|
|
5
7
|
set -eu
|
|
6
8
|
|
|
@@ -82,7 +84,7 @@ while :; do
|
|
|
82
84
|
system="$(gtd next --json=system)"
|
|
83
85
|
validate="$(gtd next --json=validate)"
|
|
84
86
|
log="$(gtd next --json=log)"
|
|
85
|
-
# \`model\` is a hint, not a model name —
|
|
87
|
+
# \`model\` is a hint, not a model name — gtd-workflows' default ships
|
|
86
88
|
# \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
|
|
87
89
|
# from your environment; unset, the hint is dropped and the agent runs
|
|
88
90
|
# its own default. Any other value is a real model name.
|
|
@@ -420,14 +422,37 @@ follow in the same numbered list.
|
|
|
420
422
|
existing config, the project's own README/docs) and ask the user what
|
|
421
423
|
they're trying to accomplish with gtd here — don't assume a fresh
|
|
422
424
|
install or silently start a process on their behalf.
|
|
423
|
-
2. **Make sure a \`gtd.config.ts\` governs the repository.**
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
425
|
+
2. **Make sure a \`gtd.config.ts\` governs the repository.** gtd ships no
|
|
426
|
+
workflow: the config imports the workflows it runs. Ask the user which
|
|
427
|
+
way they want:
|
|
428
|
+
- **A workflow from ${WORKFLOWS_REPO}.** Its \`default\` is a feature →
|
|
429
|
+
review → fix workflow. It is used from a checkout pinned on this
|
|
430
|
+
machine:
|
|
431
|
+
|
|
432
|
+
\`\`\`sh
|
|
433
|
+
git clone ${WORKFLOWS_REPO} ~/.local/share/gtd/workflows
|
|
434
|
+
~/.local/share/gtd/workflows/bin/gtd-wf pin origin/main # or a tag
|
|
435
|
+
~/.local/share/gtd/workflows/bin/gtd-wf config default > gtd.config.ts
|
|
436
|
+
\`\`\`
|
|
437
|
+
|
|
438
|
+
\`pin\` checks the ref out and bundles each workflow; \`config\` prints a
|
|
439
|
+
config importing the bundle by its ABSOLUTE path. Tell the user what
|
|
440
|
+
that means: the committed config works only where the checkout sits at
|
|
441
|
+
that same path, so a teammate or CI needs their own checkout there (or
|
|
442
|
+
their own config). If a checkout exists already, \`gtd-wf status\`
|
|
443
|
+
shows its pin — reuse it rather than cloning again. Read the
|
|
444
|
+
repository's README for the workflows it offers and their options.
|
|
445
|
+
- **Their own workflow.** A workflow is TypeScript the config exports,
|
|
446
|
+
written against \`@pmelab/gtd/flows\` — see
|
|
447
|
+
https://github.com/pmelab/gtd/blob/main/docs/configuration.md, and
|
|
448
|
+
https://github.com/pmelab/gtd/blob/main/docs/testing.md to test it. A
|
|
449
|
+
copy of a gtd-workflows workflow (\`gtd-wf new <name> --from default\`
|
|
450
|
+
in a clone of it) is a good start.
|
|
451
|
+
|
|
452
|
+
Repository settings — steering-file modes, \`gtd ui\`, per-scope skills
|
|
453
|
+
and access — go in the config's \`config\` export; workflow settings are
|
|
454
|
+
options of the workflow factory it exports.
|
|
455
|
+
3. **Commit the config before the first drive.** An uncommitted config is a
|
|
431
456
|
pending change, so starting a process without committing it first turns
|
|
432
457
|
the config into the very first thing the process plans against. Commit
|
|
433
458
|
it, THEN start driving.
|
|
@@ -453,7 +478,8 @@ follow in the same numbered list.
|
|
|
453
478
|
from every prompt line and writing no model exports, so every turn runs
|
|
454
479
|
on the chosen CLI's own default tier.
|
|
455
480
|
6. **If model hints are honored, resolve them to real model identifiers.**
|
|
456
|
-
|
|
481
|
+
A workflow may declare model hints — gtd-workflows' \`default\` ships
|
|
482
|
+
\`plannerModel: smart\` and \`coderModel: base\`.
|
|
457
483
|
**Those are opaque hints, not model names** — gtd never interprets them,
|
|
458
484
|
and passing \`--model smart\` to a real CLI fails on the first prompt
|
|
459
485
|
beat. Derive the chosen CLI's own available models at install time — its
|
|
@@ -550,11 +576,8 @@ hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
|
|
|
550
576
|
rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
|
|
551
577
|
straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
|
|
552
578
|
\`gtd doors\` lists the doors a repository offers (those of the workflows its
|
|
553
|
-
config exports —
|
|
554
|
-
\`
|
|
555
|
-
(remove it), which a config adds by exporting \`rollout\` from
|
|
556
|
-
\`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
|
|
557
|
-
\`@pmelab/gtd/workflows/cleanup\` — plus any it declares). Refuses with
|
|
579
|
+
config exports — gtd-workflows' \`default\` brings \`fix\` and
|
|
580
|
+
\`review [base]\` — plus any the config declares). Refuses with
|
|
558
581
|
\`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
|
|
559
582
|
missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
|
|
560
583
|
|
package/src/install/index.ts
CHANGED
|
@@ -175,7 +175,7 @@ const restName = (outcome: ReplayOutcome): string => {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
// Plain loops, a counter cap, and helper-driven branches: the shape the
|
|
178
|
-
//
|
|
178
|
+
// workflow might have.
|
|
179
179
|
const fixLoop = async () => {
|
|
180
180
|
await human("idle", { file: "TODO.md" })
|
|
181
181
|
for (let attempt = 1; ; attempt++) {
|
package/src/replay/index.ts
CHANGED
|
@@ -15,6 +15,6 @@ export {
|
|
|
15
15
|
type CommitSpec,
|
|
16
16
|
type JudgeVerdict,
|
|
17
17
|
} from "./Trailers.js"
|
|
18
|
-
export { accessShapeFault
|
|
18
|
+
export { accessShapeFault } from "./Access.js"
|
|
19
19
|
export { formatFault } from "./Format.js"
|
|
20
20
|
export { refNameFault } from "./RefName.js"
|
package/src/step/planStart.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ConfigDiscovery,
|
|
5
5
|
ConfigService,
|
|
6
6
|
multilineSetting,
|
|
7
|
+
ownFlagRefusal,
|
|
7
8
|
resolveStartVars,
|
|
8
9
|
StartFlags,
|
|
9
10
|
type ConfigFingerprint,
|
|
@@ -110,7 +111,11 @@ export const planStart = (
|
|
|
110
111
|
if (overrideRefusal !== undefined) return overrideRefusal
|
|
111
112
|
|
|
112
113
|
const baseOf = workflow.def.base
|
|
113
|
-
const
|
|
114
|
+
const flags = yield* StartFlags
|
|
115
|
+
const hostEnv = (yield* Host).env
|
|
116
|
+
const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv)
|
|
117
|
+
if (badFlag !== undefined) return refusal(`${commandLabel}: ${badFlag}`)
|
|
118
|
+
const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv)
|
|
114
119
|
const multiline = multilineSetting(vars)
|
|
115
120
|
if (multiline !== undefined) {
|
|
116
121
|
return refusal(
|