@pmelab/gtd 21.1.0 → 22.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "21.1.0",
3
+ "version": "22.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -512,7 +512,10 @@ can add its own. `gtd --workflow <name>` starts any workflow by name.
512
512
  The workflow itself is a plain async TypeScript function, and the
513
513
  `gtd.config.ts` at the repository root names it: the one `gtd install` writes
514
514
  re-exports the shipped workflows from `@pmelab/gtd/workflows`, and the pieces
515
- they are built from are exported for yours to reuse. See
515
+ they are built from are exported for yours to reuse. Each shipped workflow is a
516
+ factory — `feature({ testCommand: "npm run ci" })` — whose options gtd validates
517
+ when it loads the config: a misspelled or mistyped one fails every command,
518
+ naming the workflow, the key and what was expected. See
516
519
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md) —
517
520
  and remember gtd evaluates that file on every command. A shared workflow package
518
521
  bundling its own `@pmelab/gtd` is checked against the running gtd and refused,
@@ -32693,6 +32693,71 @@ const requireRevert = (edited, base) => {
32693
32693
  refuse(`gtd land: require-revert: ${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\`, then \`gtd land\` again.`);
32694
32694
  };
32695
32695
  //#endregion
32696
+ //#region src/flows/factory.ts
32697
+ const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
32698
+ /**
32699
+ * Define a workflow configured by options. `schema` is any Standard Schema
32700
+ * (https://standardschema.dev); it should reject unknown keys, so a removed or
32701
+ * misspelled option fails at load. Exported from `gtd.config.ts`, the factory
32702
+ * itself and a call's result are both workflows.
32703
+ */
32704
+ const defineWorkflow = (schema, build) => {
32705
+ const internals = {
32706
+ schema,
32707
+ build
32708
+ };
32709
+ const factory = (options) => ({
32710
+ [FACTORY]: {
32711
+ ...internals,
32712
+ options
32713
+ },
32714
+ then: () => {
32715
+ throw new Error("a configured workflow is not a flow — compose fixFlow/reviewFlow instead");
32716
+ }
32717
+ });
32718
+ return Object.assign(factory, { [FACTORY]: internals });
32719
+ };
32720
+ const internalsOf = (value) => (typeof value === "function" || typeof value === "object" && value !== null) && FACTORY in value ? value[FACTORY] : void 0;
32721
+ const isFactoryWorkflow = (value) => internalsOf(value) !== void 0;
32722
+ const segment = (key) => {
32723
+ const raw = typeof key === "object" ? key.key : key;
32724
+ return typeof raw === "number" ? raw : String(raw);
32725
+ };
32726
+ /**
32727
+ * Validate a factory workflow's options — those it was called with, `overrides`
32728
+ * on top — and build it. `undefined` for an export that is no factory workflow.
32729
+ */
32730
+ const configure$1 = (value, overrides = {}) => {
32731
+ const internals = internalsOf(value);
32732
+ if (internals === void 0) return void 0;
32733
+ const options = {
32734
+ ...internals.options,
32735
+ ...overrides
32736
+ };
32737
+ const result = internals.schema["~standard"].validate(options);
32738
+ if (result instanceof Promise) return {
32739
+ ok: false,
32740
+ issues: [{
32741
+ path: [],
32742
+ message: "the options schema validates asynchronously — gtd needs a synchronous one"
32743
+ }]
32744
+ };
32745
+ if (result.issues !== void 0) return {
32746
+ ok: false,
32747
+ issues: result.issues.length === 0 ? [{
32748
+ path: [],
32749
+ message: "the options schema rejected them without saying why"
32750
+ }] : result.issues.map((issue) => ({
32751
+ path: (issue.path ?? []).map(segment),
32752
+ message: issue.message
32753
+ }))
32754
+ };
32755
+ return {
32756
+ ok: true,
32757
+ build: internals.build(result.value)
32758
+ };
32759
+ };
32760
+ //#endregion
32696
32761
  //#region src/flows/index.ts
