@pmelab/gtd 1.8.0 → 1.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
@@ -50,7 +50,14 @@ No config file, no setup subcommand.
50
50
 
51
51
  Run `gtd` from your repository's working directory — it prints the next prompt
52
52
  to stdout. It takes **no ref argument** — the review base is always
53
- auto-computed.
53
+ auto-computed. The one exception is `gtd review <target>`: an explicit,
54
+ on-demand human review against a chosen base (see
55
+ [Review subcommand](#review-subcommand) below).
56
+
57
+ On an idle tree outside a process (no steering files, nothing reviewable), `gtd`
58
+ now runs a **health check** instead of stopping immediately — `gtd review` is
59
+ the only way to start an ad-hoc review when the automatic review base yields no
60
+ diff.
54
61
 
55
62
  ## JSON output mode
56
63
 
@@ -181,7 +188,8 @@ The complete set:
181
188
  `gtd: new task` · `gtd: grilling` · `gtd: grilled` · `gtd: planning` ·
182
189
  `gtd: building` · `gtd: errors` · `gtd: feedback` · `gtd: fixing` ·
183
190
  `gtd: package done` · `gtd: awaiting review` · `gtd: done` · `gtd: health-check`
184
- · `gtd: health-fix` — plus the hand-made `gtd: transport` (see below).
191
+ · `gtd: health-fix` · `gtd: reviewing` — plus the hand-made `gtd: transport`
192
+ (see below).
185
193
 
186
194
  The last commit subject is bucketed two ways:
187
195
 
@@ -494,9 +502,9 @@ loop before the next one starts.
494
502
  (`gtd: done`) → **Squashing** → **Idle**. The Squashing agent authors a
495
503
  conventional-commits message from the full process diff and squashes all
496
504
  intermediate `gtd: *` commits into one with `git reset --soft <base>` +
497
- <<<<<<< HEAD `git commit`, then **gtd STOPs**. The base is the parent of the
498
- current cycle's start marker **nearest to HEAD** (the last `gtd: new task`;
499
- for legacy cycles the last contiguous `gtd: grilling` run), and on a feature
505
+ `git commit`, then **gtd STOPs**. The base is the parent of the current
506
+ cycle's start marker **nearest to HEAD** (the last `gtd: new task`; for
507
+ legacy cycles the last contiguous `gtd: grilling` run), and on a feature
500
508
  branch it never reaches below the merge-base with the default branch — stray
501
509
  markers left behind by older squashes can never drag the squash into
502
510
  previously shipped features. Post-squash review does not fire automatically —
@@ -505,20 +513,13 @@ loop before the next one starts.
505
513
  has no unrelated code dirty — a lone untracked `SQUASH_MSG.md` is tolerated
506
514
  and deleted before the squash commit. If unrelated code is dirty at
507
515
  `gtd: done`, gtd routes to **New Feature** instead. Set `squash: false` in
508
- ======= `git commit`, then **gtd STOPs**. Post-squash review does not fire
509
- automatically it fires only on the next manual `gtd` run (when the squash
510
- commit is the boundary HEAD and a reviewable diff exists). Squashing fires
511
- when the tree has no unrelated code dirty — a lone untracked `SQUASH_MSG.md`
512
- is tolerated and deleted before the squash commit. If unrelated code is dirty
513
- at `gtd: done`, gtd routes to **New Feature** instead. Set `squash: false` in
514
- > > > > > > > origin/46-config-schema `.gtdrc` to skip squashing and go
515
- > > > > > > > straight to Idle. Checking off REVIEW.md checkboxes (`- [ ]` →
516
- > > > > > > > `- [x]`) also counts as approval and routes to **Done** — they
517
- > > > > > > > are navigation aids, not feedback. Only **non-checkbox** edits
518
- > > > > > > > (code changes, inline comments, textual annotations in
519
- > > > > > > > REVIEW.md) trigger **Accept Review**, which seeds a fresh
520
- > > > > > > > `TODO.md` from your feedback, discards your code edits, removes
521
- > > > > > > > `REVIEW.md`, and re-enters Grilling — the loop starts over.
516
+ `.gtdrc` to skip squashing and go straight to Idle. Checking off REVIEW.md
517
+ checkboxes (`- [ ]` `- [x]`) also counts as approval and routes to **Done**
518
+ they are navigation aids, not feedback. Only **non-checkbox** edits (code
519
+ changes, inline comments, textual annotations in REVIEW.md) trigger **Accept
520
+ Review**, which seeds a fresh `TODO.md` from your feedback, discards your
521
+ code edits, removes `REVIEW.md`, and re-enters Grilling the loop starts
522
+ over.
522
523
 
