@pmelab/gtd 3.4.0 → 3.5.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.
@@ -191766,6 +191766,40 @@ const renderStateTemplate = (template, context) => eta.renderString(template, co
191766
191766
  */
191767
191767
  const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
191768
191768
  const subjectOf$1 = (message) => (message.split("\n")[0] ?? "").trim();
191769
+ const COST_TRAILER_PREFIX = "Gtd-Cost: ";
191770
+ const COST_TRAILER_RE = /^Gtd-Cost:[ \t]*([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+?))?[ \t]*$/gm;
191771
+ /** The bucket a cost with no `--model` tag is grouped under, kept distinct so a mixed history still totals correctly. */
191772
+ const UNATTRIBUTED_MODEL = "unspecified";
191773
+ /**
191774
+ * Append a `Gtd-Cost: <cost>[ <model>]` trailer (after a blank line) to a
191775
+ * commit `subject`, or return the subject unchanged when no cost was supplied.
191776
+ * The subject (first line) is never touched, so `parseStateSubject`/
191777
+ * `resolveState` still read `gtd(<actor>): <state>` back exactly as before.
191778
+ */
191779
+ const withCostTrailer = (subject, cost, model) => cost === void 0 ? subject : `${subject}\n\n${COST_TRAILER_PREFIX}${cost}${model !== void 0 ? ` ${model}` : ""}`;
191780
+ /** Every `Gtd-Cost:` entry found across the given commit messages (each entry's model defaults to `UNATTRIBUTED_MODEL`). */
191781
+ const parseCostTrailers = (messages) => {
191782
+ const entries = [];
191783
+ for (const message of messages) for (const match of message.matchAll(COST_TRAILER_RE)) {
191784
+ const model = match[2]?.trim();
191785
+ entries.push({
191786
+ cost: Number(match[1]),
191787
+ model: model !== void 0 && model !== "" ? model : UNATTRIBUTED_MODEL
191788
+ });
191789
+ }
191790
+ return entries;
191791
+ };
191792
+ /** The total token cost across the given entries (`0` when none). */
191793
+ const totalCostOf = (entries) => entries.reduce((sum, entry) => sum + entry.cost, 0);
191794
+ /** Per-model token totals, highest-cost first (ties broken by model name for a stable order). */
191795
+ const costByModel = (entries) => {
191796
+ const byModel = /* @__PURE__ */ new Map();
191797
+ for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
191798
+ return [...byModel.entries()].map(([model, cost]) => ({
191799
+ model,
191800
+ cost
191801
+ })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
191802
+ };
191769
191803
  /** Collapse an arbitrary git status letter to the pattern grammar's closed `A|M|D` set (mirrors the plan's decision 5 — only those three are meaningful statuses). */
191770
191804
  const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
191771
191805
  /** Resolve HEAD's subject against the active workflow definition (the bundled default, or a compiled `.gtdrc` `workflow:` key). */
@@ -191810,7 +191844,8 @@ const computeProcessRun = (git, def) => gen(function* () {
191810
191844
  if (!(yield* git.hasCommits())) return {
191811
191845
  startHash: "",
191812
191846
  startParentHash: EMPTY_TREE$1,
191813
- trace: []
191847
+ trace: [],
191848
+ costEntries: []
191814
191849
  };
191815
191850
  const initialState = initialStateOf(def);
191816
191851
  const history = yield* git.commitHistory();
@@ -191821,12 +191856,15 @@ const computeProcessRun = (git, def) => gen(function* () {
191821
191856
  i--;
191822
191857
  }
191823
191858
  const startIdx = i + 1;
191824
- const trace = history.slice(startIdx).map((h) => parseStateSubject(subjectOf$1(h.message)).state);
191859
+ const processCommits = history.slice(startIdx);
191860
+ const trace = processCommits.map((h) => parseStateSubject(subjectOf$1(h.message)).state);
191861
+ const costEntries = parseCostTrailers(processCommits.map((h) => h.message));
191825
191862
  const startParentHash = i >= 0 ? history[i].hash : EMPTY_TREE$1;
191826
191863
  return {
191827
191864
  startHash: startIdx < history.length ? history[startIdx].hash : history[history.length - 1].hash,
191828
191865
  startParentHash,
191829
- trace
191866
+ trace,
191867
+ costEntries
191830
191868
  };
191831
191869
  });
191832
191870
  /** The `GTD_VAR_`-prefix stripped, exact-case, from every matching entry — the highest-precedence `it.vars` layer. A `value === undefined` entry (a name declared-but-unset in the environment) is skipped, never coerced to the string `"undefined"`. */
@@ -191863,13 +191901,27 @@ const toTemplateEdges = (edges) => (edges ?? []).map(([pattern, target, describe
191863
191901
  pattern,
191864
191902
  target
191865
191903
  });
191866
- /** Build the `PatternTemplates.TemplateContext` for rendering `state`'s content at the resolved rest. `vars` is the already-merged three-layer map (see `resolveVars`); `edges` is the resting state's own `on` edges (see `toTemplateEdges`). */
191867
- const buildTemplateContext = (git, read, state, actor, run, vars, edges) => gen(function* () {
191904
+ /**
191905
+ * Build the `PatternTemplates.TemplateContext` for rendering `state`'s content
191906
+ * at the resolved rest. `vars` is the already-merged three-layer map (see
191907
+ * `resolveVars`); `edges` is the resting state's own `on` edges (see
191908
+ * `toTemplateEdges`). `currentCost`/`currentModel` are the in-flight step's own
191909
+ * `--cost`/`--model` (folded into the process's committed cost entries so a
191910
+ * `commit:` squash template sees the whole-process total AND per-model
191911
+ * breakdown including the squashing step) — `0`/absent for the pure emitters
191912
+ * (`gtd next`/`gtd status`), where no step is being performed.
191913
+ */
191914
+ const buildTemplateContext = (git, read, state, actor, run, vars, edges, currentCost = 0, currentModel) => gen(function* () {
191868
191915
  const hasCommits = yield* git.hasCommits();
191869
191916
  const currentCommit = hasCommits ? yield* git.resolveRef("HEAD") : "";
191870
191917
  const previousCommit = hasCommits ? yield* git.resolveRef("HEAD~1").pipe(catchAll(() => succeed$3(run.startParentHash))) : "";
191871
191918
  const processDiff = [yield* git.diffRef(run.startParentHash).pipe(catchAll(() => succeed$3(""))), yield* git.diffHead().pipe(catchAll(() => succeed$3("")))].filter((d) => d.trim().length > 0).join("\n\n");
191872
191919
  const lastDiff = run.trace.length > 0 ? yield* git.commitDiff(currentCommit).pipe(catchAll(() => succeed$3(""))) : "";
191920
+ const stepEntry = currentCost > 0 || currentModel !== void 0 ? [{
191921
+ cost: currentCost,
191922
+ model: currentModel ?? "unspecified"
191923
+ }] : [];
191924
+ const allCostEntries = [...run.costEntries, ...stepEntry];
191873
191925
  return {
191874
191926
  startCommit: run.startHash,
191875
191927
  currentCommit,
@@ -191878,6 +191930,8 @@ const buildTemplateContext = (git, read, state, actor, run, vars, edges) => gen(
191878
191930
  actor,
191879
191931
  processDiff,
191880
191932
  lastDiff,
191933
+ processCost: totalCostOf(allCostEntries),
191934
+ processCostByModel: costByModel(allCostEntries),
191881
191935
  read,
191882
191936
  vars,
191883
191937
  edges: toTemplateEdges(edges)
@@ -191945,18 +191999,22 @@ const renderRest = (rest, context) => gen(function* () {
191945
191999
  });
191946
192000
  /**
191947
192001
  * Execute a `PatternMachine.step` decision: a `"commit"` decision stages and
191948
- * commits everything pending under the decided subject; a `"squash"`
191949
- * decision renders the commit-state template against the PENDING tree — a
191950
- * render failure REFUSES the step, touching nothing — then soft-resets to
191951
- * the process's start parent, writes ONE commit with the rendered message
191952
- * (via `commitAsIs`, so the still-uncommitted template file is excluded), and
191953
- * discards everything left pending (the template file included). A `"noop"`
191954
- * performs no IO.
191955
- */
191956
- const executeDecision = (git, run, decision, context) => gen(function* () {
192002
+ * commits everything pending under the decided subject, with an optional
192003
+ * `Gtd-Cost: <cost>[ <model>]` trailer (the invocation's `--cost`/`--model`,
192004
+ * persisted in the git log); a `"squash"` decision renders the commit-state
192005
+ * template against the PENDING tree — a render failure REFUSES the step,
192006
+ * touching nothing — then soft-resets to the process's start parent, writes
192007
+ * ONE commit with the rendered message (via `commitAsIs`, so the
192008
+ * still-uncommitted template file is excluded), and discards everything left
192009
+ * pending (the template file included). A squash records no trailer of its own
192010
+ * — the whole-process total and per-model breakdown reach the message through
192011
+ * `it.processCost`/`it.processCostByModel` in the rendered template. A
192012
+ * `"noop"` performs no IO.
192013
+ */
192014
+ const executeDecision = (git, run, decision, context, cost, model) => gen(function* () {
191957
192015
  switch (decision.kind) {
191958
192016
  case "commit":
191959
- yield* git.commitAllWithPrefix(decision.subject);
192017
+ yield* git.commitAllWithPrefix(withCostTrailer(decision.subject, cost, model));
191960
192018
  return {
191961
192019
  kind: "commit",
191962
192020
  subject: decision.subject
@@ -364727,6 +364785,8 @@ const buildFileModeMap = (def, vars, root) => {
364727
364785
  actor: "",
364728
364786
  processDiff: "",
364729
364787
  lastDiff: "",
364788
+ processCost: 0,
364789
+ processCostByModel: [],
364730
364790
  read: (path) => {
364731
364791
  throw new Error(`"file:" is not readable while building the path→mode map (path: ${path})`);
364732
364792
  },
@@ -364978,7 +365038,10 @@ const HELP_TEXT = `Usage: gtd [command] [options]
364978
365038
  Commands:
364979
365039
  step <actor> Authenticate as <actor>, match the resolved rest's
364980
365040
  declared patterns against the pending changes, and commit
364981
- (or squash) the one resulting transition
365041
+ (or squash) the one resulting transition. Pass
365042
+ --cost=<n> (optionally --model=<name>) to record the
365043
+ just-finished invocation's token cost and model on the
365044
+ turn commit (summed into it.processCost/processCostByModel)
364982
365045
  next Print the resolved rest's rendered script/prompt/message
364983
365046
  (no mutation)
364984
365047
  run Execute the resolved rest's emitted script, then step its
@@ -364992,6 +365055,8 @@ Commands:
364992
365055
 
364993
365056
  Options:
364994
365057
  --json Output structured JSON instead of plain text
365058
+ --cost=<n> (gtd step only) record the invocation's token cost
365059
+ --model=<name> (gtd step only, with --cost) tag that cost's model
364995
365060
  --version, -v Print version and exit
364996
365061
  --help, -h Print this help and exit
364997
365062
  `;
@@ -365007,6 +365072,58 @@ const markEnveloped = (error) => Object.assign(error, { [ENVELOPED]: true });
365007
365072
  const isEnveloped = (error) => typeof error === "object" && error !== null && error[ENVELOPED] === true;
365008
365073
  /** Non-flag positional arguments past the subcommand name (argv[3..]). */
365009
365074
  const commandArgs = (argv) => argv.slice(3).filter((a) => a.length > 0 && !a.startsWith("--"));
365075
+ const COST_FLAG = "--cost=";
365076
+ /**
365077
+ * Parse the optional `--cost=<n>` flag (only `gtd step` accepts it — see
365078
+ * `makeProgram`). `<n>` is the token cost of the invocation that produced the
365079
+ * pending changes, recorded as a `Gtd-Cost:` trailer on the turn commit. Must
365080
+ * be a non-negative finite number; a bare `--cost` (no `=`) or a non-numeric/
365081
+ * negative value is a usage error. Returns `undefined` when the flag is absent.
365082
+ */
365083
+ const parseCostFlag = (argv) => {
365084
+ if (argv.slice(2).includes("--cost")) return fail$2(/* @__PURE__ */ new Error("gtd: --cost requires a value — use --cost=<number>"));
365085
+ const flag = argv.slice(2).find((a) => a.startsWith(COST_FLAG));
365086
+ if (flag === void 0) return succeed$3(void 0);
365087
+ const raw = flag.slice(7);
365088
+ const n = Number(raw);
365089
+ if (raw.trim() === "" || !Number.isFinite(n) || n < 0) return fail$2(/* @__PURE__ */ new Error(`gtd: --cost must be a non-negative number — got "${raw}"`));
365090
+ return succeed$3(n);
365091
+ };
365092
+ const MODEL_FLAG = "--model=";
365093
+ /**
365094
+ * Parse the optional `--model=<name>` flag (only `gtd step`, and only
365095
+ * alongside `--cost` — see `makeProgram`). `<name>` tags the recorded cost
365096
+ * with the model the invocation ran on, appended to the `Gtd-Cost:` trailer
365097
+ * and grouped in `it.processCostByModel`. Must be non-empty and single-line
365098
+ * (it rides on one trailer line); a bare `--model` (no `=`) or an empty/
365099
+ * multiline value is a usage error. Returns `undefined` when the flag is absent.
365100
+ */
365101
+ const parseModelFlag = (argv) => {
365102
+ if (argv.slice(2).includes("--model")) return fail$2(/* @__PURE__ */ new Error("gtd: --model requires a value — use --model=<name>"));
365103
+ const flag = argv.slice(2).find((a) => a.startsWith(MODEL_FLAG));
365104
+ if (flag === void 0) return succeed$3(void 0);
365105
+ const raw = flag.slice(8);
365106
+ if (raw.trim() === "" || /[\r\n]/.test(raw)) return fail$2(/* @__PURE__ */ new Error("gtd: --model must be a non-empty, single-line value"));
365107
+ return succeed$3(raw);
365108
+ };
365109
+ /**
365110
+ * Parse and validate the `--cost`/`--model` step flags together. Both are
365111
+ * orthogonal to `--json` but only meaningful to `gtd step` — rejected on any
365112
+ * other command rather than silently ignored (same discipline as `--json` on
365113
+ * `format`) — and `--model` requires `--cost` (a model tag with no token count
365114
+ * records nothing to sum).
365115
+ */
365116
+ const parseStepFlags = (argv, positional) => gen(function* () {
365117
+ const cost = yield* parseCostFlag(argv);
365118
+ const model = yield* parseModelFlag(argv);
365119
+ if (cost !== void 0 && positional !== "step") return yield* fail$2(/* @__PURE__ */ new Error("gtd: --cost is only valid for `gtd step`"));
365120
+ if (model !== void 0 && positional !== "step") return yield* fail$2(/* @__PURE__ */ new Error("gtd: --model is only valid for `gtd step`"));
365121
+ if (model !== void 0 && cost === void 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd: --model requires --cost — it tags the recorded cost with the model that ran"));
365122
+ return {
365123
+ cost,
365124
+ model
365125
+ };
365126
+ });
365010
365127
  /** Rejects extra positional arguments for a subcommand that takes none (`status`, `run`). */
365011
365128
  const rejectExtraArgs = (command, argv) => {
365012
365129
  const args = commandArgs(argv);
@@ -365055,6 +365172,8 @@ const resolveRestContext = (git) => gen(function* () {
365055
365172
  context: yield* buildTemplateContext(git, worktree.read, rest.state, rest.actor, run, vars, rest.stateDef.on)
365056
365173
  };
365057
365174
  });
365175
+ /** The user-facing message for a `step` refusal — out-of-turn names the awaited actor, no-match names every declared pattern. */
365176
+ const formatStepRefusal = (invoker, refusal) => refusal.reason === "out-of-turn" ? `gtd step ${invoker}: out of turn — "${refusal.state}" awaits ${refusal.awaits}` : `gtd step ${invoker}: no declared pattern matches the pending changes at "${refusal.state}" — declared patterns: ${refusal.patterns.length > 0 ? refusal.patterns.join(", ") : "(none)"}`;
365058
365177
  /**
365059
365178
  * Authenticate `invoker` against the resolved rest and perform the one
365060
365179
  * resulting transition (commit or squash), shared by `gtd step` and
@@ -365063,7 +365182,7 @@ const resolveRestContext = (git) => gen(function* () {
365063
365182
  * `subject: null` rather than failing (exit zero, per the plan's "clean
365064
365183
  * no-op exits zero").
365065
365184
  */
365066
- const stepAsActor = (invoker) => gen(function* () {
365185
+ const stepAsActor = (invoker, cost, model) => gen(function* () {
365067
365186
  const git = yield* GitService;
365068
365187
  const config = yield* ConfigService;
365069
365188
  const worktree = yield* WorktreeReader;
@@ -365075,37 +365194,40 @@ const stepAsActor = (invoker) => gen(function* () {
365075
365194
  changes,
365076
365195
  processTrace: run.trace
365077
365196
  });
365078
- if (decision.kind === "refusal") {
365079
- const message = decision.reason === "out-of-turn" ? `gtd step ${invoker}: out of turn — "${decision.state}" awaits ${decision.awaits}` : `gtd step ${invoker}: no declared pattern matches the pending changes at "${decision.state}" — declared patterns: ${decision.patterns.length > 0 ? decision.patterns.join(", ") : "(none)"}`;
365080
- return yield* fail$2(new Error(message));
365081
- }
365197
+ if (decision.kind === "refusal") return yield* fail$2(new Error(formatStepRefusal(invoker, decision)));
365082
365198
  if (decision.kind === "noop") return {
365083
365199
  state: decision.state,
365084
- subject: null
365200
+ subject: null,
365201
+ cost: null,
365202
+ model: null
365085
365203
  };
365086
365204
  const executable = decision;
365087
365205
  const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
365088
365206
  const renderedState = decision.kind === "squash" ? decision.state : rest.state;
365089
- const outcome = yield* executeDecision(git, run, executable, yield* buildTemplateContext(git, worktree.read, renderedState, invoker, run, vars, rest.def.states[renderedState]?.on));
365207
+ const outcome = yield* executeDecision(git, run, executable, yield* buildTemplateContext(git, worktree.read, renderedState, invoker, run, vars, rest.def.states[renderedState]?.on, cost ?? 0, model), cost, model);
365090
365208
  return {
365091
365209
  state: rest.state,
365092
- subject: outcome.kind === "noop" ? null : outcome.subject
365210
+ subject: outcome.kind === "noop" ? null : outcome.subject,
365211
+ cost: cost ?? null,
365212
+ model: model ?? null
365093
365213
  };
365094
365214
  });
365095
365215
  /** Renders `stepAsActor`'s result the same way for both `gtd step` and `gtd run`. */
365096
365216
  const reportStepResult = (result, json, write) => {
365097
365217
  if (json) write(JSON.stringify({
365098
365218
  state: result.state,
365099
- subject: result.subject
365219
+ subject: result.subject,
365220
+ ...result.cost !== null ? { cost: result.cost } : {},
365221
+ ...result.model !== null ? { model: result.model } : {}
365100
365222
  }) + "\n");
365101
365223
  else write(result.subject !== null ? `committed: ${result.subject}\n` : `nothing to do at "${result.state}"\n`);
365102
365224
  };
365103
- /** `gtd step <actor>`: authenticate as `<actor>` and perform the one resulting transition. */
365104
- const runStepCommand = (argv, json, write) => gen(function* () {
365225
+ /** `gtd step <actor> [--cost=<n>] [--model=<name>]`: authenticate as `<actor>` and perform the one resulting transition, recording `--cost`/`--model` as a `Gtd-Cost:` trailer. */
365226
+ const runStepCommand = (argv, json, write, cost, model) => gen(function* () {
365105
365227
  const args = commandArgs(argv);
365106
365228
  if (args.length === 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd step: missing actor argument"));
365107
365229
  if (args.length > 1) return yield* fail$2(/* @__PURE__ */ new Error(`gtd step: too many arguments — expected one actor, got: ${args.join(", ")}`));
365108
- const result = yield* stepAsActor(args[0]);
365230
+ const result = yield* stepAsActor(args[0], cost, model);
365109
365231
  reportStepResult(result, json, write);
365110
365232
  });
365111
365233
  /** `gtd next [--json]`: pure emitter of the resolved rest's rendered content (no mutation). */
@@ -365164,8 +365286,21 @@ const computeStatusChanges = (onEdges, changes) => changes.map((change) => {
365164
365286
  pattern: matchedRow?.[0] ?? null
365165
365287
  };
365166
365288
  });
365167
- /** `gtd status --json`'s emission — `{state, actor, changes, model?, memory?, file?, mode?}`. */
365168
- const writeStatusJson = (write, rest, statusChanges, model, memory, file) => {
365289
+ /**
365290
+ * True when the per-model breakdown adds information beyond the `Cost:` total:
365291
+ * more than one model, or a single model that carries an actual `--model` tag
365292
+ * (a lone `unspecified` bucket just restates the total, so it's suppressed).
365293
+ */
365294
+ const breakdownIsInformative = (byModel) => byModel.length > 1 || byModel.length === 1 && byModel[0].model !== "unspecified";
365295
+ /** The plain-text `Cost:` line(s) for `gtd status` — empty when nothing recorded, plus an indented per-model split when informative. */
365296
+ const costStatusLines = (cost, byModel) => {
365297
+ if (cost <= 0) return [];
365298
+ const lines = [`Cost: ${cost}`];
365299
+ if (breakdownIsInformative(byModel)) for (const m of byModel) lines.push(` ${m.model}: ${m.cost}`);
365300
+ return lines;
365301
+ };
365302
+ /** `gtd status --json`'s emission — `{state, actor, changes, model?, memory?, file?, mode?, cost?, costByModel?, edges?}`. */
365303
+ const writeStatusJson = (write, rest, statusChanges, model, memory, file, cost, costByModel) => {
365169
365304
  const edges = toTemplateEdges(rest.stateDef.on);
365170
365305
  write(JSON.stringify({
365171
365306
  state: rest.state,
@@ -365175,16 +365310,19 @@ const writeStatusJson = (write, rest, statusChanges, model, memory, file) => {
365175
365310
  ...memory !== void 0 ? { memory } : {},
365176
365311
  ...file !== void 0 ? { file } : {},
365177
365312
  ...rest.stateDef.mode !== void 0 ? { mode: rest.stateDef.mode } : {},
365313
+ ...cost > 0 ? { cost } : {},
365314
+ ...cost > 0 ? { costByModel } : {},
365178
365315
  ...edges.length > 0 ? { edges } : {}
365179
365316
  }) + "\n");
365180
365317
  };
365181
- /** `gtd status`'s plain-text emission — `State:`/`Awaits:`/`Model:`/`Memory:`/`File:`/`Mode:`/`Pending:` lines. */
365182
- const writeStatusPlain = (write, rest, statusChanges, model, memory, file) => {
365318
+ /** `gtd status`'s plain-text emission — `State:`/`Awaits:`/`Model:`/`Memory:`/`File:`/`Mode:`/`Cost:`/`Pending:` lines. */
365319
+ const writeStatusPlain = (write, rest, statusChanges, model, memory, file, cost, costByModel) => {
365183
365320
  const lines = [`State: ${rest.state}`, `Awaits: ${rest.actor}`];
365184
365321
  if (model !== void 0) lines.push(`Model: ${model}`);
365185
365322
  if (memory !== void 0) lines.push(`Memory: ${memory}`);
365186
365323
  if (file !== void 0) lines.push(`File: ${file}`);
365187
365324
  if (rest.stateDef.mode !== void 0) lines.push(`Mode: ${rest.stateDef.mode}`);
365325
+ lines.push(...costStatusLines(cost, costByModel));
365188
365326
  if (statusChanges.length === 0) lines.push("Pending: (clean)");
365189
365327
  else {
365190
365328
  lines.push("Pending:");
@@ -365202,8 +365340,8 @@ const runStatusCommand = (argv, json, write) => gen(function* () {
365202
365340
  const memory = yield* renderMemory(rest.stateDef, context);
365203
365341
  const file = yield* renderFile(rest.stateDef, context);
365204
365342
  const statusChanges = computeStatusChanges(rest.stateDef.on ?? [], changes);
365205
- if (json) writeStatusJson(write, rest, statusChanges, model, memory, file);
365206
- else writeStatusPlain(write, rest, statusChanges, model, memory, file);
365343
+ if (json) writeStatusJson(write, rest, statusChanges, model, memory, file, context.processCost, context.processCostByModel);
365344
+ else writeStatusPlain(write, rest, statusChanges, model, memory, file, context.processCost, context.processCostByModel);
365207
365345
  });
365208
365346
  /**
365209
365347
  * `gtd mermaid`: pure emitter of the active workflow's SHAPE (not the
@@ -365269,9 +365407,9 @@ const assertRunningFromRepoRoot = (git, fs) => gen(function* () {
365269
365407
  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()}`));
365270
365408
  });
365271
365409
  /** Dispatches to the named `run*Command` handler for every known subcommand. */
365272
- const dispatchKnownSubcommand = (sub, argv, json, write) => {
365410
+ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
365273
365411
  switch (sub) {
365274
- case "step": return runStepCommand(argv, json, write);
365412
+ case "step": return runStepCommand(argv, json, write, cost, model);
365275
365413
  case "next": return runNextCommand(json, write);
365276
365414
  case "run": return runRunCommand(argv, json, write);
365277
365415
  case "status": return runStatusCommand(argv, json, write);
@@ -365299,8 +365437,9 @@ function makeProgram(opts = {}) {
365299
365437
  const positional = argv.slice(2).find((a) => !a.startsWith("--"));
365300
365438
  return gen(function* () {
365301
365439
  if (runVersionOrHelp(argv, write)) return;
365302
- const unknownOption = argv.slice(2).find((a) => a.startsWith("--") && a !== "--json");
365440
+ const unknownOption = argv.slice(2).find((a) => a.startsWith("--") && a !== "--json" && a !== "--cost" && a !== "--model" && !a.startsWith(COST_FLAG) && !a.startsWith(MODEL_FLAG));
365303
365441
  if (unknownOption !== void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: unknown option '${unknownOption}' — see \`gtd --help\``));
365442
+ const { cost, model } = yield* parseStepFlags(argv, positional);
365304
365443
  if (positional === "format") return yield* runFormatCommand(argv, json);
365305
365444
  if (positional === "lsp") return yield* runLspCommand(argv, json);
365306
365445
  const sub = yield* requireKnownSubcommand(positional, json, write);
@@ -365309,7 +365448,7 @@ function makeProgram(opts = {}) {
365309
365448
  yield* assertRunningFromRepoRoot(git, fs);
365310
365449
  yield* closeReviewWindow;
365311
365450
  yield* (yield* ConfigInit).ensure;
365312
- const outcome = yield* either(dispatchKnownSubcommand(sub, argv, json, write));
365451
+ const outcome = yield* either(dispatchKnownSubcommand(sub, argv, json, write, cost, model));
365313
365452
  const rearm = yield* either(openReviewWindow);
365314
365453
  if (isLeft(outcome)) return yield* fail$2(outcome.left);
365315
365454
  if (isLeft(rearm)) return yield* fail$2(rearm.left);
package/docs/cli.md CHANGED
@@ -6,7 +6,10 @@ Usage: gtd [command] [options]
6
6
  Commands:
7
7
  step <actor> Authenticate as <actor>, match the resolved rest's
8
8
  declared patterns against the pending changes, and commit
9
- (or squash) the one resulting transition
9
+ (or squash) the one resulting transition. Pass
10
+ --cost=<n> (optionally --model=<name>) to record the
11
+ just-finished invocation's token cost and model on the
12
+ turn commit (summed into it.processCost/processCostByModel)
10
13
  next Print the resolved rest's rendered script/prompt/message
11
14
  (no mutation)
12
15
  run Execute the resolved rest's emitted script, then step its
@@ -20,6 +23,8 @@ Commands:
20
23
 
21
24
  Options:
22
25
  --json Output structured JSON instead of plain text
26
+ --cost=<n> (gtd step only) record the invocation's token cost
27
+ --model=<name> (gtd step only, with --cost) tag that cost's model
23
28
  --version, -v Print version and exit
24
29
  --help, -h Print this help and exit
25
30
  ```
@@ -32,11 +37,15 @@ without touching the repository. Every other command must be run from the
32
37
  history relative to cwd, so it refuses with a clear error if invoked from a
33
38
  subdirectory.
34
39
 
35
- `--json` is the only long option. Any other `--` option (including a typo like
36
- `--jsn`) is rejected with a usage error rather than silently ignored, so a
37
- mistyped flag can never degrade a JSON caller to plain-text mode.
40
+ `--json`, `--cost=<n>`, and `--model=<name>` (the latter two only for
41
+ `gtd step`) are the only long options. Any other `--` option (including a typo
42
+ like `--jsn`) is rejected with a usage error rather than silently ignored, so a
43
+ mistyped flag can never degrade a JSON caller to plain-text mode. A bare
44
+ `--cost`/`--model` with no value, a non-numeric or negative `--cost`, an empty
45
+ `--model`, `--model` without `--cost`, or either flag on any command other than
46
+ `gtd step` are all usage errors.
38
47
 
39
- ## `gtd step <actor>`
48
+ ## `gtd step <actor> [--cost=<n>] [--model=<name>]`
40
49
 
41
50
  Authenticates `<actor>` against the resolved rest and performs the ONE resulting
42
51
  transition. Unlike v2, there is no fixpoint chain to drive: the pattern
@@ -45,6 +54,16 @@ authors at most one commit (a normal turn) or performs one squash
45
54
  (§[STATES.md](../STATES.md#8-the-squash-lifecycle)) — never both, never a chain
46
55
  of several. A caller that wants several transitions issues several invocations.
47
56
 
57
+ `--cost=<n>` records the token cost of the invocation that produced the pending
58
+ changes (a non-negative number), and the optional `--model=<name>` tags it with
59
+ the model that ran, as a `Gtd-Cost: <n> <model>` trailer on the turn commit —
60
+ persisted in the git log, summed across the process into `it.processCost` and
61
+ grouped by model into `it.processCostByModel` (see
62
+ [Configuration: Token cost](configuration.md#token-cost)). `--model` requires
63
+ `--cost`. On a squash they are folded into the process total/breakdown the
64
+ `commit:` template renders rather than written as a trailer. Both are orthogonal
65
+ to `--json`.
66
+
48
67
  - **Out-of-turn refusal** — `<actor>` isn't the resolved state's declared actor:
49
68
  exit non-zero, zero commits.
50
69
  `gtd step <actor>: out of turn — "<state>" awaits <awaited-actor>`
@@ -69,10 +88,17 @@ or, at a no-op:
69
88
  nothing to do at "idle"
70
89
  ```
71
90
 
72
- `--json` emits `{state, subject}` — `subject` is `null` at a no-op:
91
+ `--json` emits `{state, subject}` — `subject` is `null` at a no-op — plus
92
+ `cost`/`model` keys echoing what `--cost`/`--model` recorded (each omitted when
93
+ not passed):
73
94
 
74
95
  ```json
75
- { "state": "grilling", "subject": "gtd(human): grilling" }
96
+ {
97
+ "state": "grilling",
98
+ "subject": "gtd(human): grilling",
99
+ "cost": 1450,
100
+ "model": "claude-opus-4-8"
101
+ }
76
102
  ```
77
103
 
78
104
  ## `gtd next [--json]`
@@ -171,15 +197,21 @@ Pending:
171
197
  or, on a clean tree: `Pending: (clean)`. A `Model: <value>` line appears right
172
198
  after `Awaits:` when the resolved state declares a `model:` hint, and
173
199
  `Memory: <value>`/`File: <value>`/`Mode: <value>` lines appear after that (in
174
- that order) when declared — each independently, only when set.
200
+ that order) when declared — each independently, only when set. A `Cost: <n>`
201
+ line appears after those when the current process has accumulated any
202
+ `Gtd-Cost:` (see [`gtd step --cost`](#gtd-step-actor---costn-model-name)),
203
+ omitted when the running total is `0`; when the breakdown adds information (more
204
+ than one model, or a single tagged model), indented ` <model>: <cost>` lines
205
+ follow it.
175
206
 
176
207
  `--json` emits
177
- `{state, actor, changes: [{status, path, pattern}], model?, memory?, file?, mode?, edges?}`
208
+ `{state, actor, changes: [{status, path, pattern}], model?, memory?, file?, mode?, cost?, costByModel?, edges?}`
178
209
  — `pattern` is `null` when no declared row matches that change;
179
210
  `model`/`memory`/`file`/`mode` are present only when the resolved state declares
180
- them (omitted entirely, never `null`, otherwise); `edges` is the resting state's
181
- `on` edges as `[{ pattern, target, describe? }]` (same as `gtd next --json`),
182
- omitted when the state has no `on`:
211
+ them; `cost`/`costByModel` (the `[{model, cost}]` breakdown) only when the
212
+ running process total is above `0`; and `edges` is the resting state's `on`
213
+ edges as `[{ pattern, target, describe? }]` (same as `gtd next --json`), omitted
214
+ when the state has no `on` (all omitted entirely, never `null`, otherwise):
183
215
 
184
216
  ```json
185
217
  {
@@ -322,17 +322,66 @@ head is preserved under `refs/gtd/review-head` (the base under
322
322
  Every `script`/`prompt`/`message`/`commit`/`model`/`memory`/`file` template is
323
323
  rendered as an Eta template (`it.<name>`) with:
324
324
 
325
- | Variable | Meaning |
326
- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
327
- | `startCommit` | The hash the current process started from (before its first turn). |
328
- | `currentCommit` | HEAD's hash at render time. |
329
- | `previousCommit` | The hash before the last transition (HEAD's parent, in-process). |
330
- | `state` | The state whose content is being rendered. |
331
- | `actor` | The actor this render is for. |
332
- | `processDiff` | `startCommit..HEAD` plus the pending working-tree diff. |
333
- | `lastDiff` | The diff of the last transition alone. |
334
- | `read(path)` | Reads a working-tree file (pending contents, not HEAD's) by repo-relative path. Throws for a missing/unreadable path — for a `commit:` template, that throw refuses the step (see [STATES.md §8](../STATES.md#8-the-squash-lifecycle)). |
335
- | `vars` | The merged three-layer variable map, always a flat `Record<string, string>` — see ["Variables"](#variables) below. |
325
+ | Variable | Meaning |
326
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
327
+ | `startCommit` | The hash the current process started from (before its first turn). |
328
+ | `currentCommit` | HEAD's hash at render time. |
329
+ | `previousCommit` | The hash before the last transition (HEAD's parent, in-process). |
330
+ | `state` | The state whose content is being rendered. |
331
+ | `actor` | The actor this render is for. |
332
+ | `processDiff` | `startCommit..HEAD` plus the pending working-tree diff. |
333
+ | `lastDiff` | The diff of the last transition alone. |
334
+ | `processCost` | The accumulated token cost of the current process — the sum of every `Gtd-Cost:` trailer recorded by `gtd step <actor> --cost=<n>` across its turn commits, plus the in-flight step's own `--cost` (so a `commit:` squash template sees the whole-process total). A number, `0` when none recorded. See ["Token cost"](#token-cost). |
335
+ | `processCostByModel` | The same accumulated cost broken down per model: an array of `{ model, cost }`, highest-cost first, one entry per distinct `--model` tag (costs recorded with no `--model` group under `"unspecified"`). A squash template iterates it to itemize the feature's cost per model. Empty when none recorded. See ["Token cost"](#token-cost). |
336
+ | `read(path)` | Reads a working-tree file (pending contents, not HEAD's) by repo-relative path. Throws for a missing/unreadable path — for a `commit:` template, that throw refuses the step (see [STATES.md §8](../STATES.md#8-the-squash-lifecycle)). |
337
+ | `vars` | The merged three-layer variable map, always a flat `Record<string, string>` — see ["Variables"](#variables) below. |
338
+
339
+ ### Token cost
340
+
341
+ A loop driver that knows how many tokens the invocation it just drove cost — and
342
+ on which model — can record both with
343
+ `gtd step <actor> --cost=<n> [--model=<name>]` (`<n>` a non-negative number).
344
+ gtd appends them to that turn's commit as a `Gtd-Cost: <n> <model>` trailer — a
345
+ blank line then the trailer, below the untouched `gtd(<actor>): <state>` subject
346
+ — so the per-turn cost and model are persisted in the git log:
347
+
348
+ ```
349
+ gtd(agent): reviewing
350
+
351
+ Gtd-Cost: 1450 claude-opus-4-8
352
+ ```
353
+
354
+ `--model` is optional and requires `--cost` (a model tag with no token count
355
+ records nothing to sum); a cost recorded without a model is grouped under
356
+ `"unspecified"`. `gtd status` shows the process's running total and, when the
357
+ breakdown adds information, a per-model split (a `Cost:` line with indented
358
+ `<model>: <cost>` lines / a `cost` number and a `costByModel` array, omitted
359
+ when nothing has been recorded). `gtd step --json` echoes what it recorded
360
+ (`{ "state": …, "subject": …, "cost": 1450, "model": "claude-opus-4-8" }`).
361
+
362
+ Every template sees the running total as `it.processCost` and the per-model
363
+ breakdown as `it.processCostByModel` (the table above): the current process's
364
+ turn-commit entries, plus the in-flight step's own `--cost`/`--model`. Their
365
+ intended use is a `commit:` squash template, which renders against the pending
366
+ tree as the process collapses — so the whole feature's total AND its per-model
367
+ itemization (including the squashing step itself) land in the squash message
368
+ even though the intermediate per-turn trailers are discarded with the turns they
369
+ rode on:
370
+
371
+ ```yaml
372
+ done:
373
+ commit: |
374
+ feat: ship it
375
+
376
+ Total token cost: <%= it.processCost %>
377
+ <% it.processCostByModel.forEach(function(m){ %>
378
+ - <%= m.model %>: <%= m.cost %>
379
+ <% }) %>
380
+ ```
381
+
382
+ `--cost`/`--model` are accepted only by `gtd step` (a usage error on any other
383
+ command), and gtd never interprets the number's unit (tokens, cents, whatever
384
+ the driver records) or the model name — it only records, sums, and groups them.
336
385
 
337
386
  ## Variables
338
387
 
@@ -405,9 +405,25 @@ workflow:
405
405
  "M .gtd/COMMIT_MSG.md": done
406
406
 
407
407
  done:
408
- commit: '<%~ it.read(".gtd/COMMIT_MSG.md") %>'
408
+ commit: |
409
+ <%~ it.read(".gtd/COMMIT_MSG.md") %>
410
+
411
+ Token cost: <%= it.processCost %>
412
+ <% it.processCostByModel.forEach(function(m){ %>
413
+ - <%= m.model %>: <%= m.cost %>
414
+ <% }) %>
409
415
  ````
410
416
 
417
+ The `done` commit appends a token-cost summary to the squashed message: if the
418
+ loop driving this workflow passes `gtd step <actor> --cost=<n> --model=<name>`
419
+ after each agent turn, `it.processCost` is the summed cost of the whole cycle
420
+ (including the `squashing` step itself) and `it.processCostByModel` itemizes it
421
+ per model — so the single squash commit records the feature's complete cost,
422
+ broken down by which model spent what, even though the per-turn trailers are
423
+ discarded with the turns they collapsed. Drop these lines if your driver doesn't
424
+ track cost — `it.processCost` is `0` and `it.processCostByModel` empty then. See
425
+ [Configuration: Token cost](../configuration.md#token-cost).
426
+
411
427
  A clean `gtd step human` at `grilling-answer`/`architecting-answer` (the `C`
412
428
  event) accepts the current phase's draft and moves on; any other pending change
413
429
  loops back for another round. `picking` is a `script` state whose only job is to
package/docs/loop.md CHANGED
@@ -5,7 +5,10 @@ gtd (v3, the pattern machine) exposes three commands a loop driver combines:
5
5
  - **`gtd step <actor>`** — authenticate as `<actor>`, match the resolved rest's
6
6
  declared patterns against the pending changes, and commit (or squash) the one
7
7
  resulting transition. Performs AT MOST one transition — there is no fixpoint
8
- chain to drive.
8
+ chain to drive. Pass `--cost=<n>` (optionally `--model=<name>`) to record the
9
+ just-finished invocation's token cost and model on the turn commit; gtd sums
10
+ them across the process into `it.processCost`/`it.processCostByModel` (see
11
+ [Configuration: Token cost](configuration.md#token-cost)).
9
12
  - **`gtd next`** — print the resolved rest's rendered script/prompt/message,
10
13
  without mutating anything.
11
14
  - **`gtd run`** — execute the resolved rest's emitted script (only for a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "3.4.0",
3
+ "version": "3.5.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": {