32697
32762
  var flows_exports = /* @__PURE__ */ __exportAll({
32698
32763
  FLOWS_PROTOCOL: () => 1,
@@ -32706,12 +32771,15 @@ var flows_exports = /* @__PURE__ */ __exportAll({
32706
32771
  checkScript: () => checkScript,
32707
32772
  codeChanges: () => codeChanges,
32708
32773
  codeThreads: () => codeThreads,
32774
+ configure: () => configure$1,
32775
+ defineWorkflow: () => defineWorkflow,
32709
32776
  env: () => env,
32710
32777
  glob: () => glob,
32711
32778
  hasThreadFor: () => hasThreadFor,
32712
32779
  head: () => head,
32713
32780
  human: () => human,
32714
32781
  installContext: () => installContext,
32782
+ isFactoryWorkflow: () => isFactoryWorkflow,
32715
32783
  judge: () => judge,
32716
32784
  moveScript: () => moveScript,
32717
32785
  numeric: () => numeric,
@@ -49589,10 +49657,10 @@ const replay = async (input) => {
49589
49657
  };
49590
49658
  //#endregion
49591
49659
  //#region src/workflow/ConfigSchema.ts
49592
- /** The `vars:` shape: a flat name -> scalar map (`compileVarsMap` coerces every scalar to a string). */
49660
+ /** The `vars:` shape: a flat name -> scalar map (`compileVarsMap` coerces every scalar to a string). Its names are the workflow's business, never core's. */
49593
49661
  const varsJsonSchema = {
49594
49662
  type: "object",
49595
- description: "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. Scalars are coerced to strings.",
49663
+ description: "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. A workflow built by a factory receives these entries as options and validates them itself. Scalars are coerced to strings.",
49596
49664
  additionalProperties: { type: [
49597
49665
  "string",
49598
49666
  "number",
@@ -49602,7 +49670,7 @@ const varsJsonSchema = {
49602
49670
  /** The `env:` shape: environment settings, read live on every invocation and never pinned to a process. */
49603
49671
  const envJsonSchema = {
49604
49672
  type: "object",
49605
- description: "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine, like the test command or a model hint. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
49673
+ description: "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine. A workflow built by a factory receives these entries as options and validates them itself. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
49606
49674
  additionalProperties: { type: [
49607
49675
  "string",
49608
49676
  "number",
@@ -57008,11 +57076,12 @@ const load = gen(function* () {
57008
57076
  catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`)
57009
57077
  });
57010
57078
  const loaded = yield* try_({
57011
- try: () => loadWorkflows(module, shipped),
57079
+ try: () => loadWorkflows(module, shipped, compiled),
57012
57080
  catch: (e) => new GtdError(`gtd config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`)
57013
57081
  });
57014
- const diagnostics = report(wrongKindDiagnostics(loaded.file, compiled), [...rcOrder, module.filepath]);
57082
+ const diagnostics = report([...loaded.diagnostics, ...wrongKindDiagnostics(loaded.settings, compiled)], [...rcOrder, module.filepath]);
57015
57083
  yield* failOnErrors(diagnostics);
57084
+ if (loaded.default === void 0) return yield* die("unreachable: a default fails to build only with an error reported");
57016
57085
  const initial = yield* firstStep(loaded.default, headTree(yield* Workspace));
57017
57086
  const steering = loaded.file.steering;
57018
57087
  const define = (entry) => ({
@@ -57034,8 +57103,8 @@ const load = gen(function* () {
57034
57103
  const resolve = (name, entry) => ({
57035
57104
  name,
57036
57105
  def: define(entry),
57037
- vars: { ...entry.file.defaults },
57038
- env: { ...entry.file.envDefaults }
57106
+ vars: { ...entry.defaults },
57107
+ env: { ...entry.envDefaults }
57039
57108
  });
57040
57109
  const defaultWorkflow = resolve(loaded.default.name, loaded.default);
57041
57110
  return {
@@ -57056,9 +57125,9 @@ const load = gen(function* () {
57056
57125
  };
57057
57126
  });
57058
57127
  /** A name under the wrong key (or under both) in a `.gtdrc` layer: each is an error at its own origin. */
57059
- const wrongKindDiagnostics = (file, compiled) => {
57060
- const isEnvOnly = (key) => Object.hasOwn(file.envDefaults, key);
57061
- const isVarOnly = (key) => Object.hasOwn(file.defaults, key);
57128
+ const wrongKindDiagnostics = (settings, compiled) => {
57129
+ const isEnvOnly = (key) => Object.hasOwn(settings.envDefaults, key);
57130
+ const isVarOnly = (key) => Object.hasOwn(settings.defaults, key);
57062
57131
  const error = (path, message, origin) => ({
57063
57132
  severity: "error",
57064
57133
  path,
@@ -57179,16 +57248,20 @@ const RESERVED_EXPORTS = /* @__PURE__ */ new Set([
57179
57248
  "access",
57180
57249
  "doors"
57181
57250
  ]);
57251
+ /** The rules every declared setting meets, whether a file's export or a factory's build. */
57252
+ const checkSettings = (defaults, envDefaults, declares) => {
57253
+ for (const [kind, record] of [["defaults", defaults], ["envDefaults", envDefaults]]) for (const [name, setting] of Object.entries(record)) {
57254
+ if (!isSettingName(name)) throw new Error(`${declares(kind)} ${JSON.stringify(name)}, not a valid setting name — ${SETTING_NAME_RULE}`);
57255
+ if (typeof setting !== "string") throw new Error(`${declares(kind)} ${JSON.stringify(name)}, whose value is not a string`);
57256
+ }
57257
+ const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name));
57258
+ if (both.length > 0) throw new Error(`${both.map((name) => `"${name}"`).join(", ")} declared in both "defaults" and "envDefaults" — a setting is a process setting or an environment setting, not both`);
57259
+ };
57182
57260
  /** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
57183
57261
  const fileFrom = (exports, origin, dependencyFiles) => {
57184
57262
  const defaults = stringRecord(exports, "defaults");
57185
57263
  const envDefaults = stringRecord(exports, "envDefaults");
57186
- for (const [exportName, record] of [["defaults", defaults], ["envDefaults", envDefaults]]) {
57187
- const bad = Object.keys(record).find((name) => !isSettingName(name));
57188
- if (bad !== void 0) throw new Error(`the "${exportName}" export declares ${JSON.stringify(bad)}, not a valid setting name — ${SETTING_NAME_RULE}`);
57189
- }
57190
- const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name));
57191
- if (both.length > 0) throw new Error(`${both.map((name) => `"${name}"`).join(", ")} declared in both "defaults" and "envDefaults" — a setting is a process setting or an environment setting, not both`);
57264
+ checkSettings(defaults, envDefaults, (kind) => `the "${kind}" export declares`);
57192
57265
  return {
57193
57266
  defaults,
57194
57267
  envDefaults,
@@ -57243,42 +57316,171 @@ const checkDoors = (declared, catalogue) => {
57243
57316
  };
57244
57317
  const namespaceOf = (exported) => typeof exported === "object" && exported !== null ? exported : {};
57245
57318
  /**
57246
- * Evaluate `gtd.config.ts`. Every exported function but the reserved names is
57247
- * a workflow named by its export. `cache` is jiti's OWN per-call module cache
57248
- * (unrelated to the instance's `moduleCache: false`) — passing an empty object
57249
- * in has the side effect of collecting every real file jiti resolved
57250
- * evaluating this module, module AND every relative import it pulled in,
57251
- * transitively.
57319
+ * Evaluate `gtd.config.ts`. Every exported function or factory workflow but
57320
+ * the reserved names is a workflow named by its export. `cache` is jiti's OWN
57321
+ * per-call module cache (unrelated to the instance's `moduleCache: false`) —
57322
+ * passing an empty object in has the side effect of collecting every real file
57323
+ * jiti resolved evaluating this module, module AND every relative import it
57324
+ * pulled in, transitively.
57252
57325
  */
57253
- const loadWorkflows = (module, shipped) => {
57326
+ const loadWorkflows = (module, shipped, compiled) => {
57254
57327
  const cache = {};
57255
57328
  const exports = namespaceOf(jiti(shipped).evalModule(module.source, {
57256
57329
  filename: module.filepath,
57257
57330
  cache
57258
57331
  }));
57259
57332
  const file = fileFrom(exports, module.filepath, Object.keys(cache));
57260
- const catalogue = /* @__PURE__ */ new Map();
57261
- for (const [name, value] of Object.entries(exports)) {
57262
- if (RESERVED_EXPORTS.has(name) || typeof value !== "function") continue;
57263
- catalogue.set(name, {
57264
- flow: value,
57265
- file
57333
+ const exported = Object.entries(exports).filter(([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value));
57334
+ const value = defaultExport(exports);
57335
+ const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default";
57336
+ const built = /* @__PURE__ */ new Map();
57337
+ const rejections = [];
57338
+ const declarers = declarersOf([...exported, [defaultName, value]], file, compiled);
57339
+ for (const [name, entry] of [...exported, [defaultName, value]]) if (!built.has(entry)) built.set(entry, buildWorkflow(name, entry, file, compiled, rejections, declarers));
57340
+ const catalogue = new Map(exported.flatMap(([name, entry]) => {
57341
+ const workflow = built.get(entry);
57342
+ return workflow === void 0 ? [] : [[name, workflow]];
57343
+ }));
57344
+ const defaultWorkflow = built.get(value);
57345
+ return {
57346
+ file,
57347
+ catalogue,
57348
+ doors: rejections.length > 0 ? /* @__PURE__ */ new Map() : checkDoors(doorsFrom(exports), catalogue),
57349
+ default: defaultWorkflow && {
57350
+ ...defaultWorkflow,
57351
+ name: defaultName
57352
+ },
57353
+ settings: mergeSettings([...built.values()].filter((w) => w !== void 0)),
57354
+ diagnostics: nameRejections(rejections)
57355
+ };
57356
+ };
57357
+ const isWorkflow = (value) => typeof value === "function" || isFactoryWorkflow(value);
57358
+ const defaultExport = (exports) => {
57359
+ const value = exports.default;
57360
+ if (value === void 0) throw new Error("no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)");
57361
+ if (!isWorkflow(value)) throw new Error("the default export is not a function");
57362
+ return value;
57363
+ };
57364
+ const mergeSettings = (all) => ({
57365
+ defaults: Object.assign({}, ...all.map((s) => s.defaults)),
57366
+ envDefaults: Object.assign({}, ...all.map((s) => s.envDefaults))
57367
+ });
57368
+ /** Each setting name, with who declares it: the file's exports, or a factory's own build. */
57369
+ const declarersOf = (exported, file, compiled) => {
57370
+ const declarers = /* @__PURE__ */ new Map();
57371
+ const declare = (key, who) => {
57372
+ declarers.set(key, (declarers.get(key) ?? /* @__PURE__ */ new Set()).add(who));
57373
+ };
57374
+ for (const key of [...Object.keys(file.defaults), ...Object.keys(file.envDefaults)]) declare(key, file);
57375
+ for (const [, value] of exported) {
57376
+ const own = ownBuild(value, {
57377
+ ...compiled.rcVars,
57378
+ ...compiled.rcEnv
57266
57379
  });
57380
+ if (own?.ok) for (const key of [...Object.keys(own.build.defaults ?? {}), ...Object.keys(own.build.envDefaults ?? {})]) declare(key, value);
57381
+ }
57382
+ return declarers;
57383
+ };
57384
+ /**
57385
+ * A factory's build as its call alone gives it — or, when the call fails, with
57386
+ * the `.gtdrc` values for just the options it flagged, since a name the factory
57387
+ * itself rejects is its own.
57388
+ */
57389
+ const ownBuild = (value, rc) => {
57390
+ const bare = configure$1(value);
57391
+ if (bare === void 0 || bare.ok) return bare;
57392
+ const flagged = new Set(bare.issues.map((issue) => issue.path[0]));
57393
+ return configure$1(value, Object.fromEntries(Object.entries(rc).filter(([key]) => flagged.has(key))));
57394
+ };
57395
+ /**
57396
+ * One exported workflow: a plain flow as-is, a factory validated and built.
57397
+ * `.gtdrc` `vars:`/`env:` values reach a factory as options, so the factory
57398
+ * judges them — except a name some other loaded workflow declares and this one
57399
+ * does not, which belongs to that workflow.
57400
+ */
57401
+ const buildWorkflow = (name, value, file, compiled, rejections, declarers) => {
57402
+ if (configure$1(value) === void 0) return {
57403
+ flow: value,
57404
+ file,
57405
+ defaults: file.defaults,
57406
+ envDefaults: file.envDefaults
57407
+ };
57408
+ const belongsElsewhere = (key) => {
57409
+ const who = declarers.get(key);
57410
+ return who !== void 0 && !who.has(value);
57411
+ };
57412
+ const overrides = Object.fromEntries(Object.entries({
57413
+ ...compiled.rcVars,
57414
+ ...compiled.rcEnv
57415
+ }).filter(([key]) => !belongsElsewhere(key)));
57416
+ const result = configure$1(value, overrides);
57417
+ if (!result.ok) {
57418
+ for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, overrides, file, compiled));
57419
+ return;
57267
57420
  }
57268
- const flow = exports.default;
57269
- if (flow === void 0) throw new Error("no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)");
57270
- if (typeof flow !== "function") throw new Error("the default export is not a function");
57271
- const name = [...catalogue].find(([, entry]) => entry.flow === flow)?.[0] ?? "default";
57272
57421
  return {
57422
+ flow: result.build.flow,
57273
57423
  file,
57274
- catalogue,
57275
- doors: checkDoors(doorsFrom(exports), catalogue),
57276
- default: {
57277
- flow,
57278
- file,
57279
- name
57280
- }
57424
+ ...builtSettings(name, file, result.build)
57425
+ };
57426
+ };
57427
+ /** A factory's settings over its file's, held to the rules a file's own exports are. */
57428
+ const builtSettings = (name, file, build) => {
57429
+ checkSettings(build.defaults ?? {}, build.envDefaults ?? {}, (kind) => `workflow "${name}" builds ${kind}`);
57430
+ const defaults = {
57431
+ ...file.defaults,
57432
+ ...build.defaults
57433
+ };
57434
+ const envDefaults = {
57435
+ ...file.envDefaults,
57436
+ ...build.envDefaults
57281
57437
  };
57438
+ checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" builds ${kind}`);
57439
+ return {
57440
+ defaults,
57441
+ envDefaults
57442
+ };
57443
+ };
57444
+ /** A rejected option, reported where its value came from: the innermost `.gtdrc` that set it, else `gtd.config.ts`. */
57445
+ const optionDiagnostic = (workflow, issue, overrides, file, compiled) => {
57446
+ const [key] = issue.path;
57447
+ const message = `${issue.path.length > 0 ? `option "${issue.path.join(".")}"` : "options"}: ${issue.message}`;
57448
+ if (!(typeof key === "string" && Object.hasOwn(overrides, key))) return {
57449
+ workflow,
57450
+ severity: "error",
57451
+ path: issue.path,
57452
+ message,
57453
+ origin: file.origin
57454
+ };
57455
+ const kind = Object.hasOwn(compiled.rcEnv, key) ? "env" : "vars";
57456
+ const origin = [...kind === "env" ? compiled.envKeys : compiled.varsKeys].reverse().find((entry) => entry.key === key)?.origin;
57457
+ return {
57458
+ workflow,
57459
+ severity: "error",
57460
+ path: [kind, ...issue.path],
57461
+ message,
57462
+ origin: origin ?? file.origin
57463
+ };
57464
+ };
57465
+ const nameRejections = (rejections) => {
57466
+ const grouped = /* @__PURE__ */ new Map();
57467
+ for (const rejection of rejections) {
57468
+ const key = JSON.stringify([
57469
+ rejection.origin,
57470
+ rejection.path,
57471
+ rejection.message
57472
+ ]);
57473
+ const group = grouped.get(key);
57474
+ if (group === void 0) grouped.set(key, {
57475
+ first: rejection,
57476
+ workflows: [rejection.workflow]
57477
+ });
57478
+ else group.workflows.push(rejection.workflow);
57479
+ }
57480
+ return [...grouped.values()].map(({ first: { workflow: _, ...diagnostic }, workflows }) => ({
57481
+ ...diagnostic,
57482
+ message: `${workflows.length > 1 ? "workflows" : "workflow"} ${workflows.map((w) => `"${w}"`).join(", ")} ${diagnostic.message}`
57483
+ }));
57282
57484
  };
57283
57485
  const headTree = (workspace) => {
57284
57486
  let entries;