523
524
  ## Configuration
524
525
 
@@ -738,10 +739,50 @@ When there are genuinely no open questions left, the agent writes the sentinel
738
739
  line `no open questions — run gtd to plan` and leaves **no** markers — a clean
739
740
  tree with no markers is what advances the plan to **Grilled** and decomposition.
740
741
 
741
- ## Formatting
742
+ ## Subcommands
743
+
744
+ gtd ships two subcommands: `format` and `review`.
745
+
746
+ ## Review subcommand
747
+
748
+ ```bash
749
+ gtd review <target>
750
+ ```
751
+
752
+ Starts an explicit, on-demand human review of the diff between HEAD and
753
+ `merge-base(<target>, HEAD)`. Use this when the automatic review base yields no
754
+ diff (idle tree outside a process) or when you want to review against a specific
755
+ base regardless of workflow state.
756
+
757
+ ### Flow
758
+
759
+ 1. `gtd review <target>` computes the diff HEAD adds over
760
+ `merge-base(<target>, HEAD)`.
761
+ 2. The edge writes an empty anchor commit `gtd: reviewing` (no content — just
762
+ the marker).
763
+ 3. The **Clean** state writes `REVIEW.md` with the computed diff and emits the
764
+ normal review prompt. `--json` is accepted and enables auto-advance mode
765
+ (same behavior as the default command).
766
+ 4. The normal loop then drives: **Await Review** → **Done** → **Squashing**,
767
+ collapsing back to the `gtd: reviewing` anchor commit.
768
+
769
+ ### Error handling
770
+
771
+ All errors exit with **code 1** and write a message to **stderr**:
772
+
773
+ - **Missing target** — `gtd review` with no argument:
774
+ `gtd review: missing target argument`
775
+ - **Extra arguments** — `gtd review main extra`:
776
+ `gtd review: too many arguments — expected one target, got: …`
777
+ - **Unresolvable ref** — the target cannot be resolved by git:
778
+ `gtd review: cannot resolve ref '<target>': <error message>`
779
+ - **Empty diff** — the merge-base diff between `<target>` and HEAD is empty
780
+ (nothing to review):
781
+ `gtd review: nothing to review (<target> diff is empty after filtering)`
782
+
783
+ ## Format subcommand
742
784
 
743
- gtd ships a `format` subcommand the **only** subcommand — that formats a
744
- markdown file in place:
785
+ `gtd format` formats a markdown file in place:
745
786
 
