@pmelab/gtd 18.1.0 → 20.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.
@@ -30964,7 +30964,7 @@ const changesSince = (hash, pattern) => {
30964
30964
  };
30965
30965
  /** The commit the process stands on at this point of the flow. */
30966
30966
  const head = () => ctx().head();
30967
- /** The process's diff base: the commit before it began, or the base `gtd --entry` fixed. */
30967
+ /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
30968
30968
  const start = () => ctx().start();
30969
30969
  /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
30970
30970
  const skillsFor = (localName) => ctx().skillsFor(localName);
@@ -31013,6 +31013,7 @@ const checkScript = (command, options) => {
31013
31013
  return [
31014
31014
  "#!/usr/bin/env sh",
31015
31015
  "set +e",
31016
+ ...options.preamble ?? [],
31016
31017
  ...removal(options.sweep ?? []),
31017
31018
  `mkdir -p "$(dirname ${report})"`,
31018
31019
  "(",
@@ -31106,12 +31107,13 @@ const wrote = (path) => changes(path).some((c) => c.status !== "deleted");
31106
31107
  * command passed.
31107
31108
  */
31108
31109
  const check = async (name, command, options) => {
31109
- const { report, label, sweep, sweepOnGreen } = options;
31110
+ const { report, label, sweep, sweepOnGreen, preamble } = options;
31110
31111
  await run$3(name, checkScript(command, {
31111
31112
  report,
31112
31113
  stamp: head().slice(0, 7),
31113
31114
  sweep,
31114
- sweepOnGreen
31115
+ sweepOnGreen,
31116
+ preamble
31115
31117
  }), { label });
31116
31118
  return !wrote(report);
31117
31119
  };
@@ -47620,6 +47622,9 @@ const TRAILER_READERS = {
47620
47622
  "Gtd-Step": (value, into) => {
47621
47623
  into.step ??= parseStepId(value);
47622
47624
  },
47625
+ "Gtd-Workflow": (value, into) => {
47626
+ if (value !== "") into.workflow ??= value;
47627
+ },
47623
47628
  "Gtd-Judge": (value, into) => {
47624
47629
  const parsed = parseJson(value);
47625
47630
  if (isVerdict(parsed)) into.judge.push(parsed);
@@ -47644,6 +47649,7 @@ const parseCommitMessage = (message) => {
47644
47649
  const subject = (newline === -1 ? message : message.slice(0, newline)).trim();
47645
47650
  const trailers = {
47646
47651
  step: void 0,
47652
+ workflow: void 0,
47647
47653
  reviewBase: void 0,
47648
47654
  judge: [],
47649
47655
  vars: {},
@@ -47660,6 +47666,7 @@ const parseCommitMessage = (message) => {
47660
47666
  const formatCommitMessage = (spec) => {
47661
47667
  const lines = [];
47662
47668
  if (spec.step !== void 0) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`);
47669
+ if (spec.workflow !== void 0) lines.push(`Gtd-Workflow: ${spec.workflow}`);
47663
47670
  if (spec.reviewBase !== void 0) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`);
47664
47671
  for (const [name, value] of Object.entries(spec.vars ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) lines.push(`Gtd-Var: ${name}=${value}`);
47665
47672
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
@@ -47767,11 +47774,6 @@ const unknownOptions = (step) => {
47767
47774
  return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
47768
47775
  };
47769
47776
  const replay = async (input) => {
47770
- let entryRead = false;
47771
- const args = { get entry() {
47772
- entryRead = true;
47773
- return input.episode.entry;
47774
- } };
47775
47777
  const commits = input.episode.commits.map((c) => ({
47776
47778
  ...c,
47777
47779
  parsed: parseCommitMessage(c.message)
@@ -47962,8 +47964,7 @@ const replay = async (input) => {
47962
47964
  kind: "rest",
47963
47965
  rest: step,
47964
47966
  trace,
47965
- landed,
47966
- entryRead
47967
+ landed
47967
47968
  });
47968
47969
  pendingUsed = true;
47969
47970
  landed = step;
@@ -48070,7 +48071,7 @@ const replay = async (input) => {
48070
48071
  };
48071
48072
  installContext(context);
48072
48073
  try {
48073
- const flowDone = input.flow(args).then(() => {
48074
+ const flowDone = input.flow({}).then(() => {
48074
48075
  if (outcome === void 0 && trace.length === 0) outcome = {
48075
48076
  kind: "failed",
48076
48077
  message: "gtd: the flow returned without reaching any step"
@@ -48157,8 +48158,7 @@ const architectPersona = `You are the technical planning voice in gtd's build pi
48157
48158
  once product concerns are settled, reading them cold, no carried
48158
48159
  conversation. Work out the *how* per concern — structure, data models,
48159
48160
  tech-stack choices, error handling — raise the technical open
48160
- questions, then write one package spec per concern with no further
48161
- judgement call: the grouping is already decided.`;
48161
+ questions, then pin interfaces and tests in the architecture document.`;
48162
48162
  const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
48163
48163
  deliberately separate from whoever wrote the code, with no attachment
48164
48164
  to it. Write a structured review document grouping a diff into
@@ -48167,12 +48167,6 @@ just approving; answer the human's questions inline in the review;
48167
48167
  and, when asked, fix the small nits the human flagged and the risks
48168
48168
  you marked yourself, each in one batch.
48169
48169
  Beyond that you never fix or build anything yourself.`;
48170
- const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
48171
- pipeline, checking one freshly-built package against its spec. Verify
48172
- only: tasks done, criteria met, code sound and consistent with the
48173
- codebase. Write feedback only when something is genuinely wrong —
48174
- otherwise write nothing; a clean turn IS the approval. Never fix what
48175
- you find — naming it precisely enough for a fix turn is the whole job.`;
48176
48170
  const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
48177
48171
  package's declared scope end to end, tests first, and come back to fix
48178
48172
  things when redirected — a failing check, or reviewer feedback. Stay
@@ -48382,19 +48376,11 @@ carries it.
48382
48376
  What each change does next (then run \`gtd land\`):
48383
48377
  - **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
48384
48378
  `;
48385
- const architecturePreMessage = () => `Judging whether \`.gtd/REQUIREMENTS.md\`'s settled concerns need a
48386
- dedicated architecture pass — real structural decisions, multiple
48387
- integration points, or a non-obvious tradeoff — before packages
48388
- are written. Run \`gtd judge answer\` and pipe a verdict for
48389
- \`architectureWarranted\` — or land untouched to run the full pass
48390
- (the conservative default; a skipped judgment never suppresses
48391
- it).
48392
- `;
48393
48379
  const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
48394
48380
  \`.gtd/FEEDBACK.md\` holds the failing output.
48395
48381
 
48396
48382
  What each change does next (then run \`gtd land\`):
48397
- - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**start-gate.check**). To repair the baseline as its own separate reviewed commit instead, abandon this start and run \`gtd --entry fix-precheck\` from a clean \`idle\`.
48383
+ - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**start-gate.check**). To repair the baseline as its own separate reviewed commit instead, abandon this start and run \`gtd --workflow fix\` from a clean \`idle\`.
48398
48384
  `;
48399
48385
  const reviewGateBlockedMessage = () => `The test baseline is red — gtd will not start a review on a broken suite.
48400
48386
  \`.gtd/FEEDBACK.md\` holds the failing output.
@@ -48554,22 +48540,46 @@ ${footnoteFoldIn}${codeThreadReplies()}
48554
48540
  authority is to merge only, never to split — the whole problem
48555
48541
  is over-granularity
48556
48542
  - Record every merge under \`## Merged Concerns\`,
48557
- carrying both merged requirements verbatim so spec review
48558
- still covers each independently
48543
+ carrying both merged requirements verbatim so each requirement
48544
+ stays traceable to its tests
48559
48545
  - A merge raises no open question and stops for no
48560
48546
  human — do not route it to \`architecture.gate\` for a veto; the
48561
- human sees it when reviewing the plan, and spec review is the
48562
- real safety net
48547
+ human sees it when reviewing the plan
48563
48548
  - Prefer fewer, larger packages — the smallest independently
48564
48549
  valuable change, not the smallest change that compiles
48565
48550
  - Every open point here is TECHNICAL — triage already resolved
48566
48551
  the product ones, one phase earlier. PERMISSIVE: answer it
48567
48552
  yourself unless you genuinely cannot defend a default; a wrong
48568
- technical call is still caught at spec review
48553
+ technical call is still caught by the full run and the quality lap
48569
48554
  - The narrow exception: a simplification that drops something
48570
48555
  \`.gtd/REQUIREMENTS.md\` mentions is an open question, not a
48571
48556
  silent default
48572
48557
 
48558
+ - \`.gtd/ARCHITECTURE.md\` is the acceptance spec the human signs
48559
+ off. It carries these sections, in this order:
48560
+ 1. \`## Open Questions\` (qa format, first, only when present)
48561
+ 2. \`## Interfaces\` — new and changed signatures/contracts
48562
+ only, as fenced TypeScript (or the repository's language)
48563
+ 3. \`## Call Stacks\` — seam level, entry point → module
48564
+ boundary; one line per hop, never per line of code
48565
+ 4. \`## E2E Scenarios\` — Gherkin in fenced \`gherkin\` blocks
48566
+ (whatever e2e framework the repository uses), each preceded
48567
+ by a line \`- e2e: <path>\`. With no user-visible
48568
+ behaviour change, the literal line \`No e2e change.\` plus a
48569
+ one-line reason — never empty or missing
48570
+ 5. \`## Unit Tests\` — one \`### <interface>\` per entry of
48571
+ \`## Interfaces\`, each test a line
48572
+ \`- unit: <path> — <behaviour> (covers <concern>)\`;
48573
+ never against an internal helper. A requirement no test can
48574
+ cover (docs, README, deletions, config) goes under a
48575
+ \`### Chores\` heading in this section as
48576
+ \`- chore: <path> — <what> (covers <concern>)\`
48577
+ 6. \`## Merged Concerns\` — kept, when any merge happened
48578
+ 7. \`## Answered Questions\` — last
48579
+ - Coverage rule: every concern in \`.gtd/REQUIREMENTS.md\` appears
48580
+ in at least one \`(covers …)\` tail, as a test or a chore. Each
48581
+ test entry carries its file path and level (unit / e2e)
48582
+
48573
48583
  ${questionBar}
48574
48584
  ## Return lap
48575
48585
 
@@ -48581,26 +48591,41 @@ const architectureDecomposePrompt = () => `${styleBlock}
48581
48591
 
48582
48592
  ${stateFileRules}
48583
48593
  - The only state files this turn touches are the package files
48584
- under \`.gtd/packages/\` and \`.gtd/ARCHITECTURE.md\` (deleted) —
48585
- no other files for notes or output
48594
+ under \`.gtd/packages/\` — no other files for notes or output.
48595
+ Never delete \`.gtd/ARCHITECTURE.md\`: the build tail's full run
48596
+ still needs it, and package files may reference it
48586
48597
  - Work from \`.gtd/ARCHITECTURE.md\` if you wrote it earlier this
48587
48598
  conversation, otherwise read it (the converged technical
48588
- plan). It already lists an ordered set of concerns with every
48589
- merge/split judgement made — this turn is a mechanical
48590
- write-out, not a planning one. A \`## Merged Concerns\` heading
48591
- there records those merges, never a concern of its own: write
48592
- no package file for it
48593
- - Write one package file per concern, in the settled order,
48594
- under \`.gtd/packages/\` (e.g. \`.gtd/packages/01-name.md\`,
48595
- \`02-name.md\`, ...), each carrying that concern's
48596
- requirement(s) — both, independently, if merged — its
48597
- independent tasks, and each task's acceptance criteria as
48598
- \`- [ ]\` checkboxes and relevant paths
48599
- - Do not merge or split concerns here — that judgement already
48600
- happened; carry the settled grouping over verbatim. No
48601
- package file may reference any other \`.gtd/\` file
48602
- - Once written, delete \`.gtd/ARCHITECTURE.md\`. Leave everything
48603
- uncommitted and finish
48599
+ plan). It already lists the concerns, interfaces, e2e scenarios
48600
+ and unit tests — this turn groups them into packages. A
48601
+ \`## Merged Concerns\` heading there records merges, never a
48602
+ concern of its own: write no package file for it
48603
+ - A package is a consecutive run of \`## Unit Tests\` entries that go
48604
+ green together on the fast suite, at most ~8 unit-test entries
48605
+ each. Keep the merge rule: concerns whose footprints centre on the
48606
+ same files stay in one package unless the later one only consumes
48607
+ an interface the earlier one creates. Prefer fewer, larger
48608
+ packages. Order them so each stays green on the fast suite —
48609
+ interface-introducing packages first
48610
+ - Write the packages under \`.gtd/packages/\`, each carrying its
48611
+ requirement(s) — both, independently, if merged — its tasks, and
48612
+ each task's acceptance criteria as \`- [ ]\` checkboxes and
48613
+ relevant paths:
48614
+ - \`.gtd/packages/00-e2e-scenarios.md\` — package 0: it writes
48615
+ \`## E2E Scenarios\` as tests in the repository's own e2e
48616
+ framework and leaves them red. Write no such file when
48617
+ \`## E2E Scenarios\` says "No e2e change."
48618
+ - \`01-…\` onward — the unit-test packages. A requirement no test
48619
+ covers (docs, README, deletions, config) becomes a chore
48620
+ package, or a task of one; chore packages declare no tests
48621
+ - the last numbered package is the wiring package: CLI entry,
48622
+ workflow composition, config. A red e2e after it is a bug, not
48623
+ missing work
48624
+ - Every package with tests has a \`## Tests\` section, one line per
48625
+ declared test, copied from the architecture entries:
48626
+ \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
48627
+ none
48628
+ - Leave everything uncommitted and finish
48604
48629
  `;
48605
48630
  const architectureGateAnswerMessage = () => `This gate stops on every process, even with no open question, so
48606
48631
  you can discuss \`.gtd/ARCHITECTURE.md\`; changing nothing and re-running
@@ -48629,7 +48654,7 @@ What each change does next (then run \`gtd land\`):
48629
48654
  `;
48630
48655
  const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
48631
48656
  - The only state file this turn may write is \`.gtd/SATISFIED.md\`;
48632
- never delete the package file (the spec-review gate reads it
48657
+ never delete the package file (the declared-tests guard reads it
48633
48658
  after you)
48634
48659
  - The package to implement is \`${pkg}\`
48635
48660
  - First check its acceptance criteria against the current tree —
@@ -48638,6 +48663,13 @@ const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
48638
48663
  with each criterion's concrete evidence (commit, file, or
48639
48664
  symbol), change nothing else, and finish. Otherwise implement
48640
48665
  normally and skip that file
48666
+ - Write the tests the package declares under \`## Tests\` first,
48667
+ then the code that makes them pass; the turn is refused unless
48668
+ every declared test is in your diff
48669
+ - Package 0 (\`.gtd/packages/00-e2e-scenarios.md\`) writes the scenarios verbatim
48670
+ from \`## E2E Scenarios\` of \`.gtd/ARCHITECTURE.md\`, with only
48671
+ step code that typechecks, and leaves them red — no
48672
+ implementation
48641
48673
  - Implement every task the package describes, no more, no less,
48642
48674
  fanning independent ones out to parallel subagents where your
48643
48675
  harness supports it; leave other package files untouched
@@ -48654,15 +48686,6 @@ ${fixFeedbackPrompt}
48654
48686
  then legitimately report itself already satisfied later
48655
48687
  - Leave everything uncommitted and finish your turn — do not commit
48656
48688
  `;
48657
- const packagesItemFixSpecPrompt = (pkg) => `${stateFileRules}
48658
- - The only state file this turn touches is
48659
- \`.gtd/SPEC_FEEDBACK.md\` — address it, then delete it
48660
- - Read it (the reviewer's concerns) and the package spec
48661
- (\`${pkg}\`), then fix the code to
48662
- resolve every concern
48663
- - Delete \`.gtd/SPEC_FEEDBACK.md\` once resolved; leave everything
48664
- else uncommitted and finish your turn
48665
- `;
48666
48689
  const healthJudgeMessage = () => `The check is still red, and this isn't the first round —
48667
48690
  \`.gtd/FEEDBACK.md\` holds this round's output, and the previous round's
48668
48691
  is in history. Run \`gtd judge answer\` and pipe a
@@ -48700,45 +48723,22 @@ Edit \`.gtd/ESCALATION.md\` with fresh instructions for the next fix
48700
48723
  turn, or land untouched to give it one more attempt at the same
48701
48724
  analysis.
48702
48725
  `;
48703
- const packagesItemSpecPreMessage = () => `Judging whether the code already satisfies each requirement in the
48704
- package spec, before spending a full review turn. Run \`gtd judge
48705
- answer\` and pipe a verdict per section — or land untouched to run
48706
- the full review (the conservative default; a skipped judgment
48707
- never suppresses anything).
48708
- `;
48709
- /** The sections a pre-judge could not clear, when it cleared the rest. */
48710
- const specScope = (failing) => failing.length > 0 ? `- A pre-judge already found the other sections satisfied. Confine
48711
- your review to only these sections:
48712
- ${failing.map((title) => ` - ${title}\n`).join("")}` : "";
48713
- const packagesItemSpecReviewPrompt = (pkg, failing = []) => `${styleBlock}
48714
-
48715
- You are reviewing a freshly-built work package against its own
48716
- spec.
48717
-
48718
- ${stateFileRules}
48719
- - The only state file this turn touches is
48720
- \`.gtd/SPEC_FEEDBACK.md\` — write it only when you find problems
48721
- - The package spec is \`${pkg}\`
48722
- ${specScope(failing)}- Verify the implementation against it: tasks done, criteria
48723
- met, code sound and consistent with the codebase. No diff is
48724
- given — read the range yourself, from \`${start()}\`
48725
- to the working tree, process-wide (it can span earlier
48726
- packages)
48727
- - You own that bar; nothing downstream re-weighs your findings
48728
- - Write nothing when the package fully satisfies its spec —
48729
- silence is your approval. Otherwise write
48730
- \`.gtd/SPEC_FEEDBACK.md\` listing what would violate the spec if
48731
- it shipped unaddressed, specific enough to act on, each as its
48732
- own \`## \` heading
48733
- - Never fix anything yourself and never delete the package
48734
- file — a later step owns that
48726
+ const buildContextPrompt = (built) => {
48727
+ const list = (paths) => paths.length === 0 ? " (none)" : paths.map((path) => ` - ${path}`).join("\n");
48728
+ return `- Read \`.gtd/ARCHITECTURE.md\` — the plan every package was built from
48729
+ - A red scenario that is NEW in this plan likely means missing wiring
48730
+ between packages; new scenarios:
48731
+ ${list(built.scenarios.added)}
48732
+ - A red CHANGED or existing scenario or test is a regression a package
48733
+ introduced; scenarios this plan changed:
48734
+ ${list(built.scenarios.changed)}
48735
+ - Each package's commits, to bisect a break:
48736
+ ${built.ranges.map((r) => ` git log --oneline ${r.from}..${r.to} # ${r.pkg}`).join("\n")}
48735
48737
  `;
48736
- const specReviewerSystem = () => `${specReviewerPersona}
48737
-
48738
- ${agentConduct}`;
48739
- const buildFixPrompt = () => `${stateFileRules}
48738
+ };
48739
+ const buildFixPrompt = (built) => `${stateFileRules}
48740
48740
  ${fixFeedbackPrompt}
48741
- - Leave everything uncommitted — do not commit
48741
+ ${built === void 0 ? "" : buildContextPrompt(built)}- Leave everything uncommitted — do not commit
48742
48742
  `;
48743
48743
  const finisherSystem = () => `${finisherPersona}
48744
48744
 
@@ -49071,6 +49071,20 @@ ${it.processCostByModel.map((m) => `- ${m.model}: ${m.cost}\n`).join("")}
49071
49071
  Print the closing message and stop — this writes nothing itself.
49072
49072
  `;
49073
49073
  };
49074
+ const scenarioWordingMessage = (at, drifted) => `The e2e scenario wording was frozen when package 0 landed, and the last
49075
+ turn changed it. Removed lines are marked \`-\`, added ones \`+\`:
49076
+
49077
+ ${drifted.map(({ path, lines }) => `${path}\n${lines.join("\n")}`).join("\n\n")}
49078
+
49079
+ To accept the change, land untouched.
49080
+
49081
+ To reject it, run
49082
+
49083
+ git checkout ${at} -- ${drifted.map(({ path }) => quote(path)).join(" ")}
49084
+
49085
+ and land. Restoring only some of the files is a partial accept: every file
49086
+ still differing from the frozen wording is accepted.
49087
+ `;
49074
49088
  //#endregion
49075
49089
  //#region src/workflows/steps.ts
49076
49090
  const FEEDBACK = ".gtd/FEEDBACK.md";
@@ -49079,7 +49093,6 @@ const REQUIREMENTS = ".gtd/REQUIREMENTS.md";
49079
49093
  const ARCHITECTURE = ".gtd/ARCHITECTURE.md";
49080
49094
  const REVIEW = ".gtd/REVIEW.md";
49081
49095
  const QUALITY = ".gtd/QUALITY.md";
49082
- const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md";
49083
49096
  const planner = () => env.plannerModel ?? "";
49084
49097
  const coder = () => env.coderModel ?? "";
49085
49098
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
@@ -49127,20 +49140,7 @@ const fixSuite = () => agentWithSkills("fix.suite.fixing", packagesItemFixSuiteP
49127
49140
  model: coder(),
49128
49141
  system: builderSystem()
49129
49142
  });
49130
- const fixSpec = (pkg) => agentWithSkills("fix.spec.fixing", packagesItemFixSpecPrompt(pkg), {
49131
- label: "Fixing review feedback",
49132
- file: SPEC_FEEDBACK,
49133
- model: coder(),
49134
- system: builderSystem()
49135
- });
49136
- /** Review `pkg` against its spec, focused on the `failing` sections the pre-judge could not clear. */
49137
- const reviewPackage = (pkg, failing = []) => agentWithSkills("spec.review", packagesItemSpecReviewPrompt(pkg, failing), {
49138
- label: "Reviewing the package",
49139
- model: planner(),
49140
- system: specReviewerSystem(),
49141
- allowEmpty: true
49142
- });
49143
- const fix = () => agentWithSkills("fix", buildFixPrompt(), {
49143
+ const fixCheck = (built) => agentWithSkills("fix", buildFixPrompt(built), {
49144
49144
  label: "Fixing the check",
49145
49145
  file: FEEDBACK,
49146
49146
  model: coder(),
@@ -49248,11 +49248,29 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
49248
49248
  system: reviewerSystem(),
49249
49249
  allowEmpty: true
49250
49250
  });
49251
+ //#endregion
49252
+ //#region src/workflows/health.ts
49253
+ /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
49254
+ const SETUP = ".gtd/SETUP.md";
49255
+ const SETUP_TEXT = "The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it under `env:` in `.gtdrc` or as GTD_FASTTESTCOMMAND.";
49256
+ /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
49257
+ const fastSuiteGuard = () => (env.fastTestCommand ?? "").trim() === "" ? [
49258
+ `mkdir -p ${quote(dirname(SETUP))}`,
49259
+ `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
49260
+ `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
49261
+ "exit 0"
49262
+ ] : [`rm -f ${quote(SETUP)}`];
49251
49263
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
49252
- const baseline = (name, label = "Checking the baseline") => check(name, env.testCommand ?? "", {
49253
- report: FEEDBACK,
49254
- label
49255
- });
49264
+ const baseline = async (name, label = "Checking the baseline") => {
49265
+ for (;;) {
49266
+ const green = await check(name, env.testCommand ?? "", {
49267
+ report: FEEDBACK,
49268
+ label,
49269
+ preamble: fastSuiteGuard()
49270
+ });
49271
+ if (read(".gtd/SETUP.md") === void 0) return green;
49272
+ }
49273
+ };
49256
49274
  /**
49257
49275
  * Hand a red suite that repeated attempts could not fix to a person: an
49258
49276
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
@@ -49291,6 +49309,15 @@ const sameFailure = async (previous, current) => {
49291
49309
  });
49292
49310
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
49293
49311
  };
49312
+ const runSuite = (options) => {
49313
+ const fast = options.suite === "fast";
49314
+ return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
49315
+ report: FEEDBACK,
49316
+ label: "Running checks",
49317
+ sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []],
49318
+ ...fast ? { preamble: fastSuiteGuard() } : {}
49319
+ });
49320
+ };
49294
49321
  /**
49295
49322
  * Loop until the suite is green: check, and on red run `fix` — escalating
49296
49323
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
@@ -49301,11 +49328,9 @@ const healthy = async (fix, options = {}) => {
49301
49328
  let fixes = options.fixesSoFar ?? 0;
49302
49329
  let previous;
49303
49330
  for (;;) {
49304
- if (await check("health.check", env.testCommand ?? "", {
49305
- report: ".gtd/FEEDBACK.md",
49306
- label: "Running checks",
49307
- sweepOnGreen: [".gtd/ESCALATION.md"]
49308
- })) {
49331
+ const green = await runSuite(options);
49332
+ if (options.suite === "fast" && read(".gtd/SETUP.md") !== void 0) continue;
49333
+ if (green) {
49309
49334
  escalations.rounds = 0;
49310
49335
  return;
49311
49336
  }
@@ -49329,324 +49354,184 @@ const gate = (name, message) => scope(name, async () => {
49329
49354
  });
49330
49355
  });
49331
49356
  //#endregion
49332
- //#region src/workflows/diff.ts
49333
- const CONTEXT = 3;
49334
- const TOO_LARGE_LINES = 1500;
49335
- const LOCKFILES = /* @__PURE__ */ new Set([
49336
- "package-lock.json",
49337
- "npm-shrinkwrap.json",
49338
- "yarn.lock",
49339
- "pnpm-lock.yaml",
49340
- "bun.lock",
49341
- "bun.lockb",
49342
- "Cargo.lock",
49343
- "composer.lock",
49344
- "Gemfile.lock",
49345
- "poetry.lock",
49346
- "uv.lock",
49347
- "Pipfile.lock",
49348
- "go.sum",
49349
- "flake.lock",
49350
- "mix.lock",
49351
- "pubspec.lock",
49352
- "Podfile.lock",
49353
- "gradle.lockfile"
49354
- ]);
49355
- const GENERATED_GLOBS = [
49356
- "dist/**",
49357
- "build/**",
49358
- "out/**",
49359
- "coverage/**",
49360
- "node_modules/**",
49361
- "vendor/**",
49362
- ".turbo/**",
49363
- "**/__snapshots__/**",
49364
- "**/*.snap",
49365
- "**/*.min.js",
49366
- "**/*.min.css",
49367
- "**/*.map"
49368
- ];
49369
- const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
49370
- "png",
49371
- "jpg",
49372
- "jpeg",
49373
- "gif",
49374
- "webp",
49375
- "ico",
49376
- "pdf",
49377
- "zip",
49378
- "gz",
49379
- "tar",
49380
- "woff",
49381
- "woff2",
49382
- "ttf",
49383
- "otf",
49384
- "mp4",
49385
- "wasm",
49386
- "bin",
49387
- "exe",
49388
- "so",
49389
- "dylib"
49390
- ]);
49391
- const globCache = /* @__PURE__ */ new Map();
49392
- const compileGlob = (glob) => {
49393
- let pattern = "^";
49394
- let i = 0;
49395
- while (i < glob.length) {
49396
- const char = glob[i];
49397
- if (char !== "*") {
49398
- pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
49399
- i += 1;
49400
- } else if (glob[i + 1] !== "*") {
49401
- pattern += "[^/]*";
49402
- i += 1;
49403
- } else if (glob[i + 2] === "/") {
49404
- pattern += "(?:.*/)?";
49405
- i += 3;
49357
+ //#region src/workflows/scenarios.ts
49358
+ const DOC_STRING = /^(\s*)("""|```)/;
49359
+ /**
49360
+ * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
49361
+ * dropped — except inside a doc string, whose content is test input: kept
49362
+ * whole, indented relative to its opening delimiter (as Gherkin reads it).
49363
+ */
49364
+ const scenarioText = (source) => {
49365
+ const out = [];
49366
+ let doc;
49367
+ for (const line of source.split("\n")) {
49368
+ const delimiter = DOC_STRING.exec(line);
49369
+ if (doc === void 0 && delimiter !== null) {
49370
+ doc = {
49371
+ indent: delimiter[1].length,
49372
+ fence: delimiter[2]
49373
+ };
49374
+ out.push(line.trim());
49375
+ } else if (doc !== void 0 && line.trim().startsWith(doc.fence)) {
49376
+ doc = void 0;
49377
+ out.push(line.trim());
49378
+ } else if (doc !== void 0) {
49379
+ const lead = line.length - line.trimStart().length;
49380
+ out.push(line.slice(Math.min(lead, doc.indent)).trimEnd());
49406
49381
  } else {
49407
- pattern += ".*";
49408
- i += 2;
49382
+ const trimmed = line.trim();
49383
+ if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed);
49409
49384
  }
49410
49385
  }
49411
- return new RegExp(`${pattern}$`);
49386
+ return out.join("\n");
49412
49387
  };
49413
- const matchesGlob = (path, glob) => {
49414
- let regex = globCache.get(glob);
49415
- if (regex === void 0) {
49416
- regex = compileGlob(glob);
49417
- globCache.set(glob, regex);
49418
- }
49419
- return regex.test(path);
49388
+ const textOf = (path) => {
49389
+ const source = read(path);
49390
+ return source === void 0 ? void 0 : scenarioText(source);
49420
49391
  };
49421
- const extensionOf = (path) => {
49422
- const base = path.split("/").pop() ?? path;
49423
- const dot = base.lastIndexOf(".");
49424
- return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
49425
- };
49426
- const isBinary = (change) => BINARY_EXTENSIONS.has(extensionOf(change.path)) || (change.before?.includes("\0") ?? false) || (change.after?.includes("\0") ?? false);
49427
- const isExcluded = (change) => change.path === ".gtd" || change.path.startsWith(".gtd/") || LOCKFILES.has(change.path.split("/").pop() ?? change.path) || GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) || isBinary(change);
49428
- /** `changes` with lockfiles, generated trees and binaries dropped. */
49429
- const filterChanges = (changes) => changes.filter((change) => !isExcluded(change));
49430
- const linesOf = (content) => {
49431
- if (content === void 0 || content === "") return [];
49432
- const lines = content.split("\n");
49433
- if (lines[lines.length - 1] === "") lines.pop();
49434
- return lines;
49392
+ /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
49393
+ const freezeScenarios = (paths, previous) => {
49394
+ if (paths.length === 0) return previous;
49395
+ const texts = { ...previous?.texts };
49396
+ for (const path of paths) {
49397
+ const text = textOf(path);
49398
+ if (text !== void 0) texts[path] = text;
49399
+ }
49400
+ return Object.keys(texts).length === 0 ? previous : {
49401
+ at: head(),
49402
+ texts
49403
+ };
49435
49404
  };
49436
- /** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
49405
+ /** Paths whose wording differs from the snapshot (a deleted file counts). */
49406
+ const driftedScenarios = (frozen) => Object.entries(frozen.texts).filter(([path, text]) => textOf(path) !== text).map(([path]) => path);
49407
+ const lines = (text) => text === void 0 || text === "" ? [] : text.split("\n");
49408
+ /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
49437
49409
  const lcsTable = (a, b) => {
49438
- const n = a.length;
49439
- const m = b.length;
49440
- const dp = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
49441
- for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
49442
- return dp;
49443
- };
49444
- /** Walk an LCS table into an edit script, favoring a deletion on a tie. */
49445
- const backtrack = (a, b, dp) => {
49446
- const ops = [];
49410
+ const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
49411
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
49412
+ return table;
49413
+ };
49414
+ /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
49415
+ const lineDiff = (a, b) => {
49416
+ const table = lcsTable(a, b);
49417
+ const out = [];
49447
49418
  let i = 0;
49448
49419
  let j = 0;
49449
49420
  while (i < a.length && j < b.length) if (a[i] === b[j]) {
49450
- ops.push({
49451
- type: "eq",
49452
- line: a[i]
49453
- });
49454
- i++;
49455
- j++;
49456
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
49457
- ops.push({
49458
- type: "del",
49459
- line: a[i]
49460
- });
49461
49421
  i++;
49462
- } else {
49463
- ops.push({
49464
- type: "add",
49465
- line: b[j]
49466
- });
49467
49422
  j++;
49468
- }
49469
- while (i < a.length) ops.push({
49470
- type: "del",
49471
- line: a[i++]
49472
- });
49473
- while (j < b.length) ops.push({
49474
- type: "add",
49475
- line: b[j++]
49476
- });
49477
- return ops;
49478
- };
49479
- /** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
49480
- const lcsOps = (a, b) => backtrack(a, b, lcsTable(a, b));
49481
- const commonAffixes = (before, after) => {
49482
- const maxPrefix = Math.min(before.length, after.length);
49483
- let prefix = 0;
49484
- while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++;
49485
- const maxSuffix = maxPrefix - prefix;
49486
- let suffix = 0;
49487
- while (suffix < maxSuffix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
49488
- return {
49489
- prefix,
49490
- suffix
49491
- };
49423
+ } else if (table[i + 1][j] >= table[i][j + 1]) out.push(`- ${a[i++]}`);
49424
+ else out.push(`+ ${b[j++]}`);
49425
+ return [
49426
+ ...out,
49427
+ ...a.slice(i).map((l) => `- ${l}`),
49428
+ ...b.slice(j).map((l) => `+ ${l}`)
49429
+ ];
49492
49430
  };
49493
- const buildHunks = (ops) => {
49494
- const beforeLineAt = [];
49495
- const afterLineAt = [];
49496
- let bl = 1;
49497
- let al = 1;
49498
- for (const op of ops) {
49499
- beforeLineAt.push(bl);
49500
- afterLineAt.push(al);
49501
- if (op.type !== "add") bl++;
49502
- if (op.type !== "del") al++;
49503
- }
49504
- const ranges = [];
49505
- ops.forEach((op, idx) => {
49506
- if (op.type === "eq") return;
49507
- const start = Math.max(0, idx - CONTEXT);
49508
- const end = Math.min(ops.length - 1, idx + CONTEXT);
49509
- const last = ranges[ranges.length - 1];
49510
- if (last !== void 0 && start <= last[1] + 1) last[1] = Math.max(last[1], end);
49511
- else ranges.push([start, end]);
49512
- });
49513
- return ranges.map(([start, end]) => {
49514
- const slice = ops.slice(start, end + 1);
49515
- return {
49516
- beforeStart: beforeLineAt[start],
49517
- beforeCount: slice.filter((op) => op.type !== "add").length,
49518
- afterStart: afterLineAt[start],
49519
- afterCount: slice.filter((op) => op.type !== "del").length,
49520
- lines: slice.map((op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`)
49521
- };
49431
+ const wordingDrift = (frozen, path) => ({
49432
+ path,
49433
+ lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path)))
49434
+ });
49435
+ /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
49436
+ const holdWording = async (frozen) => {
49437
+ if (frozen === void 0) return;
49438
+ const drifted = driftedScenarios(frozen);
49439
+ if (drifted.length === 0) return;
49440
+ await human("scenario-wording", {
49441
+ message: scenarioWordingMessage(frozen.at, drifted.map((path) => wordingDrift(frozen, path))),
49442
+ label: "Scenario wording changed",
49443
+ acceptClean: true,
49444
+ base: frozen.at
49522
49445
  });
49446
+ const accepted = driftedScenarios(frozen);
49447
+ if (accepted.length === 0) return;
49448
+ for (const path of accepted) {
49449
+ const text = textOf(path);
49450
+ if (text === void 0) delete frozen.texts[path];
49451
+ else frozen.texts[path] = text;
49452
+ }
49453
+ frozen.at = head();
49523
49454
  };
49524
- const renderHunk = (hunk) => [`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`, ...hunk.lines];
49525
- /** One file's unified diff, or a summary line when its changed region is too large to inline. */
49526
- const renderFileDiff = (change) => {
49527
- const before = linesOf(change.before);
49528
- const after = linesOf(change.after);
49529
- const { prefix, suffix } = commonAffixes(before, after);
49530
- const beforeMiddle = before.slice(prefix, before.length - suffix);
49531
- const afterMiddle = after.slice(prefix, after.length - suffix);
49532
- if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`;
49533
- const prefixOps = before.slice(0, prefix).map((line) => ({
49534
- type: "eq",
49535
- line
49536
- }));
49537
- const suffixOps = before.slice(before.length - suffix).map((line) => ({
49538
- type: "eq",
49539
- line
49540
- }));
49541
- const ops = [
49542
- ...prefixOps,
49543
- ...lcsOps(beforeMiddle, afterMiddle),
49544
- ...suffixOps
49545
- ];
49546
- const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`;
49547
- const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`;
49548
- return [
49549
- `--- ${beforePath}`,
49550
- `+++ ${afterPath}`,
49551
- ...buildHunks(ops).flatMap(renderHunk)
49552
- ].join("\n");
49553
- };
49554
- const omissionTrailer = (paths) => `${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`;
49555
- /** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
49556
- const sizeWithTrailer = (files, keepCount) => {
49557
- let used = 0;
49558
- for (let i = 0; i < keepCount; i++) used += Buffer.byteLength(files[i].text, "utf8") + (i > 0 ? 1 : 0);
49559
- if (files.length - keepCount === 0) return used;
49560
- const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path));
49561
- return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8");
49562
- };
49563
- /**
49564
- * The filtered `changes`, rendered as one unified diff (files in path order)
49565
- * and bounded to `capBytes`: over the cap, whole files are dropped from the
49566
- * end — never bytes off a hunk — and named in a trailer line, so the
49567
- * judge is told what it did not see rather than handed a silently short diff.
49568
- * The trailer itself is sized against the cap along with the files it keeps,
49569
- * so a package with many touched files can never push the total past it.
49570
- */
49571
- const packageDiff = (changes, capBytes) => {
49572
- const files = filterChanges(changes).slice().sort((a, b) => a.path.localeCompare(b.path)).map((change) => ({
49573
- path: change.path,
49574
- text: renderFileDiff(change)
49575
- }));
49576
- let keepCount = files.length;
49577
- while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--;
49578
- const dropped = files.slice(keepCount).map((f) => f.path);
49579
- return [...files.slice(0, keepCount).map((f) => f.text), ...dropped.length > 0 ? [omissionTrailer(dropped)] : []].join("\n");
49455
+ /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
49456
+ const guarded = (frozen, turn, within) => async () => {
49457
+ await turn();
49458
+ if (within === void 0) await holdWording(frozen);
49459
+ else await scope(within, () => holdWording(frozen));
49580
49460
  };
49581
49461
  //#endregion
49582
49462
  //#region src/workflows/packages.ts
49583
- const MAX_SECTIONS = 8;
49584
- const DIFF_KEY = "diff";
49585
- const sectionQuestion = (id, title) => ({
49586
- id,
49587
- primitive: "noul",
49588
- instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
49589
- criteria: "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing."
49590
- });
49591
- /**
49592
- * Review one freshly built package against its spec: a pre-judge per
49593
- * section, then an agent review of the sections it did not confidently
49594
- * clear. `since` is the commit the package's own build started from — the
49595
- * pre-judge's evidence is a diff over exactly that range, never an earlier
49596
- * package's commits. Resolves `true` when approved.
49597
- */
49598
- const specReview = async (pkg, since) => {
49599
- const found = sectionBodies(read(pkg) ?? "");
49600
- const titles = found.map((section) => section.title);
49601
- const judged = titles.length > 0 && titles.length <= MAX_SECTIONS;
49602
- const evidence = {};
49603
- const questions = [];
49604
- if (judged) {
49605
- found.forEach(({ title, body }, i) => {
49606
- evidence[`section-${i + 1}`] = body;
49607
- questions.push(sectionQuestion(`section-${i + 1}`, title));
49463
+ /** Package 0: the e2e scenarios, written red. */
49464
+ const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md";
49465
+ const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
49466
+ /** The `## Tests` entries of a package file; malformed lines are ignored. */
49467
+ const declaredTests = (packageText) => {
49468
+ const found = [];
49469
+ let inTests = false;
49470
+ for (const line of packageText.split("\n")) if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line);
49471
+ else if (inTests) {
49472
+ const match = TEST_LINE.exec(line);
49473
+ if (match) found.push({
49474
+ level: match[1],
49475
+ path: match[2]
49608
49476
  });
49609
- const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1));
49610
- evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes);
49611
49477
  }
49612
- const { answers, truncated } = await judge("spec.pre", {
49613
- questions,
49614
- evidence,
49615
- message: packagesItemSpecPreMessage(),
49616
- label: "Judging spec coverage"
49617
- });
49618
- const clearMinP = numeric(vars.specPreJudge, Infinity);
49619
- const failing = titles.filter((_, i) => {
49620
- const id = `section-${i + 1}`;
49621
- return !judged || truncated.includes(id) || truncated.includes(DIFF_KEY) || !answered(answers[id], "yes", clearMinP);
49622
- });
49623
- if (titles.length > 0 && failing.length === 0) return true;
49624
- await reviewPackage(pkg, failing);
49625
- if (wrote(".gtd/SPEC_FEEDBACK.md")) return false;
49626
- if (changes(".gtd/SPEC_FEEDBACK.md").length > 0 || changes().length === 0) return true;
49627
- return refuse("gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve");
49628
- };
49629
- /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
49630
- const packageItem = async (pkg) => {
49631
- const since = head();
49632
- await build(pkg);
49633
- for (;;) {
49634
- await healthy(fixSuite);
49635
- if (await specReview(pkg, since)) break;
49636
- await fixSpec(pkg);
49637
- }
49638
- await run$3("closing", removeScript([
49478
+ return found;
49479
+ };
49480
+ /**
49481
+ * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
49482
+ * diff since `since`. The package text is read as of `since`, so a builder
49483
+ * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
49484
+ * written, existing in the tree suffices.
49485
+ */
49486
+ const requireDeclaredTests = (pkg, since) => {
49487
+ const range = changesSince(since);
49488
+ const text = range.get(pkg)?.before ?? read(pkg) ?? "";
49489
+ const satisfied = read(".gtd/SATISFIED.md") !== void 0;
49490
+ const missing = declaredTests(text).map((test) => test.path).filter((path) => satisfied ? read(path) === void 0 : range.get(path) === void 0 || range.get(path)?.status === "deleted" || read(path) === void 0);
49491
+ if (missing.length === 0) return;
49492
+ refuse(`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`);
49493
+ };
49494
+ /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
49495
+ const packageItem = async (pkg, frozen) => {
49496
+ const from = head();
49497
+ const declared = (turn) => async () => {
49498
+ await turn();
49499
+ requireDeclaredTests(pkg, from);
49500
+ };
49501
+ await guarded(frozen, declared(() => build(pkg)))();
49502
+ await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" });
49503
+ await run$3("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), { label: "Closing out the package" });
49504
+ return {
49639
49505
  pkg,
49640
- SPEC_FEEDBACK,
49641
- ".gtd/SATISFIED.md",
49642
- ARCHITECTURE
49643
- ]), { label: "Closing out the package" });
49506
+ from,
49507
+ to: head()
49508
+ };
49644
49509
  };
49645
49510
  /** The first queued package — the one a package step works on. */
49646
49511
  const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
49512
+ const outsideGtd = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
49647
49513
  /** Build every package file under `.gtd/packages/`, in name order. */
49648
- const packages = () => scope("packages", async () => {
49649
- for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
49514
+ const packages = (carried) => scope("packages", async () => {
49515
+ let frozen = carried;
49516
+ const ranges = [];
49517
+ let touched = [];
49518
+ for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) {
49519
+ const range = await scope("item", () => packageItem(pkg, pkg === ".gtd/packages/00-e2e-scenarios.md" ? void 0 : frozen));
49520
+ ranges.push(range);
49521
+ if (pkg === ".gtd/packages/00-e2e-scenarios.md") {
49522
+ touched = changesSince(range.from).filter((c) => outsideGtd(c.path) && c.status !== "deleted");
49523
+ frozen = freezeScenarios(touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path), frozen);
49524
+ }
49525
+ }
49526
+ const paths = (status) => touched.filter((c) => c.status === status).map((c) => c.path);
49527
+ return {
49528
+ ranges,
49529
+ scenarios: {
49530
+ added: paths("added"),
49531
+ changed: paths("modified")
49532
+ },
49533
+ frozen
49534
+ };
49650
49535
  });
49651
49536
  //#endregion
49652
49537
  //#region src/workflows/planning.ts
@@ -49679,30 +49564,8 @@ const architecture = () => scope("architecture", async () => {
49679
49564
  } while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
49680
49565
  await decompose();
49681
49566
  if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
49567
+ if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green");
49682
49568
  });
49683
- /** A package file name from a plan's first heading. */
49684
- const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "package";
49685
- /** Whether the settled plan needs its own architecture pass, or goes straight to one package. */
49686
- const architecturePass = async () => {
49687
- const { answers, truncated } = await judge("architecture-pre", {
49688
- questions: [{
49689
- id: "architectureWarranted",
49690
- primitive: "noul",
49691
- instructions: "Given the settled concerns in `.gtd/REQUIREMENTS.md` (in state), does this plan warrant a dedicated architecture pass — real structural decisions, multiple integration points, or a non-obvious tradeoff — before packages are written?",
49692
- criteria: "Answer yes if uncertain; a trivial, single-concern, mechanical plan with no real design decision answers no."
49693
- }],
49694
- evidence: { requirements: read(".gtd/REQUIREMENTS.md") ?? "" },
49695
- message: architecturePreMessage(),
49696
- label: "Judging whether this plan warrants an architecture pass"
49697
- });
49698
- const skip = truncated.length === 0 && answered(answers.architectureWarranted, "no", numeric(vars.architectureSkipMinP, Infinity));
49699
- const plan = read(REQUIREMENTS);
49700
- if (skip && plan !== void 0) {
49701
- await run$3("architecture-promote", moveScript(REQUIREMENTS, `.gtd/packages/01-${slug(sections(plan)[0] ?? "")}.md`), { label: "Promoting the plan straight to a package" });
49702
- return;
49703
- }
49704
- await architecture();
49705
- };
49706
49569
  //#endregion
49707
49570
  //#region src/workflows/review.ts
49708
49571
  /** Split a `qualityReviews` value into its lenses: on `,`, trimmed, blanks dropped. */
@@ -49717,10 +49580,10 @@ const qualityLap = async () => {
49717
49580
  return (read(".gtd/QUALITY.md") ?? "").length > 0 ? "findings" : "clean";
49718
49581
  };
49719
49582
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
49720
- const fixQualityFindings = async (escalations) => {
49583
+ const fixQualityFindings = async (escalations, frozen) => {
49721
49584
  for (let turns = 0; turns < 3; turns++) {
49722
- await fixQuality();
49723
- if (changes(".gtd/QUALITY.md").some((c) => c.status === "deleted")) return true;
49585
+ await guarded(frozen, fixQuality)();
49586
+ if (read(".gtd/QUALITY.md") === void 0) return true;
49724
49587
  }
49725
49588
  await escalation(escalations);
49726
49589
  return false;
@@ -49822,8 +49685,8 @@ const routeNotes = async (notes, r) => {
49822
49685
  answeredAt = head();
49823
49686
  }
49824
49687
  if (nits.length > 0) {
49825
- await fixNits(nits);
49826
- await healthy(fix, { escalations: r.escalations });
49688
+ await guarded(r.frozen, () => fixNits(nits), "review")();
49689
+ await healthy(guarded(r.frozen, fixCheck, "review"), { escalations: r.escalations });
49827
49690
  }
49828
49691
  if (edits.length > 0) {
49829
49692
  await r.close();
@@ -49841,7 +49704,7 @@ const routeNotes = async (notes, r) => {
49841
49704
  reviewed: answeredAt
49842
49705
  };
49843
49706
  };
49844
- const finish = async (reviewed, collectedAt, escalations) => {
49707
+ const finish = async (reviewed, collectedAt, escalations, frozen) => {
49845
49708
  const round = head();
49846
49709
  const since = changesSince(reviewed);
49847
49710
  const edited = since.filter(isCodeEdit);
@@ -49874,18 +49737,19 @@ const finish = async (reviewed, collectedAt, escalations) => {
49874
49737
  return routeNotes(notes, {
49875
49738
  round,
49876
49739
  escalations,
49740
+ frozen,
49877
49741
  close,
49878
49742
  outcome,
49879
49743
  unfolded
49880
49744
  });
49881
49745
  };
49882
49746
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
49883
- const reviewOnce = async (base, carry, escalations) => {
49747
+ const reviewOnce = async (base, carry, escalations, frozen) => {
49884
49748
  await reviewing(base, carry);
49885
49749
  const risks = reviewRisks(read(".gtd/REVIEW.md") ?? "");
49886
49750
  if (risks.length === 0) return;
49887
- await fixRisks(risks);
49888
- await healthy(fix, { escalations });
49751
+ await guarded(frozen, () => fixRisks(risks), "review")();
49752
+ await healthy(guarded(frozen, fixCheck, "review"), { escalations });
49889
49753
  await reviewing(base, carry);
49890
49754
  };
49891
49755
  /**
@@ -49894,17 +49758,17 @@ const reviewOnce = async (base, carry, escalations) => {
49894
49758
  * another planning lap, questions are answered in place, nits fixed in one
49895
49759
  * batch, praise dropped.
49896
49760
  */
49897
- const review = async (base, escalations = { rounds: 0 }) => {
49761
+ const review$1 = async (base, escalations = { rounds: 0 }, frozen) => {
49898
49762
  let carry;
49899
49763
  for (;;) {
49900
- await reviewOnce(base, carry, escalations);
49764
+ await reviewOnce(base, carry, escalations, frozen);
49901
49765
  carry = void 0;
49902
49766
  let reviewed = head();
49903
49767
  let collectedAt;
49904
49768
  for (;;) {
49905
49769
  collectedAt = await converse(base, collectedAt);
49906
49770
  if (collectedAt === "missing") break;
49907
- const result = await finish(reviewed, collectedAt, escalations);
49771
+ const result = await finish(reviewed, collectedAt, escalations, frozen);
49908
49772
  if (!("next" in result)) return result;
49909
49773
  if (result.next === "rereview") {
49910
49774
  carry = result.carry;
@@ -49915,14 +49779,20 @@ const review = async (base, escalations = { rounds: 0 }) => {
49915
49779
  }
49916
49780
  };
49917
49781
  /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
49918
- const buildTail = (fixFirst, base) => scope("build", async () => {
49782
+ const buildTail = (fixFirst, base, built) => scope("build", async () => {
49783
+ const frozen = built?.frozen;
49919
49784
  const escalations = { rounds: 0 };
49785
+ const guardedFix = guarded(frozen, () => fixCheck());
49920
49786
  let redFirst = fixFirst;
49787
+ if (!fixFirst) await healthy(guarded(frozen, () => fixCheck(built)), {
49788
+ escalations,
49789
+ sweepOnGreen: [ARCHITECTURE]
49790
+ });
49921
49791
  let lapped = false;
49922
49792
  for (;;) {
49923
49793
  if (redFirst) {
49924
- await fix();
49925
- await healthy(fix, {
49794
+ await guardedFix();
49795
+ await healthy(guardedFix, {
49926
49796
  fixesSoFar: 1,
49927
49797
  escalations
49928
49798
  });
@@ -49930,8 +49800,8 @@ const buildTail = (fixFirst, base) => scope("build", async () => {
49930
49800
  }
49931
49801
  const lap = lapped ? "clean" : await qualityLap();
49932
49802
  lapped = true;
49933
- if (lap === "clean") return review(base, escalations);
49934
- if (await fixQualityFindings(escalations)) await healthy(fix, { escalations });
49803
+ if (lap === "clean") return review$1(base, escalations, frozen);
49804
+ if (await fixQualityFindings(escalations, frozen)) await healthy(guardedFix, { escalations });
49935
49805
  else redFirst = true;
49936
49806
  }
49937
49807
  });
@@ -49942,14 +49812,13 @@ const defaults = {
49942
49812
  reviewBase: "",
49943
49813
  judgeBudgetBytes: "32768",
49944
49814
  judgeIdenticalMinP: "0.7",
49945
- specPreJudge: "0.9",
49946
49815
  reviewNoteActionable: "0.7",
49947
- architectureSkipMinP: "0.85",
49948
49816
  qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
49949
49817
  };
49950
49818
  /** Environment settings. */
49951
49819
  const envDefaults = {
49952
49820
  testCommand: "npm test",
49821
+ fastTestCommand: "",
49953
49822
  plannerModel: "smart",
49954
49823
  coderModel: "base"
49955
49824
  };
@@ -49965,8 +49834,6 @@ const skills = (vars) => ({
49965
49834
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
49966
49835
  "packages.item": ["test-driven-development", "incremental-implementation"],
49967
49836
  "packages.item.fix.suite": ["debugging-and-error-recovery"],
49968
- "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
49969
- "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
49970
49837
  "packages.item.health": ["debugging-and-error-recovery"],
49971
49838
  build: ["debugging-and-error-recovery"],
49972
49839
  "build.health": ["debugging-and-error-recovery"],
@@ -49982,15 +49849,14 @@ const access = (vars) => ({
49982
49849
  design: { write: [] },
49983
49850
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
49984
49851
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
49985
- "packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
49986
49852
  "build.review": { write: [".gtd/REVIEW.md"] },
49987
49853
  "build.review.fix.nits": {},
49988
49854
  "build.review.fix.risks": {},
49989
49855
  ...Object.fromEntries(lensesOf(vars.qualityReviews ?? "").map((lens) => [`build.quality.${lens}`, { write: [] }]))
49990
49856
  });
49991
49857
  //#endregion
49992
- //#region src/workflows/unified.ts
49993
- var unified_exports = /* @__PURE__ */ __exportAll({
49858
+ //#region src/workflows/bundled.ts
49859
+ var bundled_exports = /* @__PURE__ */ __exportAll({
49994
49860
  ARCHITECTURE: () => ARCHITECTURE,
49995
49861
  ESCALATION: () => ESCALATION,
49996
49862
  FEEDBACK: () => FEEDBACK,
@@ -49998,7 +49864,8 @@ var unified_exports = /* @__PURE__ */ __exportAll({
49998
49864
  QUALITY: () => QUALITY,
49999
49865
  REQUIREMENTS: () => REQUIREMENTS,
50000
49866
  REVIEW: () => REVIEW,
50001
- SPEC_FEEDBACK: () => SPEC_FEEDBACK,
49867
+ SCENARIO_PACKAGE: () => SCENARIO_PACKAGE,
49868
+ SETUP: () => SETUP,
50002
49869
  access: () => access,
50003
49870
  afterTail: () => afterTail,
50004
49871
  agentWithSkills: () => agentWithSkills,
@@ -50006,7 +49873,6 @@ var unified_exports = /* @__PURE__ */ __exportAll({
50006
49873
  answerReviewQuestions: () => answerReviewQuestions,
50007
49874
  answerTechnicalQuestions: () => answerTechnicalQuestions,
50008
49875
  architecture: () => architecture,
50009
- architecturePass: () => architecturePass,
50010
49876
  author: () => author,
50011
49877
  awaitReview: () => awaitReview,
50012
49878
  base: () => base,
@@ -50015,24 +49881,32 @@ var unified_exports = /* @__PURE__ */ __exportAll({
50015
49881
  buildTail: () => buildTail,
50016
49882
  builtInLenses: () => builtInLenses,
50017
49883
  collecting: () => collecting,
49884
+ declaredTests: () => declaredTests,
50018
49885
  decompose: () => decompose,
50019
- default: () => unified,
49886
+ default: () => feature,
50020
49887
  defaults: () => defaults,
50021
49888
  describeEscalation: () => describeEscalation,
50022
49889
  design: () => design,
49890
+ doors: () => doors,
49891
+ driftedScenarios: () => driftedScenarios,
50023
49892
  envDefaults: () => envDefaults,
50024
49893
  escalate: () => escalate,
50025
49894
  escalation: () => escalation,
50026
49895
  escalationExhausted: () => escalationExhausted,
49896
+ fastSuiteGuard: () => fastSuiteGuard,
49897
+ feature: () => feature,
50027
49898
  fix: () => fix,
49899
+ fixCheck: () => fixCheck,
50028
49900
  fixNits: () => fixNits,
50029
49901
  fixQuality: () => fixQuality,
50030
49902
  fixQualityFindings: () => fixQualityFindings,
50031
49903
  fixRisks: () => fixRisks,
50032
- fixSpec: () => fixSpec,
50033
49904
  fixSuite: () => fixSuite,
49905
+ freezeScenarios: () => freezeScenarios,
50034
49906
  gate: () => gate,
49907
+ guarded: () => guarded,
50035
49908
  healthy: () => healthy,
49909
+ holdWording: () => holdWording,
50036
49910
  lensesOf: () => lensesOf,
50037
49911
  nextPackage: () => nextPackage,
50038
49912
  ordinaryStart: () => ordinaryStart,
@@ -50041,19 +49915,20 @@ var unified_exports = /* @__PURE__ */ __exportAll({
50041
49915
  planAndBuild: () => planAndBuild,
50042
49916
  qualityLap: () => qualityLap,
50043
49917
  reUnwind: () => reUnwind,
49918
+ requireDeclaredTests: () => requireDeclaredTests,
50044
49919
  retryQuestion: () => retryQuestion,
50045
49920
  review: () => review,
50046
49921
  reviewMissing: () => reviewMissing,
50047
- reviewPackage: () => reviewPackage,
50048
49922
  reviewQuality: () => reviewQuality,
50049
49923
  reviewing: () => reviewing,
49924
+ scenarioText: () => scenarioText,
50050
49925
  skills: () => skills,
50051
- specReview: () => specReview,
50052
49926
  steering: () => steering,
50053
49927
  summary: () => summary,
50054
49928
  threads: () => threads,
50055
49929
  triage: () => triage$1,
50056
- unwind: () => unwind
49930
+ unwind: () => unwind,
49931
+ wordingDrift: () => wordingDrift
50057
49932
  });
50058
49933
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
50059
49934
  const unwind = () => {
@@ -50079,11 +49954,13 @@ const reUnwind = async (feedback) => {
50079
49954
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
50080
49955
  const planAndBuild = async (firstBase) => {
50081
49956
  let base = firstBase;
49957
+ let carried;
50082
49958
  for (;;) {
50083
49959
  await design(base);
50084
- await architecturePass();
50085
- await packages();
50086
- const outcome = await buildTail(false, base);
49960
+ await architecture();
49961
+ const built = await packages(carried);
49962
+ carried = built.frozen;
49963
+ const outcome = await buildTail(false, base, built);
50087
49964
  if (outcome.verdict === "signoff") return;
50088
49965
  await reUnwind(outcome);
50089
49966
  base = outcome.base;
@@ -50111,34 +49988,35 @@ const ordinaryStart = async () => {
50111
49988
  await gate("start-gate", startGateBlockedMessage());
50112
49989
  await planAndBuild(start());
50113
49990
  };
50114
- const ENTRIES = [
50115
- "fix-precheck",
50116
- "review-gate.check",
50117
- "start-gate.check"
50118
- ];
50119
- async function unified({ entry }) {
50120
- if (entry === void 0) return ordinaryStart();
50121
- if (entry === "fix-precheck") {
50122
- if (await baseline("fix-precheck")) return;
50123
- return afterTail(await buildTail(true, start()));
50124
- }
50125
- if (entry === "review-gate.check") {
50126
- await gate("review-gate", reviewGateBlockedMessage());
50127
- return afterTail(await buildTail(false, start()));
50128
- }
50129
- if (entry === "start-gate.check") {
50130
- await gate("start-gate", startGateBlockedMessage());
50131
- return planAndBuild(start());
50132
- }
50133
- refuse(`"${entry}" is not an enterable state — enterable states:\n${ENTRIES.map((name) => ` ${name}`).join("\n")}`);
50134
- }
49991
+ /** Repair a red baseline through the build tail, as its own reviewed commit. */
49992
+ const fix = async () => {
49993
+ if (await baseline("fix-precheck")) return;
49994
+ return afterTail(await buildTail(true, start()));
49995
+ };
49996
+ /** Pure review of everything since `reviewBase`. */
49997
+ const review = async () => {
49998
+ await gate("review-gate", reviewGateBlockedMessage());
49999
+ return afterTail(await buildTail(false, start()));
50000
+ };
50001
+ const feature = ordinaryStart;
50135
50002
  const summary = summaryPrompt;
50136
50003
  const steering = {
50137
50004
  [REQUIREMENTS]: "qa",
50138
50005
  [ARCHITECTURE]: "qa",
50139
50006
  [REVIEW]: "review"
50140
50007
  };
50141
- const base = (entry, vars) => entry === "review-gate.check" ? vars.reviewBase ?? "" : void 0;
50008
+ const base = (workflow, vars) => workflow === "review" ? vars.reviewBase ?? "" : void 0;
50009
+ const doors = {
50010
+ fix: { workflow: "fix" },
50011
+ review: {
50012
+ workflow: "review",
50013
+ args: [{
50014
+ name: "base",
50015
+ optional: true
50016
+ }],
50017
+ vars: ({ base }) => base === void 0 ? {} : { reviewBase: base }
50018
+ }
50019
+ };
50142
50020
  //#endregion
50143
50021
  //#region src/platform/Host.ts
50144
50022
  const toError$1 = (e) => e instanceof Error ? e : new Error(String(e));
@@ -50331,6 +50209,8 @@ const makeGitImpl = (executor, root) => {
50331
50209
  resolveRef: (ref) => exec("git", "rev-parse", "--verify", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
50332
50210
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll(() => succeed$3(none$4()))),
50333
50211
  isAncestor: (a, b) => run$2(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$2(CommandExecutor, executor)), map$4(() => true), catchAll(() => succeed$3(false))),
50212
+ mergeBase: (a, b) => exec("git", "merge-base", a, b).pipe(map$4((s) => some(s.trim())), catchAll(() => succeed$3(none$4()))),
50213
+ defaultBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(map$4((s) => s.trim()), catchAll(() => succeed$3("")), map$4((name) => name === "" ? "main" : name)),
50334
50214
  topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
50335
50215
  gitDir: () => exec("git", "rev-parse", "--absolute-git-dir").pipe(map$4((s) => s.trim())),
50336
50216
  gitCommonDir: () => exec("git", "rev-parse", "--path-format=absolute", "--git-common-dir").pipe(map$4((s) => s.trim())),
@@ -58237,45 +58117,71 @@ const load = gen(function* () {
58237
58117
  const { levels, compiled, decodeDiagnostics } = yield* readRc;
58238
58118
  const module = yield* discovery.workflowModule(host.root, host.home);
58239
58119
  const loaded = yield* try_({
58240
- try: () => loadWorkflow(module),
58120
+ try: () => loadWorkflows(module),
58241
58121
  catch: (e) => new GtdError(`gtd config:\n - ${module?.filepath ?? "(built-in default)"}: ${e instanceof Error ? e.message : String(e)}`)
58242
58122
  });
58243
58123
  const layerOrder = [...levels.map((level) => level.filepath), ...module ? [module.filepath] : []];
58244
58124
  const diagnostics = dedupeDiagnostics(sortDiagnostics([
58245
58125
  ...decodeDiagnostics,
58246
58126
  ...compiled.diagnostics,
58247
- ...wrongKindDiagnostics(loaded, compiled)
58127
+ ...wrongKindDiagnostics(loaded.files, compiled)
58248
58128
  ], layerOrder));
58249
58129
  yield* failOnErrors(diagnostics);
58250
- const initial = yield* firstStep(loaded, headTree(yield* Workspace));
58130
+ const initial = yield* firstStep(loaded.default, headTree(yield* Workspace));
58131
+ const steering = Object.assign({}, ...loaded.files.map((f) => f.steering));
58132
+ const scopesOf = (file) => {
58133
+ const vars = resolveVars(file.defaults, compiled.rcVars, {}, host.env);
58134
+ try {
58135
+ return [...Object.keys(file.skills(vars)), ...Object.keys(file.access(vars))];
58136
+ } catch {
58137
+ return [];
58138
+ }
58139
+ };
58140
+ const define = (entry) => ({
58141
+ flow: entry.flow,
58142
+ summary: entry.file.summary,
58143
+ base: entry.file.base,
58144
+ steering,
58145
+ modes: compiled.modes,
58146
+ skills: entry.file.skills,
58147
+ configuredSkills: compiled.rcSkills,
58148
+ skillsKeys: compiled.skillsKeys,
58149
+ access: entry.file.access,
58150
+ configuredAccess: compiled.access,
58151
+ accessKeys: compiled.accessKeys,
58152
+ knownScopes: () => loaded.files.filter((f) => f !== entry.file).flatMap(scopesOf),
58153
+ skillsOrigin: entry.file.origin,
58154
+ initial
58155
+ });
58156
+ const names = [...loaded.catalogue.keys()].sort();
58157
+ const resolve = (name, entry) => ({
58158
+ name,
58159
+ def: define(entry),
58160
+ vars: { ...entry.file.defaults },
58161
+ env: { ...entry.file.envDefaults }
58162
+ });
58163
+ const defaultWorkflow = resolve(loaded.default.name, loaded.default);
58251
58164
  return {
58252
- workflow: {
58253
- flow: loaded.flow,
58254
- summary: loaded.summary,
58255
- base: loaded.base,
58256
- steering: loaded.steering,
58257
- modes: compiled.modes,
58258
- skills: loaded.skills,
58259
- configuredSkills: compiled.rcSkills,
58260
- skillsKeys: compiled.skillsKeys,
58261
- access: loaded.access,
58262
- configuredAccess: compiled.access,
58263
- accessKeys: compiled.accessKeys,
58264
- skillsOrigin: module?.filepath ?? "(built-in default)",
58265
- initial
58165
+ workflow: defaultWorkflow.def,
58166
+ defaultWorkflow,
58167
+ workflowNames: names,
58168
+ doors: loaded.doors,
58169
+ workflowNamed: (name) => {
58170
+ const entry = loaded.catalogue.get(name);
58171
+ return entry === void 0 ? void 0 : resolve(name, entry);
58266
58172
  },
58267
- workflowVars: { ...loaded.defaults },
58268
58173
  rcVars: compiled.rcVars,
58269
- workflowEnv: { ...loaded.envDefaults },
58270
58174
  rcEnv: compiled.rcEnv,
58271
58175
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
58272
58176
  warnings: diagnostics.filter((d) => d.severity === "warning"),
58273
58177
  configFiles: levels.map((level) => level.filepath),
58274
- workflowFiles: loaded.dependencyFiles
58178
+ workflowFiles: loaded.files.flatMap((f) => f.dependencyFiles)
58275
58179
  };
58276
58180
  });
58277
58181
  /** A name under the wrong key (or under both) in a `.gtdrc` layer: each is an error at its own origin. */
58278
- const wrongKindDiagnostics = (loaded, compiled) => {
58182
+ const wrongKindDiagnostics = (files, compiled) => {
58183
+ const isEnvOnly = (key) => files.some((f) => Object.hasOwn(f.envDefaults, key)) && !files.some((f) => Object.hasOwn(f.defaults, key));
58184
+ const isVarOnly = (key) => files.some((f) => Object.hasOwn(f.defaults, key)) && !files.some((f) => Object.hasOwn(f.envDefaults, key));
58279
58185
  const error = (path, message, origin) => ({
58280
58186
  severity: "error",
58281
58187
  path,
@@ -58283,9 +58189,9 @@ const wrongKindDiagnostics = (loaded, compiled) => {
58283
58189
  origin
58284
58190
  });
58285
58191
  const diagnostics = [];
58286
- for (const { key, origin } of compiled.varsKeys) if (Object.hasOwn(loaded.envDefaults, key)) diagnostics.push(error(["vars", key], `"vars.${key}" is an environment setting — move it under "env:"`, origin));
58192
+ for (const { key, origin } of compiled.varsKeys) if (isEnvOnly(key)) diagnostics.push(error(["vars", key], `"vars.${key}" is an environment setting — move it under "env:"`, origin));
58287
58193
  for (const { key, origin } of compiled.envKeys) {
58288
- if (Object.hasOwn(loaded.defaults, key)) diagnostics.push(error(["env", key], `"env.${key}" is a process setting — move it under "vars:"`, origin));
58194
+ if (isVarOnly(key)) diagnostics.push(error(["env", key], `"env.${key}" is a process setting — move it under "vars:"`, origin));
58289
58195
  if (compiled.varsKeys.some((v) => v.key === key)) diagnostics.push(error(["env", key], `"${key}" is declared under both "vars:" and "env:" — a setting is one kind or the other`, origin));
58290
58196
  }
58291
58197
  return diagnostics;
@@ -58313,7 +58219,7 @@ const jiti = () => {
58313
58219
  return jitiModule.createJiti(import.meta.url, {
58314
58220
  virtualModules: {
58315
58221
  "@pmelab/gtd/flows": flows_exports,
58316
- "@pmelab/gtd/workflow": unified_exports
58222
+ "@pmelab/gtd/workflow": bundled_exports
58317
58223
  },
58318
58224
  fsCache: false,
58319
58225
  moduleCache: false,
@@ -58350,14 +58256,19 @@ const optionalFunction = (exports, name) => {
58350
58256
  if (typeof value !== "function") throw new Error(`the "${name}" export is not a function`);
58351
58257
  return value;
58352
58258
  };
58353
- /**
58354
- * Read a workflow module: the default export is the flow; `defaults`,
58355
- * `envDefaults`, `summary`, `base` and `steering` are optional; any other export is ignored.
58356
- */
58357
- const fromModule = (exported, origin, dependencyFiles) => {
58358
- const exports = typeof exported === "object" && exported !== null ? exported : {};
58359
- const flow = exports.default;
58360
- if (typeof flow !== "function") throw new Error("the default export is not a flow — export default an async function");
58259
+ const RESERVED_EXPORTS = /* @__PURE__ */ new Set([
58260
+ "default",
58261
+ "summary",
58262
+ "base",
58263
+ "defaults",
58264
+ "envDefaults",
58265
+ "steering",
58266
+ "skills",
58267
+ "access",
58268
+ "doors"
58269
+ ]);
58270
+ /** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
58271
+ const fileFrom = (exports, origin, dependencyFiles) => {
58361
58272
  const defaults = stringRecord(exports, "defaults");
58362
58273
  const envDefaults = stringRecord(exports, "envDefaults");
58363
58274
  for (const [exportName, record] of [["defaults", defaults], ["envDefaults", envDefaults]]) {
@@ -58367,7 +58278,6 @@ const fromModule = (exported, origin, dependencyFiles) => {
58367
58278
  const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name));
58368
58279
  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`);
58369
58280
  return {
58370
- flow,
58371
58281
  defaults,
58372
58282
  envDefaults,
58373
58283
  steering: stringRecord(exports, "steering"),
@@ -58379,23 +58289,120 @@ const fromModule = (exported, origin, dependencyFiles) => {
58379
58289
  dependencyFiles
58380
58290
  };
58381
58291
  };
58292
+ const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/;
58293
+ /** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
58294
+ const argsProblem = (args) => {
58295
+ if (args === void 0) return void 0;
58296
+ if (!Array.isArray(args)) return `has "args" that is not an array`;
58297
+ const seen = /* @__PURE__ */ new Set();
58298
+ let optional = false;
58299
+ for (const arg of args) {
58300
+ if (typeof arg?.name !== "string") return `has an arg without a "name"`;
58301
+ if (seen.has(arg.name)) return `declares duplicate arg "${arg.name}"`;
58302
+ seen.add(arg.name);
58303
+ if (arg.optional === true) optional = true;
58304
+ else if (optional) return `declares required arg "${arg.name}" after an optional one`;
58305
+ }
58306
+ };
58307
+ /** Why one door is malformed, or `undefined` when it is well-formed. */
58308
+ const doorProblem = (name, door) => {
58309
+ if (!DOOR_NAME_RULE.test(name)) return `is not a valid door name — it must match ${DOOR_NAME_RULE}`;
58310
+ const { workflow, args, vars } = door ?? {};
58311
+ if (typeof workflow !== "string") return `must be an object with a "workflow" name`;
58312
+ const varsOk = vars === void 0 || typeof vars === "function";
58313
+ return argsProblem(args) ?? (varsOk ? void 0 : `has "vars" that is not a function`);
58314
+ };
58315
+ /** A module's `doors` export, shape-checked; the workflow names are checked later, against the merged catalogue. */
58316
+ const doorsFrom = (exports) => {
58317
+ const value = exports["doors"];
58318
+ if (value === void 0) return /* @__PURE__ */ new Map();
58319
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`the "doors" export is not a record of doors`);
58320
+ for (const [name, door] of Object.entries(value)) {
58321
+ const problem = doorProblem(name, door);
58322
+ if (problem !== void 0) throw new Error(`door "${name}" ${problem}`);
58323
+ }
58324
+ return new Map(Object.entries(value));
58325
+ };
58326
+ /** Merge bundled and repo doors (repo wins), then check each names a workflow the merged catalogue holds. */
58327
+ const mergeDoors = (bundled, repo, catalogue) => {
58328
+ const doors = new Map([...bundled, ...repo].sort(([a], [b]) => a.localeCompare(b)));
58329
+ for (const [name, door] of doors) if (!catalogue.has(door.workflow)) throw new Error(`door "${name}" names workflow "${door.workflow}", which is not startable — known: ${[...catalogue.keys()].sort().join(", ")}`);
58330
+ return doors;
58331
+ };
58332
+ const namespaceOf = (exported) => typeof exported === "object" && exported !== null ? exported : {};
58333
+ /**
58334
+ * The bundled module is never scanned: its helper exports stay helpers, and
58335
+ * exactly these three are startable.
58336
+ */
58337
+ const loadBundled = () => {
58338
+ const file = fileFrom(namespaceOf(bundled_exports), BUILT_IN_ORIGIN, []);
58339
+ const { feature, review, fix } = bundled_exports;
58340
+ return {
58341
+ file,
58342
+ doors: doorsFrom(namespaceOf(bundled_exports)),
58343
+ catalogue: /* @__PURE__ */ new Map([
58344
+ ["feature", {
58345
+ flow: feature,
58346
+ file
58347
+ }],
58348
+ ["review", {
58349
+ flow: review,
58350
+ file
58351
+ }],
58352
+ ["fix", {
58353
+ flow: fix,
58354
+ file
58355
+ }]
58356
+ ])
58357
+ };
58358
+ };
58382
58359
  /**
58383
- * Evaluate `gtd.config.ts`, or take the bundled default. `cache` is jiti's
58384
- * OWN per-call module cache (unrelated to the instance's `moduleCache:
58385
- * false` — that governs reuse ACROSS calls, this one is populated fresh
58386
- * DURING this one) — passing an empty object in has the side effect of
58387
- * collecting every real file jiti resolved evaluating this module, module
58388
- * AND every relative import it pulled in, transitively. Virtual modules
58389
- * (`@pmelab/gtd/flows`) never touch it — no real file backs them.
58360
+ * Evaluate `gtd.config.ts` and overlay it on the bundled workflows. Every
58361
+ * exported function of the repo file but the reserved names is a workflow
58362
+ * named by its export. `cache` is jiti's OWN per-call module cache (unrelated
58363
+ * to the instance's `moduleCache: false`) — passing an empty object in has
58364
+ * the side effect of collecting every real file jiti resolved evaluating this
58365
+ * module, module AND every relative import it pulled in, transitively.
58390
58366
  */
58391
- const loadWorkflow = (module) => {
58392
- if (module === void 0) return fromModule(unified_exports, BUILT_IN_ORIGIN, []);
58367
+ const loadWorkflows = (module) => {
58368
+ const bundled = loadBundled();
58369
+ const bundledDefault = {
58370
+ flow: feature,
58371
+ file: bundled.file,
58372
+ name: "feature"
58373
+ };
58374
+ if (module === void 0) return {
58375
+ files: [bundled.file],
58376
+ catalogue: bundled.catalogue,
58377
+ doors: mergeDoors(bundled.doors, /* @__PURE__ */ new Map(), bundled.catalogue),
58378
+ default: bundledDefault
58379
+ };
58393
58380
  const cache = {};
58394
- const exported = jiti().evalModule(module.source, {
58381
+ const exports = namespaceOf(jiti().evalModule(module.source, {
58395
58382
  filename: module.filepath,
58396
58383
  cache
58397
- });
58398
- return fromModule(exported, module.filepath, Object.keys(cache));
58384
+ }));
58385
+ const file = fileFrom(exports, module.filepath, Object.keys(cache));
58386
+ const catalogue = new Map(bundled.catalogue);
58387
+ for (const [name, value] of Object.entries(exports)) {
58388
+ if (RESERVED_EXPORTS.has(name) || typeof value !== "function") continue;
58389
+ catalogue.set(name, {
58390
+ flow: value,
58391
+ file
58392
+ });
58393
+ }
58394
+ const flow = exports.default;
58395
+ if (flow !== void 0 && typeof flow !== "function") throw new Error("the default export is not a function");
58396
+ return {
58397
+ files: [bundled.file, file],
58398
+ catalogue,
58399
+ doors: mergeDoors(bundled.doors, doorsFrom(exports), catalogue),
58400
+ default: flow === void 0 ? bundledDefault : {
58401
+ flow,
58402
+ file,
58403
+ name: "default"
58404
+ }
58405
+ };
58399
58406
  };
58400
58407
  const headTree = (workspace) => {
58401
58408
  let entries;
@@ -58451,7 +58458,6 @@ const settingReads = (kind, watch) => {
58451
58458
  const replayToFirstStep = (loaded, vars, env, tree) => flatMap$4(promise(() => replay({
58452
58459
  flow: loaded.flow,
58453
58460
  episode: {
58454
- entry: void 0,
58455
58461
  base: {
58456
58462
  hash: "",
58457
58463
  tree
@@ -58465,7 +58471,7 @@ const replayToFirstStep = (loaded, vars, env, tree) => flatMap$4(promise(() => r
58465
58471
  })), (outcome) => {
58466
58472
  if (outcome.kind === "rest") return succeed$3(outcome.rest.name);
58467
58473
  const problem = outcome.kind === "failed" || outcome.kind === "refused" ? outcome.message.replace(/^gtd: /, "") : "the default entry must begin at a step — that step is where a finished process waits";
58468
- return fail$7(new GtdError(`gtd config:\n - ${loaded.origin}: ${problem}`));
58474
+ return fail$7(new GtdError(`gtd config:\n - ${loaded.file.origin}: ${problem}`));
58469
58475
  });
58470
58476
  /**
58471
58477
  * The default entry's first step — where a finished process waits — found the
@@ -58481,13 +58487,13 @@ const firstStep = (loaded, head) => gen(function* () {
58481
58487
  const env = watchedEmptySettings();
58482
58488
  const outcome = yield* either(replayToFirstStep(loaded, vars.settings, env.settings, empty.tree));
58483
58489
  const reads = [...settingReads("process", vars), ...settingReads("environment", env)];
58484
- if (reads.length > 0) return yield* fail$7(new GtdError(`gtd config:\n - ${loaded.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`));
58490
+ if (reads.length > 0) return yield* fail$7(new GtdError(`gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`));
58485
58491
  if (outcome._tag === "Left") return yield* fail$7(outcome.left);
58486
58492
  const name = outcome.right;
58487
58493
  if (!empty.read()) return name;
58488
58494
  const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head);
58489
58495
  if (atHead === name) return name;
58490
- return yield* fail$7(new GtdError(`gtd config:\n - ${loaded.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`));
58496
+ return yield* fail$7(new GtdError(`gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`));
58491
58497
  });
58492
58498
  var ConfigService = class ConfigService extends Tag("ConfigService")() {
58493
58499
  static Live = succeed$2(ConfigService, { load });
@@ -58495,7 +58501,10 @@ var ConfigService = class ConfigService extends Tag("ConfigService")() {
58495
58501
  //#endregion
58496
58502
  //#region src/workflow/init.ts
58497
58503
  const SCHEMA_URL = "https://cdn.jsdelivr.net/npm/@pmelab/gtd/schema.json";
58498
- const INIT_ENV = { testCommand: "npm test" };
58504
+ const INIT_ENV = {
58505
+ testCommand: "npm test",
58506
+ fastTestCommand: ""
58507
+ };
58499
58508
  const MODES_SUGGESTION = {
58500
58509
  qa: { format: "npx prettier --write \"$GTD_FILE\"" },
58501
58510
  review: { format: "npx prettier --write \"$GTD_FILE\"" }
@@ -58523,6 +58532,7 @@ const checkKeys = (keys, known, message, section) => {
58523
58532
  origin
58524
58533
  })), [...new Set(keys.map((k) => k.origin))])));
58525
58534
  };
58535
+ const knownTo = (def, own) => [.../* @__PURE__ */ new Set([...own, ...def.knownScopes?.() ?? []])];
58526
58536
  /**
58527
58537
  * Skill lists for the settings actually in use. `.gtdrc` `skills:` keys are
58528
58538
  * judged here, not at config load: only now are the pinned/entry vars known.
@@ -58532,7 +58542,8 @@ const resolveScopeSkills = (def, vars) => gen(function* () {
58532
58542
  try: () => checkShape(def.skills(vars)),
58533
58543
  catch: (e) => fail$4(def.skillsOrigin, e instanceof Error ? e.message : String(e))
58534
58544
  });
58535
- yield* checkKeys(def.skillsKeys, Object.keys(skills), unknownSkillsKeyMessage, "skills");
58545
+ const known = knownTo(def, Object.keys(skills));
58546
+ yield* checkKeys(def.skillsKeys, known, unknownSkillsKeyMessage, "skills");
58536
58547
  return skills;
58537
58548
  });
58538
58549
  const checkAccessShape = (value) => {
@@ -58552,7 +58563,8 @@ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
58552
58563
  try: () => checkAccessShape(def.access(vars)),
58553
58564
  catch: (e) => fail$4(def.skillsOrigin, e instanceof Error ? e.message : String(e))
58554
58565
  });
58555
- yield* checkKeys(def.accessKeys, [.../* @__PURE__ */ new Set([...Object.keys(skills), ...Object.keys(access)])], unknownAccessKeyMessage, "access");
58566
+ const known = knownTo(def, [...Object.keys(skills), ...Object.keys(access)]);
58567
+ yield* checkKeys(def.accessKeys, known, unknownAccessKeyMessage, "access");
58556
58568
  return access;
58557
58569
  });
58558
58570
  //#endregion
@@ -63941,14 +63953,16 @@ const demandOf = (input) => {
63941
63953
  * instead of failing loudly.
63942
63954
  */
63943
63955
  const statusOf = (input) => {
63944
- const { rendered, idle, log, changes, next, cost, costByModel } = input;
63956
+ const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input;
63945
63957
  return {
63946
63958
  idle,
63959
+ initial,
63947
63960
  model: rendered.model,
63948
63961
  system: rendered.system !== void 0 && rendered.system !== "" ? rendered.system : void 0,
63949
63962
  log,
63950
63963
  state: rendered.state,
63951
63964
  actor: rendered.actor,
63965
+ workflow,
63952
63966
  label: rendered.label,
63953
63967
  memory: rendered.memory,
63954
63968
  file: rendered.file,
@@ -63997,7 +64011,7 @@ const dispatchFieldsOf = (demand) => {
63997
64011
  }
63998
64012
  };
63999
64013
  /**
64000
- * Flatten a `Demand` plus its `BeatStatus` into the single 21-key document
64014
+ * Flatten a `Demand` plus its `BeatStatus` into the single 23-key document
64001
64015
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
64002
64016
  * `costByModel` are omitted together, exactly when no cost was recorded
64003
64017
  * (`cost <= 0`).
@@ -64009,6 +64023,7 @@ const beatDocument = (demand, status) => {
64009
64023
  kind: demand.kind,
64010
64024
  content: demand.content,
64011
64025
  idle: status.idle,
64026
+ initial: status.initial,
64012
64027
  session,
64013
64028
  model: status.model,
64014
64029
  system: status.system,
@@ -64016,6 +64031,7 @@ const beatDocument = (demand, status) => {
64016
64031
  log: status.log,
64017
64032
  state: status.state,
64018
64033
  actor: status.actor,
64034
+ workflow: status.workflow,
64019
64035
  label: status.label,
64020
64036
  memory: status.memory,
64021
64037
  file: status.file,
@@ -64054,7 +64070,7 @@ const definedFields = (entries) => {
64054
64070
  return result;
64055
64071
  };
64056
64072
  /**
64057
- * The header block's own lines — `State:`/`Awaits:`/optional `Label:`/
64073
+ * The header block's own lines — `State:`/`Awaits:`/`Workflow:`/optional `Label:`/
64058
64074
  * `Model:`/`Memory:`/`File:`/`Mode:`/`Cost:`(+breakdown)/`Pending:`/`Next:`,
64059
64075
  * in that order. Used by `renderBeatPlain`'s header-showing kinds below.
64060
64076
  */
@@ -64069,6 +64085,7 @@ const beatHeaderLines = (document) => {
64069
64085
  return [
64070
64086
  `State: ${document.state}`,
64071
64087
  `Awaits: ${document.actor}`,
64088
+ `Workflow: ${document.workflow}`,
64072
64089
  ...Object.entries(optional).map(([key, value]) => `${key}: ${value}`),
64073
64090
  ...costStatusLines(document.cost ?? 0, document.costByModel ?? []),
64074
64091
  ...pendingStatusLines(document.changes),
@@ -64270,7 +64287,7 @@ const accessRefusal = (changes, write) => {
64270
64287
  ].join("\n");
64271
64288
  };
64272
64289
  //#endregion
64273
- //#region src/step/planEntry.ts
64290
+ //#region src/step/planStart.ts
64274
64291
  const refusal = (message) => ({
64275
64292
  kind: "refusal",
64276
64293
  message
@@ -64282,43 +64299,42 @@ const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) =>
64282
64299
  if (undeclared.length > 0) return refusal(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`);
64283
64300
  });
64284
64301
  /**
64285
- * `gtd --entry <name>`: start a brand NEW process at a workflow entry, writing
64286
- * an opening commit that carries every process setting as a `Gtd-Var:` trailer, plus a
64287
- * `Gtd-Review-Base:` trailer when the entry fixes the process's diff base.
64302
+ * `gtd --workflow <name>`: start a brand NEW process on a workflow, writing an
64303
+ * opening commit that names the workflow's first step and carries a
64304
+ * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
64305
+ * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base.
64288
64306
  * All validation is a REFUSAL, not an Effect failure.
64289
64307
  */
64290
- const planEntry = (current, actor, entry) => gen(function* () {
64291
- const { state: name, commandLabel, vars: varOverrides } = entry;
64292
- if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before entering`);
64293
- if (current.entryRefusal !== void 0) return refusal(`${commandLabel}: ${current.entryRefusal}`);
64308
+ const planStart = (current, actor, start) => gen(function* () {
64309
+ const { workflow, first, commandLabel, vars: varOverrides } = start;
64310
+ const name = workflow.name;
64311
+ if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
64312
+ if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
64313
+ const { firstStep } = first;
64294
64314
  const config = yield* (yield* ConfigService).load;
64295
64315
  const declaredNames = Object.keys({
64296
- ...config.workflowVars,
64316
+ ...workflow.vars,
64297
64317
  ...config.rcVars
64298
64318
  });
64299
64319
  const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, declaredNames, Object.keys({
64300
- ...config.workflowEnv,
64320
+ ...workflow.env,
64301
64321
  ...config.rcEnv
64302
64322
  }));
64303
64323
  if (overrideRefusal !== void 0) return overrideRefusal;
64304
- let base;
64305
- const baseOf = current.def.base;
64306
- const vars = resolveVars(config.workflowVars, config.rcVars, varOverrides, (yield* Host).env);
64324
+ const baseOf = workflow.def.base;
64325
+ const vars = resolveVars(workflow.vars, config.rcVars, varOverrides, (yield* Host).env);
64307
64326
  const multiline = multilineSetting(vars);
64308
64327
  if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
64309
64328
  const template = yield* try_({
64310
64329
  try: () => baseOf?.(name, vars),
64311
64330
  catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
64312
64331
  });
64313
- if (baseOf !== void 0 && template !== void 0) {
64314
- const resolved = yield* resolveBase(name, commandLabel, baseOf, template);
64315
- if (typeof resolved !== "string") return resolved;
64316
- base = resolved;
64317
- }
64318
- const subject = formatSubject(actor, name);
64332
+ const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
64333
+ if (typeof base === "object") return base;
64334
+ const subject = formatSubject(actor, firstStep);
64319
64335
  return {
64320
- kind: "entry",
64321
- state: name,
64336
+ kind: "start",
64337
+ workflow: name,
64322
64338
  subject,
64323
64339
  steps: [{
64324
64340
  kind: "gitWrite",
@@ -64326,7 +64342,8 @@ const planEntry = (current, actor, entry) => gen(function* () {
64326
64342
  kind: "commitAll",
64327
64343
  message: formatCommitMessage({
64328
64344
  actor,
64329
- to: name,
64345
+ to: firstStep,
64346
+ workflow: name,
64330
64347
  ...base !== void 0 ? { reviewBase: base } : {},
64331
64348
  vars
64332
64349
  })
@@ -64340,16 +64357,21 @@ const planEntry = (current, actor, entry) => gen(function* () {
64340
64357
  }]
64341
64358
  };
64342
64359
  });
64343
- /** The entry's diff base: a commitish that resolves, is an ancestor of HEAD, and is not HEAD. */
64344
- const resolveBase = (name, commandLabel, base, value) => gen(function* () {
64345
- const rendered = value.trim();
64346
- if (rendered === "") return refusal(`${commandLabel}: "${name}"'s reviewBase template rendered blank — template: ${base.toString()}; pass the base with --var`);
64360
+ /**
64361
+ * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
64362
+ * means the default branch. The merge-base is what gets pinned, so a base on
64363
+ * another branch reviews exactly this branch's own commits.
64364
+ */
64365
+ const resolveBase = (commandLabel, value) => gen(function* () {
64347
64366
  const git = yield* GitService;
64348
- const resolvedBase = yield* either(git.resolveRef(rendered));
64349
- if (resolvedBase._tag === "Left") return refusal(`${commandLabel}: "${rendered}" does not resolve to a commit`);
64350
- if (!(yield* git.isAncestor(resolvedBase.right, "HEAD"))) return refusal(`${commandLabel}: "${rendered}" is not an ancestor of HEAD`);
64351
- if (resolvedBase.right === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: "${rendered}" is HEAD — nothing to review`);
64352
- return resolvedBase.right;
64367
+ const rendered = value.trim();
64368
+ const target = rendered === "" ? yield* git.defaultBranch() : rendered;
64369
+ const resolvedBase = yield* either(git.resolveRef(target));
64370
+ if (resolvedBase._tag === "Left") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
64371
+ const mergeBase = yield* git.mergeBase(resolvedBase.right, "HEAD");
64372
+ if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
64373
+ if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
64374
+ return mergeBase.value;
64353
64375
  });
64354
64376
  //#endregion
64355
64377
  //#region src/Edge.ts
@@ -64362,9 +64384,10 @@ const ACTORS = /* @__PURE__ */ new Set([
64362
64384
  ]);
64363
64385
  /**
64364
64386
  * Where the episode HEAD belongs to begins, read off subjects alone. Walking
64365
- * back from HEAD: a trailer-less bare `gtd(human): <entry>` commit is what
64366
- * `gtd --entry` writes — no landing ever produces one — and opens an entered
64367
- * episode as its first commit; a commit that is not a gtd step commit, or one
64387
+ * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
64388
+ * `gtd --workflow` writes — no landing ever produces one — and opens an
64389
+ * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
64390
+ * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
64368
64391
  * entering the flow's first step (a finished episode), bounds the episode
64369
64392
  * from below.
64370
64393
  */
@@ -64373,18 +64396,21 @@ const locateEpisode = (def, history) => {
64373
64396
  const message = parseCommitMessage(history[i].message);
64374
64397
  const subject = message.parsed;
64375
64398
  if (subject !== void 0 && message.step === void 0 && subject.from === void 0 && subject.actor === "human") return {
64376
- entry: subject.to,
64399
+ workflow: message.workflow,
64400
+ legacyOpening: message.workflow === void 0,
64377
64401
  baseIndex: i,
64378
64402
  processStart: i
64379
64403
  };
64380
64404
  if (subject === void 0 || !ACTORS.has(subject.actor) || subject.to === def.initial) return {
64381
- entry: void 0,
64405
+ workflow: void 0,
64406
+ legacyOpening: false,
64382
64407
  baseIndex: i,
64383
64408
  processStart: i + 1
64384
64409
  };
64385
64410
  }
64386
64411
  return {
64387
- entry: void 0,
64412
+ workflow: void 0,
64413
+ legacyOpening: false,
64388
64414
  baseIndex: -1,
64389
64415
  processStart: 0
64390
64416
  };
@@ -64418,7 +64444,8 @@ const runOf = (history, location, closingHash = void 0) => {
64418
64444
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
64419
64445
  const head = history[history.length - 1];
64420
64446
  return {
64421
- entry: location.entry,
64447
+ workflow: location.workflow,
64448
+ legacyOpening: location.legacyOpening,
64422
64449
  startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
64423
64450
  startParentHash,
64424
64451
  diffBase: first?.reviewBase ?? startParentHash,
@@ -64445,10 +64472,10 @@ const PAGE_TIERS = [
64445
64472
  * gtd step commit, or one landing the flow's initial state, bounds an
64446
64473
  * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
64447
64474
  * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
64448
- * `locateEpisode`'s own `opening` branch (an entered episode's first commit),
64475
+ * `locateEpisode`'s own `opening` branch (a started episode's first commit),
64449
64476
  * which takes priority there over the initial-state check no matter what the
64450
- * entry is named — including an entry named after `def.initial` itself,
64451
- * where the two branches would otherwise disagree. A bare human commit can
64477
+ * workflow's first step is named — including one named after `def.initial`
64478
+ * itself, where the two branches would otherwise disagree. A bare human commit can
64452
64479
  * also be a self-loop step landing rather than a real opening (this
64453
64480
  * subject-only pass can't read the `Gtd-Step` trailer that would tell them
64454
64481
  * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
@@ -64584,16 +64611,24 @@ const pendingTree = (workspace, entries, rewrite) => {
64584
64611
  * The one resolver of both setting kinds. Process settings: the recorded
64585
64612
  * (pinned) value wins per name, anything unrecorded resolves live — the
64586
64613
  * fallback for a process started before pinning existed. Environment settings
64587
- * are always live. `entryOverrides` are `--var` values, passed only while a
64588
- * process is being entered.
64614
+ * are always live. `startOverrides` are `--var` values, passed only while a
64615
+ * process is being started.
64589
64616
  */
64590
- const settingsFor = (config, pinnedVars, hostEnv, entryOverrides = {}) => ({
64617
+ const settingsFor = (config, workflow, pinnedVars, hostEnv, startOverrides = {}) => ({
64591
64618
  vars: {
64592
- ...resolveVars(config.workflowVars, config.rcVars, entryOverrides, hostEnv),
64619
+ ...resolveVars(workflow.vars, config.rcVars, startOverrides, hostEnv),
64593
64620
  ...pinnedVars
64594
64621
  },
64595
- env: resolveVars(config.workflowEnv, config.rcEnv, {}, hostEnv)
64622
+ env: resolveVars(workflow.env, config.rcEnv, {}, hostEnv)
64596
64623
  });
64624
+ /** The workflow a run replays on: its pinned one, else the default. */
64625
+ const workflowOf = (config, run) => {
64626
+ if (run.legacyOpening) return fail$7(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
64627
+ if (run.workflow === void 0) return succeed$3(config.defaultWorkflow);
64628
+ const named = config.workflowNamed(run.workflow);
64629
+ if (named !== void 0) return succeed$3(named);
64630
+ return fail$7(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
64631
+ };
64597
64632
  /**
64598
64633
  * `judgeBudgetBytes` is the one var that refuses rather than disabling its
64599
64634
  * mechanism when blanked: without a bound the judge payload is rejected
@@ -64610,7 +64645,6 @@ const judgeBudgetBytes = (vars) => {
64610
64645
  const replayFor = (setup, pending) => replay({
64611
64646
  flow: setup.def.flow,
64612
64647
  episode: {
64613
- entry: setup.run.entry,
64614
64648
  base: setup.base,
64615
64649
  commits: setup.episode
64616
64650
  },
@@ -64628,7 +64662,7 @@ const replayFor = (setup, pending) => replay({
64628
64662
  const replayError = (outcome) => {
64629
64663
  if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
64630
64664
  if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
64631
- if (outcome.kind === "ended") return /* @__PURE__ */ new Error("gtd: history ends the episode, but HEAD does not enter the default entry's first step — the workflow changed under this process; run `gtd abandon` to start over");
64665
+ if (outcome.kind === "ended") return /* @__PURE__ */ new Error("gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over");
64632
64666
  };
64633
64667
  const judgeDocument = (step) => step.request.kind === "judge" ? JSON.stringify({
64634
64668
  state: step.request.evidence,
@@ -64755,14 +64789,15 @@ const costByModel = (entries) => {
64755
64789
  })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
64756
64790
  };
64757
64791
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
64758
- const restAt = (ref, entryOverrides = {}) => gen(function* () {
64792
+ const restAt = (ref, startOverrides = {}) => gen(function* () {
64759
64793
  const git = yield* GitService;
64760
64794
  const config = yield* (yield* ConfigService).load;
64761
64795
  const workspace = yield* Workspace;
64762
64796
  const host = yield* Host;
64763
- const def = config.workflow;
64764
- const run = yield* computeProcessRun(git, def, ref);
64765
- const { vars, env } = settingsFor(config, run.pinnedVars, host.env, entryOverrides);
64797
+ const run = yield* computeProcessRun(git, config.workflow, ref);
64798
+ const resolved = yield* workflowOf(config, run);
64799
+ const def = resolved.def;
64800
+ const { vars, env } = settingsFor(config, resolved, run.pinnedVars, host.env, startOverrides);
64766
64801
  const skills = yield* resolveScopeSkills(def, vars);
64767
64802
  const access = yield* resolveScopeAccess(def, skills, vars);
64768
64803
  const budget = yield* try_({
@@ -64810,6 +64845,7 @@ const restAt = (ref, entryOverrides = {}) => gen(function* () {
64810
64845
  const memory = memoryOf(outcome.trace, run);
64811
64846
  return {
64812
64847
  def,
64848
+ workflowName: resolved.name,
64813
64849
  state: step.name,
64814
64850
  stepDef,
64815
64851
  actor: step.actor,
@@ -64831,21 +64867,20 @@ const restAt = (ref, entryOverrides = {}) => gen(function* () {
64831
64867
  });
64832
64868
  const currentRest = restAt(void 0);
64833
64869
  /**
64834
- * Why `gtd --entry <name>` cannot open a process here, or `undefined` when it
64835
- * can: the flow, handed `name`, must read it and reach a step from the tree
64836
- * the opening commit would capture.
64870
+ * Whether `gtd --workflow <name>` can start a process here: the workflow must
64871
+ * reach a step from the tree the opening commit would capture. That step is
64872
+ * what the opening commit's subject names.
64837
64873
  */
64838
- const entryRefusal = (rest, name, entryVars) => gen(function* () {
64874
+ const startRefusal = (rest, resolved, startVars) => gen(function* () {
64839
64875
  const config = yield* (yield* ConfigService).load;
64840
64876
  const host = yield* Host;
64841
- const { vars, env } = settingsFor(config, {}, host.env, entryVars);
64842
- const skills = yield* resolveScopeSkills(rest.def, vars);
64843
- const access = yield* resolveScopeAccess(rest.def, skills, vars);
64877
+ const { vars, env } = settingsFor(config, resolved, {}, host.env, startVars);
64878
+ const skills = yield* resolveScopeSkills(resolved.def, vars);
64879
+ const access = yield* resolveScopeAccess(resolved.def, skills, vars);
64844
64880
  const workspace = rest.setup.workspace;
64845
64881
  const outcome = yield* promise(() => replay({
64846
- flow: rest.def.flow,
64882
+ flow: resolved.def.flow,
64847
64883
  episode: {
64848
- entry: name,
64849
64884
  base: {
64850
64885
  hash: "",
64851
64886
  tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
@@ -64857,13 +64892,14 @@ const entryRefusal = (rest, name, entryVars) => gen(function* () {
64857
64892
  start: "",
64858
64893
  budgetBytes: rest.setup.budget,
64859
64894
  skills,
64860
- configuredSkills: rest.def.configuredSkills,
64895
+ configuredSkills: resolved.def.configuredSkills,
64861
64896
  access,
64862
- configuredAccess: rest.def.configuredAccess
64897
+ configuredAccess: resolved.def.configuredAccess
64863
64898
  }));
64864
- if (outcome.kind === "refused") return outcome.message;
64865
- if (outcome.kind === "rest") return outcome.entryRead ? void 0 : `"${name}" is not an enterable state — this workflow reads no entry`;
64866
- return `"${name}" is not an enterable state — ${outcome.kind === "ended" ? "the flow reaches no step for it" : outcome.message}`;
64899
+ if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
64900
+ if (outcome.kind === "refused") return { refusal: outcome.message };
64901
+ const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
64902
+ return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
64867
64903
  });
64868
64904
  /** The review window's diff base at the rest. */
64869
64905
  const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
@@ -64881,8 +64917,8 @@ const renderRest = (rest) => succeed$3({
64881
64917
  * attempt at this very agent rest, and another dispatch would repeat it.
64882
64918
  */
64883
64919
  const stalledAt = (rest) => rest.changes.length === 0 && rest.stepDef.kind === "prompt" && rest.stepDef.allowEmpty !== true && rest.run.headTurn?.state === rest.state && rest.run.headTurn.actor === rest.actor && !rest.run.headTurn.step && rest.run.headTurn.empty;
64884
- /** No process is underway: the default entry's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
64885
- const noProcessUnderway = (rest) => rest.run.entry === void 0 && rest.state === rest.def.initial && rest.step.id.occurrence === 1;
64920
+ /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
64921
+ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state === rest.def.initial && rest.step.id.occurrence === 1;
64886
64922
  /** `idle` means exactly one thing: no process underway, clean tree. */
64887
64923
  const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
64888
64924
  const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
@@ -64921,8 +64957,19 @@ const landingTarget = (outcome, rest) => {
64921
64957
  message: outcome.message
64922
64958
  });
64923
64959
  if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$7(new Error(outcome.message));
64924
- return succeed$3(outcome.kind === "rest" ? outcome.rest.name : rest.def.initial);
64960
+ if (outcome.kind !== "rest") return succeed$3(rest.def.initial);
64961
+ const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
64962
+ return succeed$3(reserved === void 0 ? outcome.rest.name : reserved);
64925
64963
  };
64964
+ /**
64965
+ * A step commit entering the default's first step is what ends an episode, so
64966
+ * a named workflow landing there would read back as finished. Its opening
64967
+ * commit may name that step: a bare opening is never read as a boundary.
64968
+ */
64969
+ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
64970
+ kind: "refusal",
64971
+ message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
64972
+ } : void 0;
64926
64973
  const landingTo = (rest, to) => {
64927
64974
  if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) return {
64928
64975
  kind: "noop",
@@ -64984,7 +65031,8 @@ const snapshotFromRest = (rest, verdicts) => gen(function* () {
64984
65031
  const summaryFor = (run) => gen(function* () {
64985
65032
  const config = yield* (yield* ConfigService).load;
64986
65033
  const host = yield* Host;
64987
- const summary = config.workflow.summary;
65034
+ const resolved = yield* workflowOf(config, run);
65035
+ const summary = resolved.def.summary;
64988
65036
  if (summary === void 0 || run.trace.length === 0) return void 0;
64989
65037
  const entryCommit = run.trace[0].hash;
64990
65038
  return summary({
@@ -64997,7 +65045,7 @@ const summaryFor = (run) => gen(function* () {
64997
65045
  })),
64998
65046
  processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
64999
65047
  processCostByModel: costByModel(run.costEntries),
65000
- ...settingsFor(config, run.pinnedVars, host.env)
65048
+ ...settingsFor(config, resolved, run.pinnedVars, host.env)
65001
65049
  });
65002
65050
  });
65003
65051
  //#endregion
@@ -76876,22 +76924,15 @@ f="$(gtd next --json=file)"
76876
76924
  f="\${f:-.gtd/TODO.md}"
76877
76925
  mkdir -p "$(dirname "$f")"
76878
76926
  exec "\${EDITOR:-vi}" "$f"`;
76879
- const REVIEW_COMMAND = `#!/usr/bin/env sh
76927
+ const DOOR_COMMAND = `#!/usr/bin/env sh
76880
76928
  set -eu
76881
76929
  GTD_BUILD=~/.local/bin/gtd-build
76882
76930
  if [ -z "\${1:-}" ]; then
76883
- echo "usage: gtd-review <commitish>" >&2
76931
+ echo "usage: gtd-door <name> [args...]" >&2
76884
76932
  exit 2
76885
76933
  fi
76886
76934
  cd "$(git rev-parse --show-toplevel)"
76887
- script="$(gtd --entry review-gate.check --var reviewBase="$1")"
76888
- sh -c "$script"
76889
- exec "$GTD_BUILD"`;
76890
- const FIX_COMMAND = `#!/usr/bin/env sh
76891
- set -eu
76892
- GTD_BUILD=~/.local/bin/gtd-build
76893
- cd "$(git rev-parse --show-toplevel)"
76894
- script="$(gtd --entry fix-precheck)"
76935
+ script="$(gtd door "$@")"
76895
76936
  sh -c "$script"
76896
76937
  exec "$GTD_BUILD"`;
76897
76938
  const HEADER = () => `gtd ${GTD_VERSION$1} — driver protocol\n\ngtd decides and prints; the driver executes. gtd never runs git, never runs\nyour agent, never runs a check script, and never runs a steering mode's own\nformat:/validate: command either — that pair is text in the script gtd next\n--json's validate field (or gtd validate) prints, for the driver to run\nitself ahead of gtd land.\n`;
@@ -76972,7 +77013,8 @@ exits 0, so read it in a shell variable and guard on emptiness
76972
77013
  adds the self-validation instruction at a validatable \`prompt\` rest, and a
76973
77014
  status-summary header at every OTHER kind; \`content\` itself never carries
76974
77015
  either), \`idle\` (\`true\` iff the resolved rest is the workflow's initial
76975
- state with a clean tree), \`log\`, \`state\`, \`actor\`, \`changes\` (which
77016
+ state with a clean tree), \`initial\` (\`true\` iff no process is underway —
77017
+ the default workflow's first step, first visit — whatever the tree holds), \`log\`, \`state\`, \`actor\`, \`changes\` (which
76976
77018
  declared \`on\` pattern, if any, each pending change matches), \`next\` (the
76977
77019
  first declared \`on\` edge that would fire right now — \`{action?, pattern,
76978
77020
  target}\` — or \`null\` on no match). Only at \`kind: "prompt"\` (the DISPATCH
@@ -77066,7 +77108,7 @@ const DRIVER_OBLIGATIONS = `
77066
77108
  \`--json=judge\`, pipes that rendered \`{ state, questions }\` document to a
77067
77109
  judge model, and pipes ITS verdict (one \`{ id, answer, p }\` per pending
77068
77110
  question, as JSON on stdin) to \`gtd judge answer\` — the FIFTH command
77069
- that changes anything, alongside \`gtd land\`/\`gtd --entry\`/\`gtd
77111
+ that changes anything, alongside \`gtd land\`/\`gtd --workflow\`/\`gtd
77070
77112
  abandon\`/\`gtd restore\`. It follows the exact same required-half /
77071
77113
  optional-half script contract as \`gtd land\` in obligation 8 above:
77072
77114
  read \`--json=script\` (and, if you want them, \`--json=settled\`/
@@ -77108,10 +77150,10 @@ logic beyond "ask gtd again".
77108
77150
  const commandSuite = () => `
77109
77151
  ## Building the user's command suite: interview first, then adapt
77110
77152
 
77111
- The obligations above are the contract; the four command bodies below are one
77153
+ The obligations above are the contract; the three command bodies below are one
77112
77154
  WORKED EXAMPLE of it. Do not copy them blindly, and do not guess the user's
77113
77155
  setup — INVESTIGATE first, then INTERVIEW, then build a suite shaped by both.
77114
- Hold ONE conversation, not four: the setup steps below come first, because a
77156
+ Hold ONE conversation, not three: the setup steps below come first, because a
77115
77157
  repo that is not set up yet cannot be driven, and the suite-shape questions
77116
77158
  follow in the same numbered list.
77117
77159
 
@@ -77167,8 +77209,8 @@ follow in the same numbered list.
77167
77209
  wrapped in \`# gtd-install: model exports\` / \`# gtd-install: end\`
77168
77210
  markers — NOT into \`.gtdrc\`. They stay per machine and are never
77169
77211
  committed, so one person's model choice binds nobody else on the repo.
77170
- Two consequences follow, both worth stating to the user: \`gtd-review\`
77171
- and \`gtd-fix\` inherit these exports only because they \`exec\`
77212
+ Two consequences follow, both worth stating to the user: \`gtd-door\`
77213
+ inherits these exports only because it \`exec\`s
77172
77214
  \`gtd-build\` — anything that drives beats without going through
77173
77215
  \`gtd-build\` sees the raw \`smart\`/\`base\` hints and fails; and \`GTD_*\` is
77174
77216
  the highest-precedence config layer, so these exports silently win over
@@ -77183,11 +77225,11 @@ follow in the same numbered list.
77183
77225
  obligations directly. Pick the runtime to match: bash, their language of
77184
77226
  choice, anything that reads \`--json=<path>\` and spawns subprocesses.
77185
77227
  9. **Where should the suite live, which editor should \`gtd-edit\` spawn, and
77186
- does anything need renaming?** Suggest \`~/.local/bin\` for all four —
77187
- \`gtd-build\`, \`gtd-edit\`, \`gtd-review\`, \`gtd-fix\` — and \`$EDITOR\` (never
77228
+ does anything need renaming?** Suggest \`~/.local/bin\` for all three —
77229
+ \`gtd-build\`, \`gtd-edit\`, \`gtd-door\` — and \`$EDITOR\` (never
77188
77230
  a hardcoded editor) for the one that opens files. The interview can
77189
- rename any of the four; whatever name is chosen for \`gtd-build\` is the
77190
- RESOLVED path baked into \`GTD_BUILD\` in the other two bodies below —
77231
+ rename any of the three; whatever name is chosen for \`gtd-build\` is the
77232
+ RESOLVED path baked into \`GTD_BUILD\` in the \`gtd-door\` body below —
77191
77233
  never the literal string \`gtd-build\`.
77192
77234
 
77193
77235
  Then build it, and verify safely before the first real drive:
@@ -77196,9 +77238,9 @@ check your kind dispatch against the table above, call them as often as you
77196
77238
  like. Nothing happens until you run an emitted script.
77197
77239
 
77198
77240
  The reference rendering in sh (no \`jq\`, no JSON parser at all — each
77199
- \`--json=<path>\` call prints its one value directly). Two of the four bodies
77200
- below share one convention: \`GTD_BUILD\` is set once, at the top, to the
77201
- suite's resolved \`gtd-build\` path.
77241
+ \`--json=<path>\` call prints its one value directly). \`gtd-door\` takes the
77242
+ \`gtd-build\` path from one convention: \`GTD_BUILD\` is set once, at the top, to
77243
+ the suite's resolved \`gtd-build\` path.
77202
77244
 
77203
77245
  ### \`gtd-build\` — the driver loop
77204
77246
 
@@ -77236,41 +77278,33 @@ are ready. Default path: \`~/.local/bin/gtd-edit\`.
77236
77278
  ${EDIT_COMMAND}
77237
77279
  \`\`\`
77238
77280
 
77239
- ### \`gtd-review <commitish>\` — start a review round and drive it
77240
-
77241
- Runs \`gtd --entry review-gate.check --var reviewBase=<commitish>\`, captured
77242
- by command substitution — never a pipe: a pipeline reports only its LAST
77243
- command's exit status, so \`gtd --entry ... | sh\` under \`set -e\` would sail
77244
- past a refusal and \`exec\` a loop over a process that was never started. It
77245
- runs the captured script, then \`exec\`s the suite's RESOLVED \`gtd-build\`
77246
- path — never the literal string \`gtd-build\`. This is one invocation that
77247
- carries the review all the way to its next human gate, not just a starter:
77248
- \`gtd-review\` on a RED baseline hands off to \`gtd-build\`, which halts at the
77249
- blocked gate and prints it, rather than starting the review. Refuses with
77250
- \`usage: gtd-review <commitish>\` on stderr and exit \`2\` when the commitish is
77251
- missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-review\`.
77281
+ ### \`gtd-door <name> [args...]\` — start a process through a door and drive it
77252
77282
 
77253
- \`\`\`bash
77254
- ${REVIEW_COMMAND}
77255
- \`\`\`
77256
-
77257
- ### \`gtd-fix\` — enter the fix process and drive it
77258
-
77259
- Runs \`gtd --entry fix-precheck\`, captured the same way, then \`exec\`s the
77260
- suite's RESOLVED \`gtd-build\` path. \`gtd-fix\` on a GREEN suite is a no-op
77283
+ Runs \`gtd door <name> [args...]\`, captured by command substitution — never a
77284
+ pipe: a pipeline reports only its LAST command's exit status, so
77285
+ \`gtd door ... | sh\` under \`set -e\` would sail past a refusal and \`exec\` a
77286
+ loop over a process that was never started. It runs the captured script, then
77287
+ \`exec\`s the suite's RESOLVED \`gtd-build\` path — never the literal string
77288
+ \`gtd-build\`. This is one invocation that carries the process all the way to
77289
+ its next human gate, not just a starter: \`gtd-door review\` on a RED baseline
77290
+ hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
77291
+ rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
77261
77292
  straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
77262
- Default path: \`~/.local/bin/gtd-fix\`.
77293
+ \`gtd doors\` lists the doors a repository offers (the bundled \`fix\` and
77294
+ \`review [base]\`, plus any its config declares). Refuses with
77295
+ \`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
77296
+ missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
77263
77297
 
77264
77298
  \`\`\`bash
77265
- ${FIX_COMMAND}
77299
+ ${DOOR_COMMAND}
77266
77300
  \`\`\`
77267
77301
  ${REINSTALL}`;
77268
77302
  const REINSTALL = `
77269
77303
  ## Re-installing: detect and adapt, don't blindly overwrite
77270
77304
 
77271
77305
  A second install on a machine that already has a suite is not a fresh
77272
- install — read each of the four suite paths before writing anything
77273
- (\`gtd-build\`, \`gtd-edit\`, \`gtd-review\`, \`gtd-fix\`), and branch per path:
77306
+ install — read each of the three suite paths before writing anything
77307
+ (\`gtd-build\`, \`gtd-edit\`, \`gtd-door\`), and branch per path:
77274
77308
 
77275
77309
  - Absent — install it, as in a fresh install.
77276
77310
  - Present and content-equal to what this gtd version would emit — say so,
@@ -77292,10 +77326,16 @@ markers is unchanged, and re-asks nothing — the resolved model names are
77292
77326
  per machine, so without this exemption every single re-install would
77293
77327
  report drift on the one command everybody has.
77294
77328
 
77295
- This check scopes to exactly the four suite paths above. \`gtd-loop\` is
77329
+ This check scopes to exactly the three suite paths above, plus the superseded
77330
+ pair below. \`gtd-loop\` is
77296
77331
  outside it entirely — never read, never diffed, never touched by it in any
77297
77332
  way — an existing \`gtd-loop\` survives untouched beside the new
77298
77333
  \`gtd-build\`, and cleaning it up is the human's own call.
77334
+
77335
+ \`gtd-fix\` and \`gtd-review\` are superseded by \`gtd-door\` (\`gtd-door fix\`,
77336
+ \`gtd-door review [base]\`). When either is still present beside the suite,
77337
+ name it to the user as superseded and ask before removing it — never remove
77338
+ one unasked.
77299
77339
  `;
77300
77340
  const EDITOR_INTEGRATION = `
77301
77341
  ## Editor integration: offer it, don't bury it
@@ -77343,7 +77383,7 @@ const PREREQUISITES = `
77343
77383
  ## Prerequisites and portability
77344
77384
 
77345
77385
  - A POSIX \`sh\` (dash, ash, bash's own POSIX mode, etc.) — gtd's own emitted
77346
- scripts (\`gtd land --json=script\`, \`gtd --entry <state>\`, \`gtd abandon\`,
77386
+ scripts (\`gtd land --json=script\`, \`gtd --workflow <name>\`, \`gtd abandon\`,
77347
77387
  \`gtd restore\`, \`gtd judge answer --json=script\`) are POSIX sh; captured,
77348
77388
  then piped into it (see obligation 8 above). Reading
77349
77389
  \`gtd next --json=<path>\`/\`gtd land --json=<path>\`'s own output needs
@@ -77481,7 +77521,7 @@ const normalizeScriptNewline = (script) => script.length === 0 ? script : `${scr
77481
77521
  /**
77482
77522
  * `combinedScript`, narrowed to accept only an already-guarded
77483
77523
  * `RunnableScript` for `required` — the one boundary `ScriptSurface`'s brand
77484
- * exists to protect: `gtd land`/`gtd --entry`'s emitted script. Every OTHER
77524
+ * exists to protect: `gtd land`/`gtd --workflow`'s emitted script. Every OTHER
77485
77525
  * `combinedScript` call (`abandon`/`restore`/steering validate, below) builds
77486
77526
  * from `emitScripts` directly and keeps the unbranded signature — those
77487
77527
  * paths land no guard-gated git write for a brand to protect.
@@ -77644,29 +77684,77 @@ const runLandCommand = (opts, json, out) => gen(function* () {
77644
77684
  else out.write(renderLandPlain(built));
77645
77685
  });
77646
77686
  /**
77647
- * Starts a brand new process at `<state>`, always as `"human"`. Captures
77648
- * whatever the working tree carries at entry, like an ordinary land, rather
77649
- * than demanding a clean tree.
77687
+ * Starts a brand new process on `<workflow>`, always as `"human"`. Captures
77688
+ * whatever the working tree carries at the start, like an ordinary land,
77689
+ * rather than demanding a clean tree.
77650
77690
  *
77651
- * Refused when a process is already underway, the flow opens none for
77652
- * `<state>`, a `--var` name is undeclared, or `reviewBase:` renders to
77653
- * something that is not an ancestor of (and differs from) HEAD.
77691
+ * An unknown name is a usage error. Refused when a process is already
77692
+ * underway, the workflow reaches no step, a `--var` name is undeclared, or
77693
+ * `base` renders to something that is not an ancestor of (and differs from)
77694
+ * HEAD.
77654
77695
  */
77655
- const runEntryCommand = (actor, entryState, varOverrides, out, commandLabel) => gen(function* () {
77696
+ const runStartCommand = (actor, workflow, varOverrides, out, commandLabel) => gen(function* () {
77697
+ const config = yield* (yield* ConfigService).load;
77698
+ const resolved = config.workflowNamed(workflow);
77699
+ if (resolved === void 0) return yield* fail$7(new GtdUsageError(`gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`));
77656
77700
  const rest = yield* restAt(void 0, varOverrides);
77657
- const plan = yield* planEntry({
77658
- def: rest.def,
77701
+ const started = yield* startRefusal(rest, resolved, varOverrides);
77702
+ const plan = yield* planStart({
77659
77703
  state: rest.state,
77660
- idle: noProcessUnderway(rest),
77661
- entryRefusal: yield* entryRefusal(rest, entryState, varOverrides)
77704
+ idle: noProcessUnderway(rest)
77662
77705
  }, actor, {
77663
- state: entryState,
77706
+ workflow: resolved,
77707
+ first: started,
77664
77708
  commandLabel,
77665
77709
  vars: varOverrides
77666
77710
  });
77667
77711
  if (plan.kind === "refusal") return yield* fail$7(new Error(plan.message));
77668
77712
  out.write(landingScript(ScriptSurface.render(plan.steps)));
77669
77713
  });
77714
+ const doorSynopsis = (name, door) => [name, ...(door.args ?? []).map((arg) => arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)].join(" ");
77715
+ /**
77716
+ * `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
77717
+ * process settings its `vars` derive from the positional args. A wrong name or
77718
+ * arity is a usage error naming what would have worked.
77719
+ */
77720
+ const runDoorCommand = (name, args, out) => gen(function* () {
77721
+ const config = yield* (yield* ConfigService).load;
77722
+ const door = config.doors.get(name);
77723
+ if (door === void 0) {
77724
+ const known = [...config.doors];
77725
+ return yield* fail$7(new GtdUsageError(`gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`));
77726
+ }
77727
+ const declared = door.args ?? [];
77728
+ const required = declared.filter((arg) => arg.optional !== true).length;
77729
+ if (args.length < required || args.length > declared.length) return yield* fail$7(new GtdUsageError(`gtd door ${name}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd door ${doorSynopsis(name, door)}`));
77730
+ const named = {};
77731
+ declared.forEach((arg, i) => {
77732
+ named[arg.name] = args[i];
77733
+ });
77734
+ const vars = yield* try_({
77735
+ try: () => door.vars?.(named) ?? {},
77736
+ catch: (e) => /* @__PURE__ */ new Error(`gtd door ${name}: ${e instanceof Error ? e.message : String(e)}`)
77737
+ });
77738
+ yield* runStartCommand("human", door.workflow, { ...vars }, out, `gtd door ${name}`);
77739
+ });
77740
+ /** `gtd doors`: every door and its args, sorted by name — plain lines, or one JSON array. */
77741
+ const runDoorsCommand = (json, out) => gen(function* () {
77742
+ const doors = [...(yield* (yield* ConfigService).load).doors];
77743
+ if (json.kind === "off") {
77744
+ out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""));
77745
+ return;
77746
+ }
77747
+ const document = doors.map(([n, d]) => ({
77748
+ name: n,
77749
+ workflow: d.workflow,
77750
+ args: (d.args ?? []).map((arg) => ({
77751
+ name: arg.name,
77752
+ optional: arg.optional === true
77753
+ }))
77754
+ }));
77755
+ if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`);
77756
+ else yield* writeSelection(out, document, json.path);
77757
+ });
77670
77758
  /**
77671
77759
  * Ends the process underway without completing it. Nothing is discarded: a
77672
77760
  * `git reset --mixed` to the process's start commit leaves every turn
@@ -77923,6 +78011,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
77923
78011
  }), statusOf({
77924
78012
  rendered,
77925
78013
  idle: restIsIdle(rest),
78014
+ initial: noProcessUnderway(rest),
78015
+ workflow: rest.workflowName,
77926
78016
  log,
77927
78017
  changes: computeStatusChanges(rest.changes),
77928
78018
  next: yield* computeNextMatch(rest),
@@ -78009,7 +78099,9 @@ const dispatchVoidCommand = (command, json, out) => {
78009
78099
  ...command.cost !== void 0 ? { cost: command.cost } : {},
78010
78100
  ...command.model !== void 0 ? { model: command.model } : {}
78011
78101
  }, json, out);
78012
- case "entry": return runEntryCommand(command.actor, command.state, command.vars, out, command.label);
78102
+ case "start": return runStartCommand(command.actor, command.workflow, command.vars, out, command.label);
78103
+ case "door": return runDoorCommand(command.name, command.args, out);
78104
+ case "doors": return runDoorsCommand(json, out);
78013
78105
  case "abandon": return runAbandonCommand(out);
78014
78106
  case "restore": return runRestoreCommand(out);
78015
78107
  case "next": return runNextCommand(json, out);
@@ -78073,12 +78165,12 @@ const FLAGS = [
78073
78165
  name: "--json",
78074
78166
  arity: "optional",
78075
78167
  repeatable: false,
78076
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer",
78168
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors",
78077
78169
  decode: ([raw]) => right(raw ?? ""),
78078
- scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
78170
+ scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
78079
78171
  valueHint: "<path>",
78080
78172
  help: [
78081
- "(gtd next/gtd land/gtd judge/gtd judge answer only) output",
78173
+ "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors only) output",
78082
78174
  "structured JSON. Bare --json prints the whole document;",
78083
78175
  "--json=<path> (a dotted key path into that document, e.g.",
78084
78176
  "kind, content, session.id) prints just that value: a",
@@ -78177,7 +78269,7 @@ const FLAGS = [
78177
78269
  repeatable: false,
78178
78270
  scope: (kind) => kind === "land",
78179
78271
  decode: ([raw]) => nonNegativeNumber(raw ?? "", "--cost"),
78180
- scopeError: "gtd: --cost is only valid for `gtd land` — an entry is not a metered agent turn",
78272
+ scopeError: "gtd: --cost is only valid for `gtd land` — a start is not a metered agent turn",
78181
78273
  valueHint: "<n>",
78182
78274
  help: ["(gtd land only) record the invocation's token cost"]
78183
78275
  },
@@ -78196,24 +78288,20 @@ const FLAGS = [
78196
78288
  ]
78197
78289
  },
78198
78290
  {
78199
- name: "--entry",
78291
+ name: "--workflow",
78200
78292
  arity: 1,
78201
78293
  repeatable: false,
78202
- scope: (kind) => kind === "entry",
78294
+ scope: (kind) => kind === "start",
78203
78295
  decode: ([raw]) => right(raw ?? ""),
78204
- scopeError: "gtd: --entry is only valid with no other command — use the bare `gtd --entry <state>` form; landing and entering are different verbs",
78205
- valueHint: "<state>",
78206
- help: [
78207
- "(with no command at all) start a brand new process,",
78208
- "handing <state> to the workflow as its entry —",
78209
- "authenticated as human"
78210
- ]
78296
+ scopeError: "gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` form; landing and starting are different verbs",
78297
+ valueHint: "<name>",
78298
+ help: ["(with no command at all) start a brand new process on", "the named workflow — authenticated as human"]
78211
78299
  },
78212
78300
  {
78213
78301
  name: "--var",
78214
78302
  arity: 1,
78215
78303
  repeatable: true,
78216
- scope: (kind) => kind === "entry",
78304
+ scope: (kind) => kind === "start",
78217
78305
  decode: (raws) => {
78218
78306
  const vars = {};
78219
78307
  const seen = /* @__PURE__ */ new Set();
@@ -78230,10 +78318,10 @@ const FLAGS = [
78230
78318
  }
78231
78319
  return right(vars);
78232
78320
  },
78233
- scopeError: "gtd: --var requires --entry",
78321
+ scopeError: "gtd: --var requires --workflow",
78234
78322
  valueHint: "<name>=<value>",
78235
78323
  help: [
78236
- "(with --entry; repeatable) pin a process setting for the",
78324
+ "(with --workflow; repeatable) pin a process setting for the",
78237
78325
  "new process; the name must already be declared by the",
78238
78326
  "workflow's defaults or the .gtdrc vars: (environment",
78239
78327
  "settings are not pinnable)"
@@ -78516,6 +78604,34 @@ const COMMAND_ROWS = [
78516
78604
  "Refuses (exit 1) when no process is underway."
78517
78605
  ]
78518
78606
  },
78607
+ {
78608
+ token: "door",
78609
+ kind: "door",
78610
+ arity: {
78611
+ name: "name",
78612
+ variadic: "args"
78613
+ },
78614
+ details: [
78615
+ "Start a process through a named shortcut, mapping the",
78616
+ "positional args to that workflow's process settings — the",
78617
+ "same start `gtd --workflow` makes, printed the same way.",
78618
+ "Doors come from the config's `doors` export; the bundled",
78619
+ "ones are `fix` and `review [base]`. An unknown door, or too",
78620
+ "few or too many args, is a usage error (exit 2) naming the",
78621
+ "doors or the door's synopsis"
78622
+ ]
78623
+ },
78624
+ {
78625
+ token: "doors",
78626
+ kind: "doors",
78627
+ arity: "none",
78628
+ details: [
78629
+ "List every door, sorted: one `name [args] → workflow` line",
78630
+ "each. --json prints one JSON array of { name, workflow,",
78631
+ "args: [{ name, optional }] } instead; --json=<path> reads one",
78632
+ "value off it. Writes nothing"
78633
+ ]
78634
+ },
78519
78635
  {
78520
78636
  token: "judge",
78521
78637
  kind: "judge",
@@ -78571,23 +78687,23 @@ const renderBlock = (header, lines) => {
78571
78687
  const headerCell = ` ${header}`;
78572
78688
  return (headerCell.length < COLUMN ? `${headerCell.padEnd(COLUMN)}${first ?? ""}\n` : `${headerCell}\n${" ".repeat(COLUMN)}${first ?? ""}\n`) + rest.map((l) => `${" ".repeat(COLUMN)}${l}\n`).join("");
78573
78689
  };
78574
- const ENTRY_SHORT_FORM = renderBlock("(no command) --entry <state>", ["Starts a new process authenticated as human, e.g.", "'gtd --entry <state>'"]);
78690
+ const START_SHORT_FORM = renderBlock("(no command) --workflow <name>", ["Starts a new process authenticated as human, e.g.", "'gtd --workflow <name>'"]);
78575
78691
  const VERSION_BLOCK = renderBlock("version", ["Print version and exit"]);
78576
78692
  const HELP_BLOCK = renderBlock("help", ["Print this help and exit"]);
78577
- const SPACE_FORM_FLAGS = /* @__PURE__ */ new Set(["--entry", "--var"]);
78693
+ const SPACE_FORM_FLAGS = /* @__PURE__ */ new Set(["--workflow", "--var"]);
78578
78694
  const flagHeader = (row) => {
78579
78695
  if (row.valueHint === "") return row.name;
78580
78696
  return SPACE_FORM_FLAGS.has(row.name) ? `${row.name} ${row.valueHint}` : `${row.name}=${row.valueHint}`;
78581
78697
  };
78582
78698
  const renderHelp = () => {
78583
78699
  const commandBlocks = COMMAND_ROWS.map((row) => {
78584
- const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : `${row.token} <${row.arity.name}>`;
78700
+ const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : "variadic" in row.arity ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]` : `${row.token} <${row.arity.name}>`;
78585
78701
  return renderBlock(header, row.details);
78586
78702
  });
78587
78703
  return `Usage: gtd [command] [options]\n\nCommands:\n${[
78588
78704
  commandBlocks[0],
78589
78705
  commandBlocks[1],
78590
- ENTRY_SHORT_FORM,
78706
+ START_SHORT_FORM,
78591
78707
  ...commandBlocks.slice(2),
78592
78708
  VERSION_BLOCK,
78593
78709
  HELP_BLOCK
@@ -78692,14 +78808,14 @@ const conflictViolation = (present) => {
78692
78808
  };
78693
78809
  const arityError = (cmd, rest, arity) => {
78694
78810
  if (arity === "none") return rest.length > 0 ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}` : void 0;
78695
- if ("names" in arity) {
78696
- const [first, second] = arity.names;
78697
- if (rest.length < 2) return rest.length === 0 ? `gtd ${cmd}: missing ${first} and ${second} arguments` : `gtd ${cmd}: missing ${second} argument`;
78698
- if (rest.length > 2) return `gtd ${cmd}: too many arguments — expected ${first} and ${second}, got: ${rest.join(", ")}`;
78699
- return;
78700
- }
78811
+ if ("names" in arity) return pairArityError(cmd, rest, arity.names);
78701
78812
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`;
78702
- if (rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
78813
+ if (!("variadic" in arity) && rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
78814
+ };
78815
+ const pairArityError = (cmd, rest, [first, second]) => {
78816
+ if (rest.length === 0) return `gtd ${cmd}: missing ${first} and ${second} arguments`;
78817
+ if (rest.length === 1) return `gtd ${cmd}: missing ${second} argument`;
78818
+ if (rest.length > 2) return `gtd ${cmd}: too many arguments — expected ${first} and ${second}, got: ${rest.join(", ")}`;
78703
78819
  };
78704
78820
  /** Decode every present flag into a bag of values, or the first decode error encountered. */
78705
78821
  const decodeFlags = (byFlag) => {
@@ -78733,8 +78849,8 @@ const parseArgv = (argv) => {
78733
78849
  if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen);
78734
78850
  const { positionals, byFlag, present, jsonSeen } = tokenized;
78735
78851
  const first = positionals[0];
78736
- const entryRaw = byFlag.get("--entry")?.[0];
78737
- const entryPresent = entryRaw !== void 0;
78852
+ const workflowRaw = byFlag.get("--workflow")?.[0];
78853
+ const workflowPresent = workflowRaw !== void 0;
78738
78854
  if (first === "version") return {
78739
78855
  kind: "output",
78740
78856
  stdout: `${GTD_VERSION}\n`
@@ -78745,17 +78861,17 @@ const parseArgv = (argv) => {
78745
78861
  };
78746
78862
  const row = first === void 0 ? void 0 : commandByToken(first);
78747
78863
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer";
78748
- const selectsEntry = entryPresent && first === void 0;
78864
+ const selectsStart = workflowPresent && first === void 0;
78749
78865
  const judgeRun = row?.kind === "judge" && positionals[1] === "run";
78750
- const kind = selectsEntry ? "entry" : judgeAnswer ? "judgeAnswer" : judgeRun ? "judgeRun" : row?.kind;
78751
- if (row === void 0 && !selectsEntry) {
78866
+ const kind = selectsStart ? "start" : judgeAnswer ? "judgeAnswer" : judgeRun ? "judgeRun" : row?.kind;
78867
+ if (row === void 0 && !selectsStart) {
78752
78868
  const violation = scopeViolation(void 0, present);
78753
78869
  if (violation !== void 0) return usagePlan(violation, jsonSeen);
78754
78870
  if (first === void 0) return usagePlan("gtd: missing command — gtd decides and prints, a driver executes; run `gtd install`, or copy one from https://github.com/pmelab/gtd/blob/main/docs/driver.md's \"A complete minimal driver\" section and run that, or see usage above (`gtd --help`)", jsonSeen, renderHelp());
78755
78871
  return usagePlan(`unknown command '${first}'`, jsonSeen);
78756
78872
  }
78757
78873
  const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1);
78758
- if (kind === "entry" && first === void 0) {
78874
+ if (kind === "start" && first === void 0) {
78759
78875
  if (restPositionals.length > 0) return usagePlan(`gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`, jsonSeen);
78760
78876
  } else {
78761
78877
  const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(first, restPositionals, row.arity);
@@ -78782,14 +78898,14 @@ const parseArgv = (argv) => {
78782
78898
  verbose
78783
78899
  };
78784
78900
  }
78785
- if (kind === "entry") {
78786
- const label = `gtd --entry ${entryRaw}`;
78901
+ if (kind === "start") {
78902
+ const label = `gtd --workflow ${workflowRaw}`;
78787
78903
  return {
78788
78904
  kind: "command",
78789
78905
  command: {
78790
- kind: "entry",
78906
+ kind: "start",
78791
78907
  actor: "human",
78792
- state: entryRaw,
78908
+ workflow: workflowRaw,
78793
78909
  vars: bag["--var"] ?? {},
78794
78910
  label
78795
78911
  },
@@ -78797,6 +78913,16 @@ const parseArgv = (argv) => {
78797
78913
  verbose
78798
78914
  };
78799
78915
  }
78916
+ if (kind === "door") return {
78917
+ kind: "command",
78918
+ command: {
78919
+ kind: "door",
78920
+ name: restPositionals[0],
78921
+ args: restPositionals.slice(1)
78922
+ },
78923
+ json,
78924
+ verbose
78925
+ };
78800
78926
  if (kind === "ui") return {
78801
78927
  kind: "command",
78802
78928
  command: {