@pmelab/gtd 6.8.0 → 6.9.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/README.md CHANGED
@@ -58,13 +58,19 @@ gtd init
58
58
  This writes a `.gtdrc.json` for the bundled unified workflow, with each agent
59
59
  state's prompt saved as an editable Markdown file under `gtd-prompts/` and
60
60
  referenced from the config (review and commit both). Edit a prompt by editing
61
- its `gtd-prompts/*.md` file — no config edit needed. It also seeds a top-level
62
- `modes:` block suggesting **Prettier** as the steering-file formatter
63
- (`npx prettier --write` for the built-in `qa`/`review` modes — format only, so
64
- gtd still validates); edit or drop it freely (swap in dprint, a script, or
65
- delete the key). `gtd init` takes no argument. gtd ships **no** default
66
- workflow: a state command run before `gtd init` fails, pointing you back here.
67
- See [Configuration](docs/configuration.md#gtd-init) for the template.
61
+ its `gtd-prompts/*.md` file — no config edit needed. File references resolve
62
+ relative to **the `.gtdrc` that declares them**, so a config and its
63
+ `gtd-prompts/` can even live in a parent directory shared across repos while gtd
64
+ runs from each repo root — run `gtd init` from that parent directory (it need
65
+ not be a git repo) to scaffold it there (see
66
+ [Configuration](docs/configuration.md#content-values-inline-or-a-file-reference)).
67
+ It also seeds a top-level `modes:` block suggesting **Prettier** as the
68
+ steering-file formatter (`npx prettier --write` for the built-in `qa`/`review`
69
+ modes — format only, so gtd still validates); edit or drop it freely (swap in
70
+ dprint, a script, or delete the key). `gtd init` takes no argument. gtd ships
71
+ **no** default workflow: a state command run before `gtd init` fails, pointing
72
+ you back here. See [Configuration](docs/configuration.md#gtd-init) for the
73
+ template.
68
74
 
69
75
  ## How it works
70
76
 
@@ -189815,6 +189815,49 @@ const resolveContent = (value, configDir, where, errors) => {
189815
189815
  return;
189816
189816
  }
189817
189817
  };
189818
+ /**
189819
+ * Inline every `./`/`../` content file reference in ONE raw `workflow:` value
189820
+ * against `configDir` — the directory of the `.gtdrc` that DECLARED it — and
189821
+ * return a copy with those references replaced by the referenced file's text.
189822
+ *
189823
+ * Used by `src/Config.ts`'s `loadMerged` to resolve each config level's
189824
+ * references against its OWN file's directory BEFORE the levels are
189825
+ * deep-merged. The merge collapses every level into one anonymous object, so it
189826
+ * erases which file a given `states.x.prompt` came from; resolving up front,
189827
+ * per level, is the only way `./gtd-prompts/x.md` in a parent `.gtdrc` resolves
189828
+ * against the parent (not the cwd a child repo runs from). `compileWorkflowConfig`
189829
+ * is then invoked with `inlineFileRefs: false` on the merged result.
189830
+ *
189831
+ * Malformed shapes (a non-object workflow, non-object `states`, a non-object
189832
+ * state, a non-string content value) pass through untouched —
189833
+ * `compileWorkflowConfig`/`validateDefinition` own those findings; this pass
189834
+ * only rewrites the strings it recognizes as file references, collecting a load
189835
+ * error (via `resolveContent`) for any that is missing or unreadable.
189836
+ */
189837
+ const inlineWorkflowFileRefs = (rawWorkflow, configDir, errors) => {
189838
+ if (!isPlainObject$1(rawWorkflow)) return rawWorkflow;
189839
+ const rawStates = rawWorkflow["states"];
189840
+ if (!isPlainObject$1(rawStates)) return rawWorkflow;
189841
+ const states = {};
189842
+ for (const [name, state] of Object.entries(rawStates)) {
189843
+ if (!isPlainObject$1(state)) {
189844
+ states[name] = state;
189845
+ continue;
189846
+ }
189847
+ const next = { ...state };
189848
+ for (const key of CONTENT_KEYS) {
189849
+ const value = state[key];
189850
+ if (typeof value !== "string" || !isFileReference(value)) continue;
189851
+ const resolved = resolveContent(value, configDir, `state "${name}" (${key})`, errors);
189852
+ if (resolved !== void 0) next[key] = resolved;
189853
+ }
189854
+ states[name] = next;
189855
+ }
189856
+ return {
189857
+ ...rawWorkflow,
189858
+ states
189859
+ };
189860
+ };
189818
189861
  const KNOWN_EDGE_KEYS = /* @__PURE__ */ new Set(["to", "describe"]);
189819
189862
  /**
189820
189863
  * Compile one `on` row's value into an `OnEdge` (or `undefined`, pushing a
@@ -189883,8 +189926,15 @@ const compileRetry = (raw, name, errors) => {
189883
189926
  otherwise
189884
189927
  } : void 0;
189885
189928
  };
189886
- /** Exactly one of script/prompt/message/commit, each a string, file-refs auto-inlined. */
189887
- const compileContent = (raw, name, configDir, errors) => {
189929
+ /**
189930
+ * Exactly one of script/prompt/message/commit, each a string, file-refs
189931
+ * auto-inlined. `inlineFileRefs` is `false` only when the caller
189932
+ * (`src/Config.ts`'s `loadMerged`) has ALREADY inlined every reference per
189933
+ * declaring file — the content is then taken verbatim, so a `script:` whose
189934
+ * inlined text happens to begin with `./` is never mistaken for a second file
189935
+ * reference and re-resolved.
189936
+ */
189937
+ const compileContent = (raw, name, configDir, errors, inlineFileRefs) => {
189888
189938
  const content = {};
189889
189939
  for (const key of CONTENT_KEYS) {
189890
189940
  const rawValue = raw[key];
@@ -189893,6 +189943,10 @@ const compileContent = (raw, name, configDir, errors) => {
189893
189943
  errors.push(`state "${name}": "${key}" must be a string`);
189894
189944
  continue;
189895
189945
  }
189946
+ if (!inlineFileRefs) {
189947
+ content[key] = rawValue;
189948
+ continue;
189949
+ }
189896
189950
  const resolved = resolveContent(rawValue, configDir, `state "${name}" (${key})`, errors);
189897
189951
  if (resolved !== void 0) content[key] = resolved;
189898
189952
  }
@@ -189991,7 +190045,7 @@ const assembleStateDef = (parts) => ({
189991
190045
  ...assembleContentFields(parts.content)
189992
190046
  });
189993
190047
  /** One state's full shape: actor, content, `on`, `initial`, `retry`. */
189994
- const compileState = (name, raw, configDir, errors) => {
190048
+ const compileState = (name, raw, configDir, errors, inlineFileRefs) => {
189995
190049
  if (!isPlainObject$1(raw)) {
189996
190050
  errors.push(`state "${name}": must be an object, got ${describeType(raw)}`);
189997
190051
  return {};
@@ -190000,7 +190054,7 @@ const compileState = (name, raw, configDir, errors) => {
190000
190054
  if (unknownKeys.length > 0) errors.push(`state "${name}": unknown key(s) ${unknownKeys.join(", ")}`);
190001
190055
  return assembleStateDef({
190002
190056
  actor: compileActor(raw, name, errors),
190003
- content: compileContent(raw, name, configDir, errors),
190057
+ content: compileContent(raw, name, configDir, errors, inlineFileRefs),
190004
190058
  on: compileOn(raw.on, name, errors),
190005
190059
  initial: compileInitial(raw, name, errors),
190006
190060
  retry: compileRetry(raw.retry, name, errors),
@@ -190019,12 +190073,15 @@ const compileState = (name, raw, configDir, errors) => {
190019
190073
  * file's own directory, used to resolve `./`/`../` file references. `rcModes`
190020
190074
  * is the already-compiled top-level `.gtdrc` `modes:` key (`src/Config.ts`),
190021
190075
  * layered over the workflow's own `modes:` per half BEFORE validation — so a
190022
- * state may name a mode either layer declares. Throws a single
190023
- * `Error` (message: `"workflow config:\n - ..."`, one line per finding) on
190024
- * ANY config-shape problem or `validateDefinition` finding — never partially
190025
- * succeeds.
190026
- */
190027
- const compileWorkflowConfig = (raw, configDir, rcModes) => {
190076
+ * state may name a mode either layer declares. `inlineFileRefs` (default
190077
+ * `true`) inlines content file references against `configDir`; the real config
190078
+ * path (`src/Config.ts`'s `loadMerged`) passes `false` because it has already
190079
+ * inlined every reference per declaring file across the merge chain, so
190080
+ * `configDir` is then irrelevant. Throws a single `Error` (message:
190081
+ * `"workflow config:\n - ..."`, one line per finding) on ANY config-shape
190082
+ * problem or `validateDefinition` finding — never partially succeeds.
190083
+ */
190084
+ const compileWorkflowConfig = (raw, configDir, rcModes, inlineFileRefs = true) => {
190028
190085
  if (!isPlainObject$1(raw)) throw new Error(`workflow config: must be an object, got ${describeType(raw)}`);
190029
190086
  const errors = [];
190030
190087
  const unknownTopKeys = Object.keys(raw).filter((k) => !KNOWN_TOP_KEYS.has(k));
@@ -190037,7 +190094,7 @@ const compileWorkflowConfig = (raw, configDir, rcModes) => {
190037
190094
  throw new Error(formatErrors(errors));
190038
190095
  }
190039
190096
  const states = {};
190040
- for (const [name, s] of Object.entries(rawStates)) states[name] = compileState(name, s, configDir, errors);
190097
+ for (const [name, s] of Object.entries(rawStates)) states[name] = compileState(name, s, configDir, errors, inlineFileRefs);
190041
190098
  const definition = modes !== void 0 ? {
190042
190099
  states,
190043
190100
  modes
@@ -190327,22 +190384,50 @@ const makeExplorer = () => (0, import_dist.cosmiconfig)("gtd", {
190327
190384
  /**
190328
190385
  * Load and deep-merge every config level from cwd up the directory chain.
190329
190386
  * Innermost (cwd) wins. Returns the merged plain object (undecoded).
190330
- */
190387
+ *
190388
+ * A `workflow:` value's `./`/`../` content file references are inlined PER
190389
+ * LEVEL against that level's OWN file directory (`dirname(result.filepath)`)
190390
+ * BEFORE merging — because the deep-merge collapses every level into one
190391
+ * anonymous object and erases which file each `states.x.prompt` came from.
190392
+ * Resolving up front is what lets a `.gtdrc` stored in a parent directory
190393
+ * reference `./gtd-prompts/x.md` and have it resolve against the parent, even
190394
+ * though gtd runs from a child repo (a different cwd). Any missing/unreadable
190395
+ * reference is collected and thrown as one aggregated `workflow config:` error,
190396
+ * exactly like the compiler's own resolution. The merged, already-inlined
190397
+ * `workflow:` is later compiled with `inlineFileRefs: false` (see
190398
+ * `toOperations`).
190399
+ */
190400
+ /**
190401
+ * Normalize one loaded config level: validate it is a plain object, then inline
190402
+ * its `workflow:` file references against its OWN file's directory (collecting
190403
+ * any missing/unreadable reference into `refErrors`). A level with no
190404
+ * `workflow:` key passes through untouched.
190405
+ */
190406
+ const inlineLevel = (config, filepath, refErrors) => {
190407
+ if (!isPlainObject(config)) throw new Error(`${filepath}: config must be a plain object, got ${Array.isArray(config) ? "array" : String(config)}`);
190408
+ if (config["workflow"] === void 0) return config;
190409
+ return {
190410
+ ...config,
190411
+ workflow: inlineWorkflowFileRefs(config["workflow"], dirname(filepath), refErrors)
190412
+ };
190413
+ };
190414
+ /** Read every config level from cwd up the chain, outermost→innermost, each with its file references already inlined per declaring file. Throws on a malformed level or an aggregated set of bad references. */
190415
+ const readConfigLevels = async (root) => {
190416
+ const chain = walkUp(root, homedir());
190417
+ const explorer = makeExplorer();
190418
+ const levels = [];
190419
+ const refErrors = [];
190420
+ for (let i = chain.length - 1; i >= 0; i--) {
190421
+ const result = await explorer.search(chain[i]);
190422
+ if (!result || result.isEmpty) continue;
190423
+ levels.push(inlineLevel(result.config, result.filepath, refErrors));
190424
+ }
190425
+ if (refErrors.length > 0) throw new Error(`workflow config:\n${refErrors.map((e) => ` - ${e}`).join("\n")}`);
190426
+ return levels;
190427
+ };
190331
190428
  const loadMerged = (root) => tryPromise({
190332
190429
  try: async () => {
190333
- const home = homedir();
190334
- const chain = walkUp(root, home);
190335
- const explorer = makeExplorer();
190336
- const levels = [];
190337
- for (let i = chain.length - 1; i >= 0; i--) {
190338
- const dir = chain[i];
190339
- const result = await explorer.search(dir);
190340
- if (result && !result.isEmpty) {
190341
- if (!isPlainObject(result.config)) throw new Error(`${result.filepath}: config must be a plain object, got ${Array.isArray(result.config) ? "array" : String(result.config)}`);
190342
- levels.push(result.config);
190343
- }
190344
- }
190345
- return levels.reduce((acc, level) => deepMerge(acc, level), {});
190430
+ return (await readConfigLevels(root)).reduce((acc, level) => deepMerge(acc, level), {});
190346
190431
  },
190347
190432
  catch: (e) => e instanceof Error ? e : new Error(String(e))
190348
190433
  });
@@ -190396,18 +190481,19 @@ const compileRcVars = (raw) => {
190396
190481
  const NO_WORKFLOW_MESSAGE = "gtd: no workflow configured — run `gtd init` to create .gtdrc.json";
190397
190482
  /**
190398
190483
  * Compile the decoded config's `workflow:` key plus its top-level
190399
- * `vars:`/`modes:` keys into `ConfigOperations`. `root` is used as the
190400
- * workflow compiler's `configDir` — the directory a custom workflow's
190401
- * `./`-relative content references resolve against. Throws `NO_WORKFLOW_MESSAGE`
190402
- * when no `workflow:` key is present (there is no bundled default to fall back
190403
- * to — see `gtd init`), or (via `compileWorkflowConfig`/`compileRcVars`) on any
190404
- * invalid workflow/vars.
190484
+ * `vars:`/`modes:` keys into `ConfigOperations`. Its `./`/`../` content file
190485
+ * references were already inlined per declaring file by `loadMerged`, so the
190486
+ * compiler is invoked with `inlineFileRefs: false` and `root` is passed only as
190487
+ * an (unused) `configDir` placeholder. Throws `NO_WORKFLOW_MESSAGE` when no
190488
+ * `workflow:` key is present (there is no bundled default to fall back to — see
190489
+ * `gtd init`), or (via `compileWorkflowConfig`/`compileRcVars`) on any invalid
190490
+ * workflow/vars.
190405
190491
  */
190406
190492
  const toOperations = (decoded, root) => {
190407
190493
  const rcVars = compileRcVars(decoded.vars);
190408
190494
  const rcModes = compileRcModes(decoded.modes);
190409
190495
  if (decoded.workflow === void 0) throw new Error(NO_WORKFLOW_MESSAGE);
190410
- const { definition, vars: workflowVars } = compileWorkflowConfig(decoded.workflow, root, rcModes);
190496
+ const { definition, vars: workflowVars } = compileWorkflowConfig(decoded.workflow, root, rcModes, false);
190411
190497
  return {
190412
190498
  workflow: definition,
190413
190499
  workflowVars,
@@ -204404,7 +204490,7 @@ const runInitCommand = (argv, json, write) => gen(function* () {
204404
204490
  if (args.length > 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd init: too many arguments — init takes no argument, got: ${args.join(", ")}`));
204405
204491
  const git = yield* GitService;
204406
204492
  const fs = yield* FileSystem;
204407
- yield* assertRunningFromRepoRoot(git, fs);
204493
+ const inRepo = yield* assertInitLocation(git, fs);
204408
204494
  const { root } = yield* Cwd;
204409
204495
  if (yield* configPresentAt(root)) return yield* fail$2(/* @__PURE__ */ new Error("gtd init: a gtd config already exists — remove it before re-initializing"));
204410
204496
  const toError = (e) => e instanceof Error ? e : new Error(String(e));
@@ -204418,11 +204504,12 @@ const runInitCommand = (argv, json, write) => gen(function* () {
204418
204504
  if (json) write(JSON.stringify({
204419
204505
  written: ".gtdrc.json",
204420
204506
  workflow: "unified",
204421
- prompts: scaffold.prompts.map((p) => p.path)
204507
+ prompts: scaffold.prompts.map((p) => p.path),
204508
+ inRepo
204422
204509
  }) + "\n");
204423
204510
  else {
204424
204511
  const promptCount = scaffold.prompts.length;
204425
- write(`Wrote .gtdrc.json with the bundled unified workflow, and its ${promptCount} agent prompt${promptCount === 1 ? "" : "s"} as editable Markdown under ${PROMPTS_DIR}/ (referenced from the config).\n\nReview and commit them before starting: an uncommitted .gtdrc.json (or prompt\nfile) counts as a pending change, so the initial state would capture it on the\nfirst step. Once committed, run \`gtd step human\` to begin.\n`);
204512
+ write(`Wrote .gtdrc.json with the bundled unified workflow, and its ${promptCount} agent prompt${promptCount === 1 ? "" : "s"} as editable Markdown under ${PROMPTS_DIR}/ (referenced from the config).\n\n` + (inRepo ? "Review and commit them before starting: an uncommitted .gtdrc.json (or prompt\nfile) counts as a pending change, so the initial state would capture it on the\nfirst step. Once committed, run `gtd step human` to begin.\n" : "This directory is not a git repository, so there is nothing to commit here. The\nconfig applies to any gtd repository nested below it — gtd discovers it by\nwalking up from the repository root. Run `gtd step human` from such a repo to\nbegin.\n"));
204426
204513
  }
204427
204514
  });
204428
204515
  /**
@@ -204812,6 +204899,24 @@ const assertRunningFromRepoRoot = (git, fs) => gen(function* () {
204812
204899
  const topLevel = yield* git.topLevel();
204813
204900
  if ((yield* fs.realPath(topLevel)) !== (yield* fs.realPath(process.cwd()))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd must be run from the repository root (${topLevel}); the current directory is ${process.cwd()}`));
204814
204901
  });
204902
+ /**
204903
+ * `gtd init` writes only a `.gtdrc.json` (+ prompt files) — it derives no state,
204904
+ * so unlike the state commands it need not sit in a git repository at all. It
204905
+ * may run EITHER at a repository root OR in a directory outside any repository —
204906
+ * the latter scaffolds a shared config a nested repo picks up by walking up the
204907
+ * cwd→home chain (see the parent-dir layout in docs/configuration.md). The one
204908
+ * placement it must refuse is a repository SUBDIRECTORY: gtd runs from the repo
204909
+ * root and discovers config by walking UP, so a config written below the root
204910
+ * would silently never be found. Returns whether cwd is inside a repository (at
204911
+ * its root), so the caller can tailor the "commit before starting" guidance —
204912
+ * there is nothing to commit outside a repo.
204913
+ */
204914
+ const assertInitLocation = (git, fs) => gen(function* () {
204915
+ const topLevel = yield* either(git.topLevel());
204916
+ if (topLevel._tag === "Left") return false;
204917
+ if ((yield* fs.realPath(topLevel.right)) !== (yield* fs.realPath(process.cwd()))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd init must be run from the repository root (${topLevel.right}) or from a directory outside any git repository; the current directory is a repository subdirectory: ${process.cwd()}`));
204918
+ return true;
204919
+ });
204815
204920
  /** Dispatches to the named `run*Command` handler for every known subcommand. */
204816
204921
  const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
204817
204922
  switch (sub) {
@@ -92,11 +92,23 @@ engine, and [§3](../STATES.md#3-pattern-grammar) for the pattern grammar.
92
92
  ### Content values: inline or a file reference
93
93
 
94
94
  A `script`/`prompt`/`message`/`commit` value starting with `./` or `../` is a
95
- **file reference** — resolved relative to the config file's own directory and
96
- read at load time. A missing or unreadable file reference is a **load error**,
97
- collected and thrown with every other config problem — never silently treated as
98
- inline text. Any other string (including one that merely contains a `/`, or an
99
- absolute path) is inline Eta template source, used verbatim.
95
+ **file reference** — resolved relative to **the directory of the `.gtdrc` that
96
+ declared it** (not the directory `gtd` runs from) and read at load time. A
97
+ missing or unreadable file reference is a **load error**, collected and thrown
98
+ with every other config problem — never silently treated as inline text. Any
99
+ other string (including one that merely contains a `/`, or an absolute path) is
100
+ inline Eta template source, used verbatim.
101
+
102
+ Because references anchor to the declaring file, a `.gtdrc` and its
103
+ `gtd-prompts/` (or `prompts/`) directory can live **outside** the repo — e.g. in
104
+ a parent directory shared across several repos. gtd still runs from the repo
105
+ root, discovers the parent `.gtdrc` by walking up the directory chain (see
106
+ ["Lookup and precedence"](#lookup-and-precedence) — cwd→home), and inlines each
107
+ level's `./`-relative references against that level's own directory. When
108
+ workflows are layered across levels, every reference resolves against the file
109
+ that wrote it, so a parent's `./gtd-prompts/x.md` and a child's never collide.
110
+ You can scaffold such a shared config by running [`gtd init`](#gtd-init) from
111
+ that parent directory — it need not be a git repository.
100
112
 
101
113
  ```yaml
102
114
  workflow:
@@ -677,6 +689,16 @@ load time, so a prompt is editable Markdown and editing it changes the workflow
677
689
  with no config edit. Human `message:` blocks and check `script:` bodies stay
678
690
  inline in the config (they are workflow mechanics, not prompts).
679
691
 
692
+ `gtd init` writes only config — it derives no git state — so it need not run in
693
+ a git repository. It runs at a **repository root** or in a directory **outside
694
+ any repository** (scaffolding a shared config a nested repo picks up by walking
695
+ up — see the parent-dir note under
696
+ [file references](#content-values-inline-or-a-file-reference)); its guidance
697
+ adjusts to say there is nothing to commit when it is not in a repo. It refuses
698
+ only to write into a repository **subdirectory**, where the upward config walk
699
+ would never find the file. Either way, it will not overwrite an existing config
700
+ at that directory (remove it first to re-scaffold).
701
+
680
702
  It also seeds a top-level [`modes:`](#modes--pluggable-steering-file-modes)
681
703
  block as a ready-to-edit suggestion — a Prettier `format:` for each built-in
682
704
  steering-file mode the template uses:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "6.8.0",
3
+ "version": "6.9.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": {