746
787
  ```bash
747
788
  gtd format <file>
@@ -368477,8 +368477,8 @@ var fixing_default = '<%~ include("@header") %>\n\n<%~ include("@context", { con
368477
368477
  // src/prompts/agentic-review.md
368478
368478
  var agentic_review_default = '<%~ include("@header") %>\n\n<%~ include("@context", { context: it.context, fenceFor: it.fenceFor }) %>\nSpawn a **reviewing subagent** using model `<%= it.model %>`. The package\'s task spec\nfiles and its cumulative diff are found below. The subagent must:\n\n1. **Read the task spec files** \u2014 they define the acceptance criteria. Every\n requirement must be checked.\n2. **Read the package diff** \u2014 examine every hunk to determine whether the\n implementation satisfies the spec.\n3. **Always write `FEEDBACK.md`** in the repo root:\n - **Fully satisfies the spec** \u2192 write an **empty** `FEEDBACK.md` (zero bytes\n or whitespace only). This empty file is the **approval** signal.\n - **Does not fully satisfy** \u2192 write concrete, actionable findings anchored\n to specific file and symbol names, grouped under short headings, so the fix\n agent can act without re-reading the diff.\n4. **Do not edit source files and do not commit** \u2014 the reviewer only writes\n `FEEDBACK.md`, left uncommitted.\n<% if (it.context.packages[0]) { %>\n<%~ include("@package", { pkg: it.context.packages[0], fenceFor: it.fenceFor }) %>\n<% } %><% if (it.context.refDiff && it.context.refDiff.trim()) { %>\n<%~ include("@diff", { heading: "Package diff", diff: it.context.refDiff, fenceFor: it.fenceFor }) %>\n<% } %>\n\n<%~ include(it.tail) %>\n';
368479
368479
 
368480
- // src/prompts/clean.md
368481
- var clean_default = '<%~ include("@header") %>\n\n<%~ include("@context", { context: it.context, fenceFor: it.fenceFor }) %>\nSpawn a **planning-model subagent** using model `<%= it.model %>` to author a\n`REVIEW.md` file that will help a human to review the changes. It must:\n\n1. **Read the diff inlined below** \u2014 extract the changed hunks with their file\n paths.\n2. **Group hunks semantically** \u2014 cluster hunks that serve the same logical\n concern (the same feature, refactor, or fix), even across files. Aim for the\n fewest chunks that keep the review navigable.\n3. **Write `REVIEW.md`** in the repo root in this format:\n\n ```markdown\n # Review: <short-hash>\n\n <!-- base: <full-hash> -->\n\n ## <Chunk Title>\n\n <What this chunk changes and why>\n\n - [ ] ./path/to/file.ts#42\n - [ ] ./path/to/file.ts#99\n\n ## <Another Chunk Title>\n\n <Explanation>\n\n - [ ] ./path/to/another.ts#1\n ```\n\n - `<short-hash>` is the first 7 characters of the review base SHA;\n `<full-hash>` is the full SHA. Both are read from the `Review base:` line /\n diff label in the prompt context.\n - Chunk titles are short imperative phrases (\u2264 6 words).\n - Explanations describe _what_ changed and _why_, not just where.\n - File pointers are relative, prefixed with `./`; the line numbers (`#42`)\n are creation-time hints that will drift \u2014 not authoritative.\n - Checkboxes (`- [ ]`) signal approval \u2014 ticking them (with no other edits)\n counts as approving the review (`gtd: done`). Only non-checkbox edits to\n `REVIEW.md` (or any code edits) are treated as a change-request.\n - The user checks off or edits items in place as they work through the\n review; there is no separate Resolved section.\n\n4. Leave `REVIEW.md` **uncommitted**, the outer loop will process it.\n<% if (it.context.refDiff && it.context.refDiff.trim()) { %>\n<%= it.context.reviewBase !== undefined ? "\\nReview base: " + it.context.reviewBase + "\\n" : "" %>\n<%~ include("@diff", { heading: "Changes to review (`git diff " + (it.context.reviewBase !== undefined ? it.context.reviewBase : "<base>") + " HEAD`)", diff: it.context.refDiff, fenceFor: it.fenceFor }) %>\n<% } %>\n\n<%~ include(it.tail) %>\n';
368480
+ // src/prompts/review.md
368481
+ var review_default = '<%~ include("@header") %>\n\n<%~ include("@context", { context: it.context, fenceFor: it.fenceFor }) %>\nSpawn a **planning-model subagent** using model `<%= it.model %>` to author a\n`REVIEW.md` file that will help a human to review the changes. It must:\n\n1. **Read the diff inlined below** \u2014 extract the changed hunks with their file\n paths.\n2. **Group hunks semantically** \u2014 cluster hunks that serve the same logical\n concern (the same feature, refactor, or fix), even across files. Aim for the\n fewest chunks that keep the review navigable.\n3. **Write `REVIEW.md`** in the repo root in this format:\n\n ```markdown\n # Review: <short-hash>\n\n <!-- base: <full-hash> -->\n\n ## <Chunk Title>\n\n <What this chunk changes and why>\n\n - [ ] ./path/to/file.ts#42\n - [ ] ./path/to/file.ts#99\n\n ## <Another Chunk Title>\n\n <Explanation>\n\n - [ ] ./path/to/another.ts#1\n ```\n\n - `<short-hash>` is the first 7 characters of the review base SHA;\n `<full-hash>` is the full SHA. Both are read from the `Review base:` line /\n diff label in the prompt context.\n - Chunk titles are short imperative phrases (\u2264 6 words).\n - Explanations describe _what_ changed and _why_, not just where.\n - File pointers are relative, prefixed with `./`; the line numbers (`#42`)\n are creation-time hints that will drift \u2014 not authoritative.\n - Checkboxes (`- [ ]`) signal approval \u2014 ticking them (with no other edits)\n counts as approving the review (`gtd: done`). Only non-checkbox edits to\n `REVIEW.md` (or any code edits) are treated as a change-request.\n - The user checks off or edits items in place as they work through the\n review; there is no separate Resolved section.\n\n4. Leave `REVIEW.md` **uncommitted**, the outer loop will process it.\n<% if (it.context.refDiff && it.context.refDiff.trim()) { %>\n<%= it.context.reviewBase !== undefined ? "\\nReview base: " + it.context.reviewBase + "\\n" : "" %>\n<%~ include("@diff", { heading: "Changes to review (`git diff " + (it.context.reviewBase !== undefined ? it.context.reviewBase : "<base>") + " HEAD`)", diff: it.context.refDiff, fenceFor: it.fenceFor }) %>\n<% } %>\n\n<%~ include(it.tail) %>\n';
368482
368482
 
