@pmelab/gtd 25.0.0 → 26.0.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 +2 -0
- package/dist/gtd.bundle.mjs +27 -2
- package/dist/testkit.mjs +27 -2
- package/package.json +2 -1
- package/src/cli/Cli.test.ts +25 -0
- package/src/cli/Cli.ts +12 -1
- package/src/flags/index.test.ts +67 -0
- package/src/flags/index.ts +39 -0
- package/src/workflow/vars.test.ts +16 -0
- package/src/workflow/vars.ts +4 -1
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ Or run without installing (prefix every `gtd` below with `npx`) — see
|
|
|
28
28
|
for the settings most projects tune. A process setting's value is committed to
|
|
29
29
|
Git history; keep secrets in an environment setting (see
|
|
30
30
|
[Settings](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#settings)).
|
|
31
|
+
`GTD_FLAG_<NAME>` variables toggle experimental features — see
|
|
32
|
+
[Feature flags](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#feature-flags).
|
|
31
33
|
|
|
32
34
|
Also install all three skill sources the bundled workflow names in its prompts —
|
|
33
35
|
none is optional. See
|
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -20957,6 +20957,26 @@ const restorability = (git, headHash, tipHash) => gen(function* () {
|
|
|
20957
20957
|
};
|
|
20958
20958
|
});
|
|
20959
20959
|
//#endregion
|
|
20960
|
+
//#region src/flags/index.ts
|
|
20961
|
+
const FLAGS$1 = [];
|
|
20962
|
+
const BOOL = {
|
|
20963
|
+
"": false,
|
|
20964
|
+
"0": false,
|
|
20965
|
+
false: false,
|
|
20966
|
+
"1": true,
|
|
20967
|
+
true: true
|
|
20968
|
+
};
|
|
20969
|
+
const resolveFlags = (env, registry = FLAGS$1) => {
|
|
20970
|
+
const out = {};
|
|
20971
|
+
for (const { name } of registry) {
|
|
20972
|
+
const variable = `GTD_FLAG_${name.toUpperCase()}`;
|
|
20973
|
+
const key = (env[variable] ?? "").toLowerCase();
|
|
20974
|
+
if (!Object.hasOwn(BOOL, key)) throw new Error(`${variable} must be 1, 0, true or false`);
|
|
20975
|
+
out[name] = BOOL[key];
|
|
20976
|
+
}
|
|
20977
|
+
return out;
|
|
20978
|
+
};
|
|
20979
|
+
//#endregion
|
|
20960
20980
|
//#region src/install/Version.ts
|
|
20961
20981
|
const _require = createRequire(import.meta.url);
|
|
20962
20982
|
const findPackageJson = (fromUrl) => {
|
|
@@ -38131,7 +38151,9 @@ const resolveVars = (workflowVars, entryVars, env) => {
|
|
|
38131
38151
|
...entryVars
|
|
38132
38152
|
};
|
|
38133
38153
|
for (const name of Object.keys(merged)) {
|
|
38134
|
-
const
|
|
38154
|
+
const key = PREFIX + name.toUpperCase();
|
|
38155
|
+
if (key.startsWith("GTD_FLAG_")) continue;
|
|
38156
|
+
const value = env[key];
|
|
38135
38157
|
if (value !== void 0) merged[name] = value;
|
|
38136
38158
|
}
|
|
38137
38159
|
return merged;
|
|
@@ -42051,7 +42073,10 @@ const runCli = (argv, io) => {
|
|
|
42051
42073
|
io.exit(2);
|
|
42052
42074
|
});
|
|
42053
42075
|
const out = bufferedArtifactOut(io);
|
|
42054
|
-
return
|
|
42076
|
+
return flatMap$4(Host, ({ env }) => try_({
|
|
42077
|
+
try: () => resolveFlags(env, io.flags),
|
|
42078
|
+
catch: (e) => new GtdError(e.message)
|
|
42079
|
+
})).pipe(flatMap$4(() => runCommand(plan.command, plan.json, out)), map$4(() => {
|
|
42055
42080
|
out.flush();
|
|
42056
42081
|
io.exit(0);
|
|
42057
42082
|
}), provide$1(io.layers(plan.verbose)), sandbox, catchAll((cause) => isInterruptedOnly(cause) ? failCause$2(cause) : report(io, plan.json.kind !== "off")(cause)));
|
package/dist/testkit.mjs
CHANGED
|
@@ -6085,6 +6085,26 @@ const restorability = (git, headHash, tipHash) => gen(function* () {
|
|
|
6085
6085
|
};
|
|
6086
6086
|
});
|
|
6087
6087
|
//#endregion
|
|
6088
|
+
//#region src/flags/index.ts
|
|
6089
|
+
const FLAGS$1 = [];
|
|
6090
|
+
const BOOL = {
|
|
6091
|
+
"": false,
|
|
6092
|
+
"0": false,
|
|
6093
|
+
false: false,
|
|
6094
|
+
"1": true,
|
|
6095
|
+
true: true
|
|
6096
|
+
};
|
|
6097
|
+
const resolveFlags = (env, registry = FLAGS$1) => {
|
|
6098
|
+
const out = {};
|
|
6099
|
+
for (const { name } of registry) {
|
|
6100
|
+
const variable = `GTD_FLAG_${name.toUpperCase()}`;
|
|
6101
|
+
const key = (env[variable] ?? "").toLowerCase();
|
|
6102
|
+
if (!Object.hasOwn(BOOL, key)) throw new Error(`${variable} must be 1, 0, true or false`);
|
|
6103
|
+
out[name] = BOOL[key];
|
|
6104
|
+
}
|
|
6105
|
+
return out;
|
|
6106
|
+
};
|
|
6107
|
+
//#endregion
|
|
6088
6108
|
//#region src/install/Version.ts
|
|
6089
6109
|
const _require = createRequire(import.meta.url);
|
|
6090
6110
|
const findPackageJson = (fromUrl) => {
|
|
@@ -7408,7 +7428,9 @@ const resolveVars = (workflowVars, entryVars, env) => {
|
|
|
7408
7428
|
...entryVars
|
|
7409
7429
|
};
|
|
7410
7430
|
for (const name of Object.keys(merged)) {
|
|
7411
|
-
const
|
|
7431
|
+
const key = PREFIX + name.toUpperCase();
|
|
7432
|
+
if (key.startsWith("GTD_FLAG_")) continue;
|
|
7433
|
+
const value = env[key];
|
|
7412
7434
|
if (value !== void 0) merged[name] = value;
|
|
7413
7435
|
}
|
|
7414
7436
|
return merged;
|
|
@@ -11303,7 +11325,10 @@ const runCli = (argv, io) => {
|
|
|
11303
11325
|
io.exit(2);
|
|
11304
11326
|
});
|
|
11305
11327
|
const out = bufferedArtifactOut(io);
|
|
11306
|
-
return
|
|
11328
|
+
return flatMap$4(Host, ({ env }) => try_({
|
|
11329
|
+
try: () => resolveFlags(env, io.flags),
|
|
11330
|
+
catch: (e) => new GtdError(e.message)
|
|
11331
|
+
})).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4(() => {
|
|
11307
11332
|
out.flush();
|
|
11308
11333
|
io.exit(0);
|
|
11309
11334
|
}), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"src/cli/",
|
|
70
70
|
"src/edge/",
|
|
71
71
|
"src/emit/",
|
|
72
|
+
"src/flags/",
|
|
72
73
|
"src/install/",
|
|
73
74
|
"src/platform/",
|
|
74
75
|
"src/replay/",
|
package/src/cli/Cli.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ToolCommands,
|
|
24
24
|
} from "./index.js"
|
|
25
25
|
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
|
+
import { Host } from "../platform/index.js"
|
|
26
27
|
import { type CommandRequirements } from "./Cli.js"
|
|
27
28
|
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
28
29
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
@@ -1164,6 +1165,30 @@ describe("runCli — layers() is never invoked for output/usage plans", () => {
|
|
|
1164
1165
|
})
|
|
1165
1166
|
})
|
|
1166
1167
|
|
|
1168
|
+
describe("runCli — a garbled GTD_FLAG_* value", () => {
|
|
1169
|
+
it("exits 1 naming the variable", async () => {
|
|
1170
|
+
const { io, captured } = capturingIo(
|
|
1171
|
+
() =>
|
|
1172
|
+
Layer.succeed(Host, {
|
|
1173
|
+
root: "/",
|
|
1174
|
+
home: "/",
|
|
1175
|
+
scratchDir: "/",
|
|
1176
|
+
realPath: (p: string) => Effect.succeed(p),
|
|
1177
|
+
env: { GTD_FLAG_BETA: "ture" },
|
|
1178
|
+
}) as unknown as Layer.Layer<CommandRequirements>,
|
|
1179
|
+
)
|
|
1180
|
+
await Effect.runPromise(
|
|
1181
|
+
runCli(["node", "gtd.js", "next"], {
|
|
1182
|
+
...io,
|
|
1183
|
+
flags: [{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "ops" }],
|
|
1184
|
+
}),
|
|
1185
|
+
)
|
|
1186
|
+
expect(captured().exitCode).toBe(1)
|
|
1187
|
+
expect(captured().stdout).toBe("")
|
|
1188
|
+
expect(captured().stderr).toContain("GTD_FLAG_BETA")
|
|
1189
|
+
})
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1167
1192
|
describe("runCli — exit codes", () => {
|
|
1168
1193
|
it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
|
|
1169
1194
|
const { io, captured } = capturingIo(throwingLayers)
|
package/src/cli/Cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NodeContext } from "@effect/platform-node"
|
|
2
2
|
import { Cause, Effect, Either, Layer } from "effect"
|
|
3
3
|
import {
|
|
4
|
+
GtdError,
|
|
4
5
|
GtdUsageError,
|
|
5
6
|
Narrator,
|
|
6
7
|
renderFailure,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
Workspace,
|
|
10
11
|
CommandRunner,
|
|
11
12
|
} from "../platform/index.js"
|
|
13
|
+
import { resolveFlags, type FlagDeclaration } from "../flags/index.js"
|
|
12
14
|
import { GTD_VERSION } from "../install/index.js"
|
|
13
15
|
import {
|
|
14
16
|
ConfigDiscovery,
|
|
@@ -1270,6 +1272,8 @@ export interface CliIo {
|
|
|
1270
1272
|
* writer when `false`.
|
|
1271
1273
|
*/
|
|
1272
1274
|
readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
|
|
1275
|
+
/** Test seam: the shipped registry is empty, so a garbled-value test cannot reach the failure path without injecting one. Drop it when the first real flag ships. */
|
|
1276
|
+
readonly flags?: readonly FlagDeclaration[]
|
|
1273
1277
|
}
|
|
1274
1278
|
|
|
1275
1279
|
// The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
|
|
@@ -1403,7 +1407,14 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
|
|
|
1403
1407
|
}
|
|
1404
1408
|
|
|
1405
1409
|
const out = bufferedArtifactOut(io)
|
|
1406
|
-
return
|
|
1410
|
+
return Effect.flatMap(Host, ({ env }) =>
|
|
1411
|
+
Effect.try({
|
|
1412
|
+
// Validate only: the result is discarded so a garbled value fails before any command runs.
|
|
1413
|
+
try: () => resolveFlags(env, io.flags),
|
|
1414
|
+
catch: (e) => new GtdError((e as Error).message),
|
|
1415
|
+
}),
|
|
1416
|
+
).pipe(
|
|
1417
|
+
Effect.flatMap(() => runCommand(plan.command, plan.json, out)),
|
|
1407
1418
|
// `flush()` fires here, on success, BEFORE `io.exit` — the one point
|
|
1408
1419
|
// where the whole buffered artifact is known-complete and safe to hand
|
|
1409
1420
|
// to stdout (a failure discards the buffer instead — see
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
3
|
+
|
|
4
|
+
const registry = [
|
|
5
|
+
{ name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
|
|
6
|
+
{ name: "new_review", owner: "pmelab", expiry: "2099-01-01", kind: "ops" },
|
|
7
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
8
|
+
|
|
9
|
+
describe("resolveFlags", () => {
|
|
10
|
+
it.each(["1", "true", "TRUE", "True"])("%s turns a flag on", (value) => {
|
|
11
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each(["0", "false", "FALSE"])("%s turns a flag off", (value) => {
|
|
15
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads unset and empty as off, with every declared flag a key", () => {
|
|
19
|
+
expect(resolveFlags({ GTD_FLAG_NEW_REVIEW: "" }, registry)).toEqual({
|
|
20
|
+
alpha: false,
|
|
21
|
+
new_review: false,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it.each(["ture", "constructor", "__proto__", "toString"])(
|
|
26
|
+
"throws naming the variable, not the value %s",
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: value }, registry)).toThrow(
|
|
29
|
+
/^GTD_FLAG_NEW_REVIEW must be 1, 0, true or false$/,
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
it("reports the first garbled flag in registry order", () => {
|
|
35
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: "x", GTD_FLAG_ALPHA: "y" }, registry)).toThrow(
|
|
36
|
+
/^GTD_FLAG_ALPHA /,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("ignores an undeclared GTD_FLAG_* key with a garbled value", () => {
|
|
41
|
+
expect(() => resolveFlags({ GTD_FLAG_NOPE: "ture" }, registry)).not.toThrow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("resolves a flag past its expiry like any other", () => {
|
|
45
|
+
expect(registry[0].expiry < new Date().toISOString().slice(0, 10)).toBe(true)
|
|
46
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "1" }, registry).alpha).toBe(true)
|
|
47
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "0" }, registry).alpha).toBe(false)
|
|
48
|
+
expect(resolveFlags({}, registry).alpha).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("Flags<R> rejects a misspelt name", () => {
|
|
52
|
+
const flags: Flags<typeof registry> = resolveFlags({}, registry)
|
|
53
|
+
// @ts-expect-error misspelt flag name
|
|
54
|
+
void flags.alpah
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("FLAGS", () => {
|
|
59
|
+
it("declares settable, unique variable names", () => {
|
|
60
|
+
// Vacuous while FLAGS is empty; guards the first declared flag.
|
|
61
|
+
const names = (FLAGS as readonly FlagDeclaration[]).map(({ name }) => {
|
|
62
|
+
expect(name).toMatch(/^[a-z][a-z0-9_]*$/)
|
|
63
|
+
return name
|
|
64
|
+
})
|
|
65
|
+
expect(new Set(names).size).toBe(names.length)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A flag may change how a step runs, never which step comes next: flags are not recorded in
|
|
2
|
+
// history, so replay must not read them. No lint rule enforces this.
|
|
3
|
+
|
|
4
|
+
export type FlagKind = "release" | "ops" | "experiment"
|
|
5
|
+
|
|
6
|
+
export interface FlagDeclaration {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly owner: string
|
|
9
|
+
/** ISO `YYYY-MM-DD`; never enforced at runtime. */
|
|
10
|
+
readonly expiry: string
|
|
11
|
+
readonly kind: FlagKind
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// fallow-ignore-next-line unused-export
|
|
15
|
+
export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
|
|
16
|
+
|
|
17
|
+
/** A misspelt name is a compile error. */
|
|
18
|
+
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
19
|
+
readonly [N in R[number]["name"]]: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BOOL: Record<string, boolean> = { "": false, "0": false, false: false, "1": true, true: true }
|
|
23
|
+
|
|
24
|
+
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
25
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
26
|
+
registry: R = FLAGS as unknown as R,
|
|
27
|
+
): Flags<R> => {
|
|
28
|
+
const out: Record<string, boolean> = {}
|
|
29
|
+
for (const { name } of registry) {
|
|
30
|
+
const variable = `GTD_FLAG_${name.toUpperCase()}`
|
|
31
|
+
const raw = env[variable] ?? ""
|
|
32
|
+
const key = raw.toLowerCase()
|
|
33
|
+
if (!Object.hasOwn(BOOL, key)) {
|
|
34
|
+
throw new Error(`${variable} must be 1, 0, true or false`)
|
|
35
|
+
}
|
|
36
|
+
out[name] = BOOL[key]!
|
|
37
|
+
}
|
|
38
|
+
return out as Flags<R>
|
|
39
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { resolveVars } from "./vars.js"
|
|
3
|
+
|
|
4
|
+
describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
|
|
5
|
+
it.each(["flag_x", "FLAG_X", "Flag_x"])("%s keeps its default with GTD_FLAG_X set", (name) => {
|
|
6
|
+
expect(resolveVars({ [name]: "off" }, {}, { GTD_FLAG_X: "on" })).toEqual({ [name]: "off" })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it.each([
|
|
10
|
+
["flags", "GTD_FLAGS"],
|
|
11
|
+
["flagship", "GTD_FLAGSHIP"],
|
|
12
|
+
["my_flag_x", "GTD_MY_FLAG_X"],
|
|
13
|
+
])("%s still takes %s", (name, key) => {
|
|
14
|
+
expect(resolveVars({ [name]: "off" }, {}, { [key]: "on" })).toEqual({ [name]: "on" })
|
|
15
|
+
})
|
|
16
|
+
})
|
package/src/workflow/vars.ts
CHANGED
|
@@ -13,7 +13,10 @@ export const resolveVars = (
|
|
|
13
13
|
): Record<string, string> => {
|
|
14
14
|
const merged = { ...workflowVars, ...entryVars }
|
|
15
15
|
for (const name of Object.keys(merged)) {
|
|
16
|
-
const
|
|
16
|
+
const key = PREFIX + name.toUpperCase()
|
|
17
|
+
// GTD_FLAG_* belongs to feature flags; a flag_* setting loses its env override.
|
|
18
|
+
if (key.startsWith("GTD_FLAG_")) continue
|
|
19
|
+
const value = env[key]
|
|
17
20
|
if (value !== undefined) merged[name] = value
|
|
18
21
|
}
|
|
19
22
|
return merged
|