368483
368483
  // src/prompts/squashing.md
368484
368484
  var squashing_default = '<%~ include("@header") %>\n\n<%~ include("@context", { context: it.context, fenceFor: it.fenceFor }) %>\nThe process is **approved and done**. Your job is to author a clean\nconventional-commits squash message and hand it off to the harness.\n\n### Step 1 \u2014 Extract decisions from grilling rounds\n\nScan the git history of recent `gtd: ...` commits. Look for changes to `TODO.md`\n\u2014 specifically the `## Captured input (grilling)` sections and any edits to plan\ntext. Extract **key decisions, trade-offs, and design choices** made during\ngrilling rounds. These will appear in the commit body so the history is\nself-documenting.\n\n### Step 2 \u2014 Draft the commit message\n\nDraft ONE conventional-commits message:\n\n```\ntype(scope): subject\n\nbody (explain the why \u2014 motivation, trade-offs, key decisions from grilling)\n```\n\n- **type**: `feat` / `fix` / `refactor` / `chore` / `docs` / `test`\n- **subject**: imperative mood, \u2264 72 characters, lowercase after the colon\n- **body**: include the important decisions / trade-offs from grilling sessions.\n Omit if there were no meaningful decisions to capture.\n\n### Step 3 \u2014 Write SQUASH_MSG.md and hand off\n\nWrite the commit message (plain text, no markdown wrapper) to `SQUASH_MSG.md` in\nthe repo root, then leave it uncommitted \u2014 the harness handles the squash commit\non the next cycle once it sees `SQUASH_MSG.md`.\n\n**Do not run `git reset --soft` or `git commit` yourself** \u2014 the harness handles\nthe squash commit.\n<% if (it.context.squashDiff && it.context.squashDiff.trim()) { %>\n<%= it.context.squashBase !== undefined ? "\\nSquash base: " + it.context.squashBase + "\\n" : "" %>\n<%~ include("@diff", { heading: "Full-process diff (`git diff " + (it.context.squashBase !== undefined ? it.context.squashBase : "<squashBase>") + " HEAD`)", diff: it.context.squashDiff, fenceFor: it.fenceFor }) %>\n<% } %>\n\n<%~ include(it.tail) %>\n';
@@ -368532,7 +368532,7 @@ eta.loadTemplate("@decompose", decompose_default);
368532
368532
  eta.loadTemplate("@building", building_default);
368533
368533
  eta.loadTemplate("@fixing", fixing_default);
368534
368534
  eta.loadTemplate("@agentic-review", agentic_review_default);
368535
- eta.loadTemplate("@clean", clean_default);
368535
+ eta.loadTemplate("@review", review_default);
368536
368536
  eta.loadTemplate("@squashing", squashing_default);
368537
368537
  eta.loadTemplate("@escalate", escalate_default);
368538
368538
  eta.loadTemplate("@idle", idle_default);
@@ -368544,7 +368544,7 @@ var STATE_TEMPLATE = {
368544
368544
  building: "@building",
368545
368545
  fixing: "@fixing",
368546
368546
  "agentic-review": "@agentic-review",
368547
- clean: "@clean",
368547
+ clean: "@review",
368548
368548
  squashing: "@squashing",
368549
368549
  escalate: "@escalate",
368550
368550
  idle: "@idle",
@@ -385730,7 +385730,7 @@ function clean(original, cloned) {
385730
385730
  }
385731
385731
  return cloned;
385732
385732
  }
385733
- var clean_default2 = clean;
385733
+ var clean_default = clean;
385734
385734
  function printFrontMatter({ node }) {
385735
385735
  return node.raw;
385736
385736
  }
@@ -385835,7 +385835,7 @@ function normalizePrinter(printer2) {
385835
385835
  if (originalCleanFunction && frontMatterSupport.massageAstNode) {
385836
385836
  massageAstNode = new Proxy(originalCleanFunction, {
385837
385837
  apply(target, thisArgument, argumentsList) {
385838
- clean_default2(...argumentsList);
385838
+ clean_default(...argumentsList);
385839
385839
  return Reflect.apply(target, thisArgument, argumentsList);
385840
385840
  }
385841
385841
  });
@@ -388279,6 +388279,7 @@ var PACKAGE_DONE_SUBJECT = "gtd: package done";
388279
388279
  var AWAITING_REVIEW_SUBJECT = "gtd: awaiting review";
388280
388280
  var DONE_SUBJECT = "gtd: done";
388281
388281
  var REVIEW_FEEDBACK_SUBJECT = "gtd: review feedback";
388282
+ var REVIEWING_SUBJECT = "gtd: reviewing";
388282
388283
  var SQUASH_MSG_FILE = "SQUASH_MSG.md";
388283
388284
  var STEERING_FILES = [
388284
388285
  TODO_FILE,
@@ -388577,7 +388578,7 @@ var gatherEvents = () => (
388577
388578
  const squashCycle = squashHistory.slice(prevDoneIdx + 1, lastDoneIdxForSquash + 1);
388578
388579
  let startIdx = -1;
388579
388580
  for (let i = squashCycle.length - 1; i >= 0; i--) {
388580
- if (subjectOf(squashCycle[i]) === NEW_TASK_SUBJECT) {
388581
+ if (subjectOf(squashCycle[i]) === NEW_TASK_SUBJECT || subjectOf(squashCycle[i]) === REVIEWING_SUBJECT) {
388581
388582
  startIdx = i;
388582
388583
  break;
388583
388584
  }
@@ -388684,6 +388685,15 @@ var captureAndRevert = (git, subject, onRegen) => Effect_exports.gen(function* (
388684
388685
  yield* git.revertNoCommit("HEAD");
388685
388686
  return captured;
388686
388687
  });
388688
+ var reviewAgainst = (target) => Effect_exports.gen(function* () {
388689
+ const git = yield* GitService;
388690
+ const targetHash = yield* git.resolveRef(target);
388691
+ const mergeBase = yield* git.mergeBase(target, "HEAD");
388692
+ const mergeBaseHash = Option_exports.isNone(mergeBase) || mergeBase.value === targetHash ? targetHash : mergeBase.value;
388693
+ const refDiff = yield* git.diffRef(mergeBaseHash, WORKFLOW_FILE_EXCLUDES);
388694
+ if (refDiff.trim().length === 0) return void 0;
388695
+ return { reviewBase: mergeBaseHash, refDiff };
388696
+ });
388687
388697
  var perform = (action) => (
388688
388698
  // fallow-ignore-next-line complexity
388689
388699
  Effect_exports.gen(function* () {
@@ -389198,6 +389208,20 @@ var resolve4 = (events) => {
389198
389208
  if (p4.todoExists) return resolveGrilling(p4, counters, head7);
389199
389209
  return resolveCleanOrIdle(p4, counters, head7) ?? corrupt();
389200
389210
  };
389211
+ var cleanResult = (args2) => ({
389212
+ state: "clean",
389213
+ autoAdvance: args2.autoAdvance,
389214
+ context: {
389215
+ ...buildContext(
389216
+ {
389217
+ ...DEFAULT_PAYLOAD,
389218
+ reviewBase: args2.reviewBase,
389219
+ refDiff: args2.refDiff
389220
+ },
389221
+ { testFixCount: 0, reviewFixCount: 0, healthFixCount: 0 }
389222
+ )
389223
+ }
389224
+ });
389201
389225
 
389202
389226
  // src/State.ts
389203
389227
  var EDGE_ONLY_STATES2 = /* @__PURE__ */ new Set([
@@ -389257,10 +389281,6 @@ function makeProgram(opts = {}) {
389257
389281
  yield* formatFile(args2[0]);
389258
389282
  return;
389259
389283
  }
389260
- if (sub !== void 0) {
389261
- return yield* Effect_exports.fail(new Error(`unknown command '${sub}'`));
389262
- }
389263
- const config2 = yield* ConfigService;
389264
389284
  const git = yield* GitService;
389265
389285
  const fs9 = yield* FileSystem_exports.FileSystem;
389266
389286
  const topLevel = yield* git.topLevel();
@@ -389273,6 +389293,50 @@ function makeProgram(opts = {}) {
389273
389293
  )
389274
389294
  );
389275
389295
  }
389296
+ const config2 = yield* ConfigService;
389297
+ if (sub === "review") {
389298
+ const args2 = argv.slice(3).filter((a5) => a5.length > 0 && !a5.startsWith("--"));
389299
+ if (args2.length === 0) {
389300
+ return yield* Effect_exports.fail(new Error("gtd review: missing target argument"));
389301
+ }
389302
+ if (args2.length > 1) {
389303
+ return yield* Effect_exports.fail(
389304
+ new Error(
389305
+ `gtd review: too many arguments \u2014 expected one target, got: ${args2.join(", ")}`
389306
+ )
389307
+ );
389308
+ }
389309
+ const target = args2[0];
389310
+ const reviewResult = yield* reviewAgainst(target).pipe(
389311
+ Effect_exports.catchAll(
389312
+ (error) => Effect_exports.fail(new Error(`gtd review: cannot resolve ref '${target}': ${error.message}`))
389313
+ )
389314
+ );
389315
+ if (reviewResult === void 0) {
389316
+ return yield* Effect_exports.fail(
389317
+ new Error(`gtd review: nothing to review (${target} diff is empty after filtering)`)
389318
+ );
389319
+ }
389320
+ const { reviewBase, refDiff } = reviewResult;
389321
+ yield* git.commitAllWithPrefix(REVIEWING_SUBJECT);
389322
+ const result = cleanResult({ reviewBase, refDiff, autoAdvance: json2 });
389323
+ const builtPrompt = buildPrompt(result, config2.resolveModel, json2 ? "json" : "plain");
389324
+ if (json2) {
389325
+ write4(
389326
+ JSON.stringify({
389327
+ state: result.state,
389328
+ autoAdvance: result.autoAdvance,
389329
+ prompt: builtPrompt
389330
+ }) + "\n"
389331
+ );
389332
+ } else {
389333
+ write4(builtPrompt);
389334
+ }
389335
+ return;
389336
+ }
389337
+ if (sub !== void 0) {
389338
+ return yield* Effect_exports.fail(new Error(`unknown command '${sub}'`));
389339
+ }
389276
389340
  let hops = 0;
389277
389341
  while (true) {
389278
389342
  hops += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "1.8.0",
3
+ "version": "1.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": {