@pmelab/gtd 18.0.0 → 19.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.
@@ -30000,7 +30000,7 @@ const handleBadArgument = (method) => (cause) => new BadArgument({
30000
30000
  method,
30001
30001
  cause
30002
30002
  });
30003
- const access = /*#__PURE__*/ (() => {
30003
+ const access$1 = /*#__PURE__*/ (() => {
30004
30004
  const nodeAccess = /*#__PURE__*/ effectify(NFS.access, /*#__PURE__*/ handleErrnoException("FileSystem", "access"), /*#__PURE__*/ handleBadArgument("access"));
30005
30005
  return (path, options) => {
30006
30006
  let mode = NFS.constants.F_OK;
@@ -30285,7 +30285,7 @@ const writeFile$1 = (path, data, options) => async((resume, signal) => {
30285
30285
  * @category layer
30286
30286
  */
30287
30287
  const layer$7 = /* @__PURE__ */ effect$1(FileSystem, /* @__PURE__ */ map$4(/*#__PURE__*/ serviceOption(WatchBackend), (backend) => make$6({
30288
- access,
30288
+ access: access$1,
30289
30289
  chmod,
30290
30290
  chown,
30291
30291
  copy,
@@ -30968,6 +30968,8 @@ const head = () => ctx().head();
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);
30971
+ /** The folded access `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
30972
+ const accessFor = (localName, file) => ctx().accessFor(localName, file);
30971
30973
  const settingsProxy = (read) => new Proxy({}, {
30972
30974
  get: (_target, key) => typeof key === "string" ? read()[key] : void 0,
30973
30975
  has: (_target, key) => typeof key === "string" && key in read(),
@@ -31011,6 +31013,7 @@ const checkScript = (command, options) => {
31011
31013
  return [
31012
31014
  "#!/usr/bin/env sh",
31013
31015
  "set +e",
31016
+ ...options.preamble ?? [],
31014
31017
  ...removal(options.sweep ?? []),
31015
31018
  `mkdir -p "$(dirname ${report})"`,
31016
31019
  "(",
@@ -31104,12 +31107,13 @@ const wrote = (path) => changes(path).some((c) => c.status !== "deleted");
31104
31107
  * command passed.
31105
31108
  */
31106
31109
  const check = async (name, command, options) => {
31107
- const { report, label, sweep, sweepOnGreen } = options;
31110
+ const { report, label, sweep, sweepOnGreen, preamble } = options;
31108
31111
  await run$3(name, checkScript(command, {
31109
31112
  report,
31110
31113
  stamp: head().slice(0, 7),
31111
31114
  sweep,
31112
- sweepOnGreen
31115
+ sweepOnGreen,
31116
+ preamble
31113
31117
  }), { label });
31114
31118
  return !wrote(report);
31115
31119
  };
@@ -31161,6 +31165,7 @@ const requireRevert = (edited, base) => {
31161
31165
  //#endregion
31162
31166
  //#region src/flows/index.ts
31163
31167
  var flows_exports = /* @__PURE__ */ __exportAll({
31168
+ accessFor: () => accessFor,
31164
31169
  agent: () => agent,
31165
31170
  answered: () => answered,
31166
31171
  changes: () => changes,
@@ -47480,6 +47485,34 @@ const viewOf = (format, content) => ({
47480
47485
  /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
47481
47486
  const openThreads = (format, content) => format.openThreads?.(content) ?? [];
47482
47487
  //#endregion
47488
+ //#region src/replay/Access.ts
47489
+ const UNRESTRICTED = {};
47490
+ const withExtras = (globs, extras) => globs === void 0 ? null : [.../* @__PURE__ */ new Set([...globs, ...extras])];
47491
+ /**
47492
+ * Fold a step's own paths into a scope's access: each restricted side gets the
47493
+ * steering `file`, then the code threads waiting on the agent. An unrestricted
47494
+ * side stays `null`.
47495
+ */
47496
+ const foldAccess = (scopeAccess, file, threadPaths) => {
47497
+ const access = scopeAccess ?? UNRESTRICTED;
47498
+ const extras = [...file === void 0 ? [] : [file], ...threadPaths];
47499
+ return {
47500
+ read: withExtras(access.read, extras),
47501
+ write: withExtras(access.write, extras)
47502
+ };
47503
+ };
47504
+ /** The shape fault of a `scope()` `access` value, or `undefined` when well-formed. */
47505
+ const accessShapeFault = (value) => {
47506
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "access must be an object of { read?, write? } glob arrays";
47507
+ for (const [side, globs] of Object.entries(value)) {
47508
+ if (side !== "read" && side !== "write") return `unknown access key "${side}"`;
47509
+ if (globs === void 0) continue;
47510
+ if (!Array.isArray(globs) || globs.some((g) => typeof g !== "string")) return `access.${side} must be an array of glob strings`;
47511
+ }
47512
+ };
47513
+ const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
47514
+ const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
47515
+ //#endregion
47483
47516
  //#region src/replay/Glob.ts
47484
47517
  const ESCAPE_RE = /[.+^${}()|[\]\\?]/g;
47485
47518
  const compile = (glob) => {
@@ -47701,7 +47734,8 @@ const budgeted = (evidence, budgetBytes) => {
47701
47734
  truncated
47702
47735
  };
47703
47736
  };
47704
- const samePersona = (a, b) => a.model === b.model && a.system === b.system && (a.skills?.length ?? 0) === (b.skills?.length ?? 0) && (a.skills ?? []).every((skill, i) => skill === b.skills?.[i]);
47737
+ const sameSkills = (a, b) => (a?.length ?? 0) === (b?.length ?? 0) && (a ?? []).every((skill, i) => skill === b?.[i]);
47738
+ const samePersona = (a, b) => a.model === b.model && a.system === b.system && sameSkills(a.skills, b.skills) && sameAccess(a.access, b.access);
47705
47739
  /**
47706
47740
  * Replay one episode: run its entry flow, answering each step from the
47707
47741
  * episode's commits in order, until a step has no commit left — the rest.
@@ -47791,17 +47825,34 @@ const replay = async (input) => {
47791
47825
  truncated: judgeCuts.get(request) ?? []
47792
47826
  } : void 0;
47793
47827
  const judgeCuts = /* @__PURE__ */ new WeakMap();
47794
- const resolveSkills = (memory) => {
47828
+ const resolveScoped = (memory, pick, configured, bundled) => {
47795
47829
  const own = /* @__PURE__ */ new Map();
47796
47830
  const names = [];
47797
47831
  for (const s of scopes) {
47798
47832
  if (s.name !== void 0) names.push(s.name);
47799
- if (s.skills !== void 0) own.set(names.join("."), s.skills);
47833
+ const v = pick(s);
47834
+ if (v !== void 0) own.set(names.join("."), v);
47800
47835
  }
47801
47836
  const parts = memory === "" ? [] : memory.split(".");
47802
- const hit = parts.map((_, i) => parts.slice(0, parts.length - i).join(".")).concat("").map((p) => input.configuredSkills?.[p] ?? own.get(p) ?? input.skills?.[p]).find((r) => r !== void 0);
47837
+ return parts.map((_, i) => parts.slice(0, parts.length - i).join(".")).concat("").map((p) => configured?.[p] ?? own.get(p) ?? bundled?.[p]).find((r) => r !== void 0);
47838
+ };
47839
+ const resolveSkills = (memory) => {
47840
+ const hit = resolveScoped(memory, (s) => s.skills, input.configuredSkills, input.skills);
47803
47841
  return hit === void 0 || hit.length === 0 ? void 0 : hit;
47804
47842
  };
47843
+ const resolveAccess = (memory) => resolveScoped(memory, (s) => s.access, input.configuredAccess, input.access);
47844
+ const foldedAccessFor = (scopeAccess, file) => {
47845
+ return foldAccess(scopeAccess, file, scopeAccess?.read !== void 0 || scopeAccess?.write !== void 0 ? context.codeThreads().filter((t) => t.waitingOn === "agent").map((t) => t.path) : []);
47846
+ };
47847
+ const scopeAccessFault = () => {
47848
+ for (const [i, s] of scopes.entries()) {
47849
+ if (s.access === void 0) continue;
47850
+ const fault = accessShapeFault(s.access);
47851
+ if (fault === void 0) continue;
47852
+ return `gtd: scope "${scopes.slice(0, i + 1).flatMap((o) => o.name === void 0 ? [] : [o.name]).join(".") || "root"}": ${fault}`;
47853
+ }
47854
+ };
47855
+ const unfoldedAccess = /* @__PURE__ */ new WeakMap();
47805
47856
  const resolve = (request) => {
47806
47857
  if (request.kind === "agent") {
47807
47858
  const persona = {};
@@ -47833,6 +47884,8 @@ const replay = async (input) => {
47833
47884
  const occurrence = (occurrences.get(name) ?? 0) + 1;
47834
47885
  occurrences.set(name, occurrence);
47835
47886
  const resolved = resolve(request);
47887
+ const memory = memoryScopeOf(name);
47888
+ const scopeAccess = resolved.kind === "agent" ? resolveAccess(memory) : void 0;
47836
47889
  const reached = {
47837
47890
  id: {
47838
47891
  name,
@@ -47843,11 +47896,15 @@ const replay = async (input) => {
47843
47896
  actor: actorOfKind(resolved.kind),
47844
47897
  request: resolved,
47845
47898
  memoryScope: memoryScopeOf(name),
47846
- ...resolved.kind === "agent" ? { skills: resolveSkills(memoryScopeOf(name)) } : {},
47899
+ ...resolved.kind === "agent" ? {
47900
+ skills: resolveSkills(memory),
47901
+ access: foldedAccessFor(scopeAccess, resolved.options.file)
47902
+ } : {},
47847
47903
  enteredAt: position.hash,
47848
47904
  truncated: (judgeCuts.get(resolved) ?? []).length > 0,
47849
47905
  scopeCalls: namedScopeCalls()
47850
47906
  };
47907
+ if (resolved.kind === "agent") unfoldedAccess.set(reached, scopeAccess);
47851
47908
  trace.push(reached);
47852
47909
  return reached;
47853
47910
  };
@@ -47856,14 +47913,15 @@ const replay = async (input) => {
47856
47913
  const identity = {
47857
47914
  model: step.request.options.model,
47858
47915
  system: step.request.options.system,
47859
- skills: step.skills
47916
+ skills: step.skills,
47917
+ access: unfoldedAccess.get(step)
47860
47918
  };
47861
47919
  const seen = personas.get(step.memoryScope);
47862
47920
  if (seen === void 0) {
47863
47921
  personas.set(step.memoryScope, identity);
47864
47922
  return;
47865
47923
  }
47866
- return samePersona(seen, identity) ? void 0 : `gtd: "${step.name}" runs with a different model, system prompt or skills than an earlier agent step in memory scope "${step.memoryScope || "root"}" — one scope is one conversation, and a conversation has one identity`;
47924
+ return samePersona(seen, identity) ? void 0 : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in memory scope "${step.memoryScope || "root"}" — one scope is one conversation, and a conversation has one identity`;
47867
47925
  };
47868
47926
  const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over";
47869
47927
  const blockerAt = (step) => {
@@ -47917,6 +47975,10 @@ const replay = async (input) => {
47917
47975
  });
47918
47976
  return replyFor(step.request, input.pending.verdicts ?? []);
47919
47977
  };
47978
+ const requestFault = (request) => {
47979
+ if (request.kind === "run" && typeof request.body !== "string") return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`;
47980
+ return request.kind === "agent" ? scopeAccessFault() : void 0;
47981
+ };
47920
47982
  const handleStep = async (request) => {
47921
47983
  if (outcome !== void 0) throw new Stop();
47922
47984
  if (request.kind === "restart") return finish(endOfFlow("restart"));
@@ -47924,9 +47986,10 @@ const replay = async (input) => {
47924
47986
  kind: "failed",
47925
47987
  message: "gtd: a step was called without a name"
47926
47988
  });
47927
- if (request.kind === "run" && typeof request.body !== "string") return finish({
47989
+ const fault = requestFault(request);
47990
+ if (fault !== void 0) return finish({
47928
47991
  kind: "failed",
47929
- message: `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
47992
+ message: fault
47930
47993
  });
47931
47994
  const step = reach(request);
47932
47995
  const blocker = blockerAt(step);
@@ -47946,7 +48009,7 @@ const replay = async (input) => {
47946
48009
  if (tree === void 0) throw new Error(`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`);
47947
48010
  return changesBetween(tree, position.tree);
47948
48011
  };
47949
- installContext({
48012
+ const context = {
47950
48013
  step: handleStep,
47951
48014
  refuse: (message) => {
47952
48015
  outcome ??= {
@@ -48001,11 +48064,13 @@ const replay = async (input) => {
48001
48064
  env: input.env,
48002
48065
  start: () => input.start,
48003
48066
  skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
48067
+ accessFor: (localName, file) => foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
48004
48068
  head: () => {
48005
48069
  const rest = commits.slice(cursor);
48006
48070
  return rest.length > 0 && rest.every((c) => c.parsed.step === void 0) ? rest[rest.length - 1].hash : position.hash;
48007
48071
  }
48008
- });
48072
+ };
48073
+ installContext(context);
48009
48074
  try {
48010
48075
  const flowDone = input.flow(args).then(() => {
48011
48076
  if (outcome === void 0 && trace.length === 0) outcome = {
@@ -48045,6 +48110,11 @@ const skillsPreamble = `- Load whatever's listed here that your harness actually
48045
48110
  prompt states first
48046
48111
  - Never let a loaded skill turn this turn interactive — answer nothing,
48047
48112
  ask nothing; this runs unattended, with no one at a keyboard`;
48113
+ /** One bullet per restricted side; empty when neither is. An empty glob list reads "nothing". */
48114
+ const accessPreamble = (access) => {
48115
+ const globs = (list) => list.length === 0 ? "nothing" : list.join(", ");
48116
+ return [...access.read === null ? [] : [`- This turn may read only: ${globs(access.read)}`], ...access.write === null ? [] : [`- This turn may write only: ${globs(access.write)} — anything else is refused when the turn lands`]].join("\n");
48117
+ };
48048
48118
  const styleBlock = `- A deliverable, not a chat reply — size follows the work; cut padding
48049
48119
  - Lead with the answer; never circle back to restate it
48050
48120
  - Flat, commanding sentences — commit to the claim, never hedge
@@ -48089,8 +48159,7 @@ const architectPersona = `You are the technical planning voice in gtd's build pi
48089
48159
  once product concerns are settled, reading them cold, no carried
48090
48160
  conversation. Work out the *how* per concern — structure, data models,
48091
48161
  tech-stack choices, error handling — raise the technical open
48092
- questions, then write one package spec per concern with no further
48093
- judgement call: the grouping is already decided.`;
48162
+ questions, then pin interfaces and tests in the architecture document.`;
48094
48163
  const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
48095
48164
  deliberately separate from whoever wrote the code, with no attachment
48096
48165
  to it. Write a structured review document grouping a diff into
@@ -48099,12 +48168,6 @@ just approving; answer the human's questions inline in the review;
48099
48168
  and, when asked, fix the small nits the human flagged and the risks
48100
48169
  you marked yourself, each in one batch.
48101
48170
  Beyond that you never fix or build anything yourself.`;
48102
- const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
48103
- pipeline, checking one freshly-built package against its spec. Verify
48104
- only: tasks done, criteria met, code sound and consistent with the
48105
- codebase. Write feedback only when something is genuinely wrong —
48106
- otherwise write nothing; a clean turn IS the approval. Never fix what
48107
- you find — naming it precisely enough for a fix turn is the whole job.`;
48108
48171
  const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
48109
48172
  package's declared scope end to end, tests first, and come back to fix
48110
48173
  things when redirected — a failing check, or reviewer feedback. Stay
@@ -48282,8 +48345,12 @@ const withSkills = (skills, prompt) => {
48282
48345
  if (skills === void 0 || skills.trim() === "") return prompt;
48283
48346
  return `${skillsPreamble.replaceAll("{skills}", skills)}\n\n${prompt}`;
48284
48347
  };
48285
- /** An `agent()` step whose prompt opens with the preamble naming its scope's skills. */
48286
- const agentWithSkills = (name, prompt, options = {}) => agent(name, withSkills(skillsFor(name).join(", "), prompt), options);
48348
+ /** An `agent()` step whose prompt opens with the preamble naming its scope's skills, then its restricted file access. */
48349
+ const agentWithSkills = (name, prompt, options = {}) => {
48350
+ const access = accessPreamble(accessFor(name, options.file));
48351
+ const body = access === "" ? prompt : `${access}\n\n${prompt}`;
48352
+ return agent(name, withSkills(skillsFor(name).join(", "), body), options);
48353
+ };
48287
48354
  const unwindFailure = (commit) => `gtd could not unwind ${commit} out of your working tree.`;
48288
48355
  const idleMessage = () => `No active gtd process.
48289
48356
 
@@ -48310,14 +48377,6 @@ carries it.
48310
48377
  What each change does next (then run \`gtd land\`):
48311
48378
  - **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
48312
48379
  `;
48313
- const architecturePreMessage = () => `Judging whether \`.gtd/REQUIREMENTS.md\`'s settled concerns need a
48314
- dedicated architecture pass — real structural decisions, multiple
48315
- integration points, or a non-obvious tradeoff — before packages
48316
- are written. Run \`gtd judge answer\` and pipe a verdict for
48317
- \`architectureWarranted\` — or land untouched to run the full pass
48318
- (the conservative default; a skipped judgment never suppresses
48319
- it).
48320
- `;
48321
48380
  const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
48322
48381
  \`.gtd/FEEDBACK.md\` holds the failing output.
48323
48382
 
@@ -48482,22 +48541,46 @@ ${footnoteFoldIn}${codeThreadReplies()}
48482
48541
  authority is to merge only, never to split — the whole problem
48483
48542
  is over-granularity
48484
48543
  - Record every merge under \`## Merged Concerns\`,
48485
- carrying both merged requirements verbatim so spec review
48486
- still covers each independently
48544
+ carrying both merged requirements verbatim so each requirement
48545
+ stays traceable to its tests
48487
48546
  - A merge raises no open question and stops for no
48488
48547
  human — do not route it to \`architecture.gate\` for a veto; the
48489
- human sees it when reviewing the plan, and spec review is the
48490
- real safety net
48548
+ human sees it when reviewing the plan
48491
48549
  - Prefer fewer, larger packages — the smallest independently
48492
48550
  valuable change, not the smallest change that compiles
48493
48551
  - Every open point here is TECHNICAL — triage already resolved
48494
48552
  the product ones, one phase earlier. PERMISSIVE: answer it
48495
48553
  yourself unless you genuinely cannot defend a default; a wrong
48496
- technical call is still caught at spec review
48554
+ technical call is still caught by the full run and the quality lap
48497
48555
  - The narrow exception: a simplification that drops something
48498
48556
  \`.gtd/REQUIREMENTS.md\` mentions is an open question, not a
48499
48557
  silent default
48500
48558
 
48559
+ - \`.gtd/ARCHITECTURE.md\` is the acceptance spec the human signs
48560
+ off. It carries these sections, in this order:
48561
+ 1. \`## Open Questions\` (qa format, first, only when present)
48562
+ 2. \`## Interfaces\` — new and changed signatures/contracts
48563
+ only, as fenced TypeScript (or the repository's language)
48564
+ 3. \`## Call Stacks\` — seam level, entry point → module
48565
+ boundary; one line per hop, never per line of code
48566
+ 4. \`## E2E Scenarios\` — Gherkin in fenced \`gherkin\` blocks
48567
+ (whatever e2e framework the repository uses), each preceded
48568
+ by a line \`- e2e: <path>\`. With no user-visible
48569
+ behaviour change, the literal line \`No e2e change.\` plus a
48570
+ one-line reason — never empty or missing
48571
+ 5. \`## Unit Tests\` — one \`### <interface>\` per entry of
48572
+ \`## Interfaces\`, each test a line
48573
+ \`- unit: <path> — <behaviour> (covers <concern>)\`;
48574
+ never against an internal helper. A requirement no test can
48575
+ cover (docs, README, deletions, config) goes under a
48576
+ \`### Chores\` heading in this section as
48577
+ \`- chore: <path> — <what> (covers <concern>)\`
48578
+ 6. \`## Merged Concerns\` — kept, when any merge happened
48579
+ 7. \`## Answered Questions\` — last
48580
+ - Coverage rule: every concern in \`.gtd/REQUIREMENTS.md\` appears
48581
+ in at least one \`(covers …)\` tail, as a test or a chore. Each
48582
+ test entry carries its file path and level (unit / e2e)
48583
+
48501
48584
  ${questionBar}
48502
48585
  ## Return lap
48503
48586
 
@@ -48509,26 +48592,41 @@ const architectureDecomposePrompt = () => `${styleBlock}
48509
48592
 
48510
48593
  ${stateFileRules}
48511
48594
  - The only state files this turn touches are the package files
48512
- under \`.gtd/packages/\` and \`.gtd/ARCHITECTURE.md\` (deleted) —
48513
- no other files for notes or output
48595
+ under \`.gtd/packages/\` — no other files for notes or output.
48596
+ Never delete \`.gtd/ARCHITECTURE.md\`: the build tail's full run
48597
+ still needs it, and package files may reference it
48514
48598
  - Work from \`.gtd/ARCHITECTURE.md\` if you wrote it earlier this
48515
48599
  conversation, otherwise read it (the converged technical
48516
- plan). It already lists an ordered set of concerns with every
48517
- merge/split judgement made — this turn is a mechanical
48518
- write-out, not a planning one. A \`## Merged Concerns\` heading
48519
- there records those merges, never a concern of its own: write
48520
- no package file for it
48521
- - Write one package file per concern, in the settled order,
48522
- under \`.gtd/packages/\` (e.g. \`.gtd/packages/01-name.md\`,
48523
- \`02-name.md\`, ...), each carrying that concern's
48524
- requirement(s) — both, independently, if merged — its
48525
- independent tasks, and each task's acceptance criteria as
48526
- \`- [ ]\` checkboxes and relevant paths
48527
- - Do not merge or split concerns here — that judgement already
48528
- happened; carry the settled grouping over verbatim. No
48529
- package file may reference any other \`.gtd/\` file
48530
- - Once written, delete \`.gtd/ARCHITECTURE.md\`. Leave everything
48531
- uncommitted and finish
48600
+ plan). It already lists the concerns, interfaces, e2e scenarios
48601
+ and unit tests — this turn groups them into packages. A
48602
+ \`## Merged Concerns\` heading there records merges, never a
48603
+ concern of its own: write no package file for it
48604
+ - A package is a consecutive run of \`## Unit Tests\` entries that go
48605
+ green together on the fast suite, at most ~8 unit-test entries
48606
+ each. Keep the merge rule: concerns whose footprints centre on the
48607
+ same files stay in one package unless the later one only consumes
48608
+ an interface the earlier one creates. Prefer fewer, larger
48609
+ packages. Order them so each stays green on the fast suite —
48610
+ interface-introducing packages first
48611
+ - Write the packages under \`.gtd/packages/\`, each carrying its
48612
+ requirement(s) — both, independently, if merged — its tasks, and
48613
+ each task's acceptance criteria as \`- [ ]\` checkboxes and
48614
+ relevant paths:
48615
+ - \`.gtd/packages/00-e2e-scenarios.md\` — package 0: it writes
48616
+ \`## E2E Scenarios\` as tests in the repository's own e2e
48617
+ framework and leaves them red. Write no such file when
48618
+ \`## E2E Scenarios\` says "No e2e change."
48619
+ - \`01-…\` onward — the unit-test packages. A requirement no test
48620
+ covers (docs, README, deletions, config) becomes a chore
48621
+ package, or a task of one; chore packages declare no tests
48622
+ - the last numbered package is the wiring package: CLI entry,
48623
+ workflow composition, config. A red e2e after it is a bug, not
48624
+ missing work
48625
+ - Every package with tests has a \`## Tests\` section, one line per
48626
+ declared test, copied from the architecture entries:
48627
+ \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
48628
+ none
48629
+ - Leave everything uncommitted and finish
48532
48630
  `;
48533
48631
  const architectureGateAnswerMessage = () => `This gate stops on every process, even with no open question, so
48534
48632
  you can discuss \`.gtd/ARCHITECTURE.md\`; changing nothing and re-running
@@ -48557,7 +48655,7 @@ What each change does next (then run \`gtd land\`):
48557
48655
  `;
48558
48656
  const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
48559
48657
  - The only state file this turn may write is \`.gtd/SATISFIED.md\`;
48560
- never delete the package file (the spec-review gate reads it
48658
+ never delete the package file (the declared-tests guard reads it
48561
48659
  after you)
48562
48660
  - The package to implement is \`${pkg}\`
48563
48661
  - First check its acceptance criteria against the current tree —
@@ -48566,6 +48664,13 @@ const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
48566
48664
  with each criterion's concrete evidence (commit, file, or
48567
48665
  symbol), change nothing else, and finish. Otherwise implement
48568
48666
  normally and skip that file
48667
+ - Write the tests the package declares under \`## Tests\` first,
48668
+ then the code that makes them pass; the turn is refused unless
48669
+ every declared test is in your diff
48670
+ - Package 0 (\`.gtd/packages/00-e2e-scenarios.md\`) writes the scenarios verbatim
48671
+ from \`## E2E Scenarios\` of \`.gtd/ARCHITECTURE.md\`, with only
48672
+ step code that typechecks, and leaves them red — no
48673
+ implementation
48569
48674
  - Implement every task the package describes, no more, no less,
48570
48675
  fanning independent ones out to parallel subagents where your
48571
48676
  harness supports it; leave other package files untouched
@@ -48582,15 +48687,6 @@ ${fixFeedbackPrompt}
48582
48687
  then legitimately report itself already satisfied later
48583
48688
  - Leave everything uncommitted and finish your turn — do not commit
48584
48689
  `;
48585
- const packagesItemFixSpecPrompt = (pkg) => `${stateFileRules}
48586
- - The only state file this turn touches is
48587
- \`.gtd/SPEC_FEEDBACK.md\` — address it, then delete it
48588
- - Read it (the reviewer's concerns) and the package spec
48589
- (\`${pkg}\`), then fix the code to
48590
- resolve every concern
48591
- - Delete \`.gtd/SPEC_FEEDBACK.md\` once resolved; leave everything
48592
- else uncommitted and finish your turn
48593
- `;
48594
48690
  const healthJudgeMessage = () => `The check is still red, and this isn't the first round —
48595
48691
  \`.gtd/FEEDBACK.md\` holds this round's output, and the previous round's
48596
48692
  is in history. Run \`gtd judge answer\` and pipe a
@@ -48628,45 +48724,22 @@ Edit \`.gtd/ESCALATION.md\` with fresh instructions for the next fix
48628
48724
  turn, or land untouched to give it one more attempt at the same
48629
48725
  analysis.
48630
48726
  `;
48631
- const packagesItemSpecPreMessage = () => `Judging whether the code already satisfies each requirement in the
48632
- package spec, before spending a full review turn. Run \`gtd judge
48633
- answer\` and pipe a verdict per section — or land untouched to run
48634
- the full review (the conservative default; a skipped judgment
48635
- never suppresses anything).
48727
+ const buildContextPrompt = (built) => {
48728
+ const list = (paths) => paths.length === 0 ? " (none)" : paths.map((path) => ` - ${path}`).join("\n");
48729
+ return `- Read \`.gtd/ARCHITECTURE.md\` — the plan every package was built from
48730
+ - A red scenario that is NEW in this plan likely means missing wiring
48731
+ between packages; new scenarios:
48732
+ ${list(built.scenarios.added)}
48733
+ - A red CHANGED or existing scenario or test is a regression a package
48734
+ introduced; scenarios this plan changed:
48735
+ ${list(built.scenarios.changed)}
48736
+ - Each package's commits, to bisect a break:
48737
+ ${built.ranges.map((r) => ` git log --oneline ${r.from}..${r.to} # ${r.pkg}`).join("\n")}
48636
48738
  `;
48637
- /** The sections a pre-judge could not clear, when it cleared the rest. */
48638
- const specScope = (failing) => failing.length > 0 ? `- A pre-judge already found the other sections satisfied. Confine
48639
- your review to only these sections:
48640
- ${failing.map((title) => ` - ${title}\n`).join("")}` : "";
48641
- const packagesItemSpecReviewPrompt = (pkg, failing = []) => `${styleBlock}
48642
-
48643
- You are reviewing a freshly-built work package against its own
48644
- spec.
48645
-
48646
- ${stateFileRules}
48647
- - The only state file this turn touches is
48648
- \`.gtd/SPEC_FEEDBACK.md\` — write it only when you find problems
48649
- - The package spec is \`${pkg}\`
48650
- ${specScope(failing)}- Verify the implementation against it: tasks done, criteria
48651
- met, code sound and consistent with the codebase. No diff is
48652
- given — read the range yourself, from \`${start()}\`
48653
- to the working tree, process-wide (it can span earlier
48654
- packages)
48655
- - You own that bar; nothing downstream re-weighs your findings
48656
- - Write nothing when the package fully satisfies its spec —
48657
- silence is your approval. Otherwise write
48658
- \`.gtd/SPEC_FEEDBACK.md\` listing what would violate the spec if
48659
- it shipped unaddressed, specific enough to act on, each as its
48660
- own \`## \` heading
48661
- - Never fix anything yourself and never delete the package
48662
- file — a later step owns that
48663
- `;
48664
- const specReviewerSystem = () => `${specReviewerPersona}
48665
-
48666
- ${agentConduct}`;
48667
- const buildFixPrompt = () => `${stateFileRules}
48739
+ };
48740
+ const buildFixPrompt = (built) => `${stateFileRules}
48668
48741
  ${fixFeedbackPrompt}
48669
- - Leave everything uncommitted — do not commit
48742
+ ${built === void 0 ? "" : buildContextPrompt(built)}- Leave everything uncommitted — do not commit
48670
48743
  `;
48671
48744
  const finisherSystem = () => `${finisherPersona}
48672
48745
 
@@ -48999,6 +49072,20 @@ ${it.processCostByModel.map((m) => `- ${m.model}: ${m.cost}\n`).join("")}
48999
49072
  Print the closing message and stop — this writes nothing itself.
49000
49073
  `;
49001
49074
  };
49075
+ const scenarioWordingMessage = (at, drifted) => `The e2e scenario wording was frozen when package 0 landed, and the last
49076
+ turn changed it. Removed lines are marked \`-\`, added ones \`+\`:
49077
+
49078
+ ${drifted.map(({ path, lines }) => `${path}\n${lines.join("\n")}`).join("\n\n")}
49079
+
49080
+ To accept the change, land untouched.
49081
+
49082
+ To reject it, run
49083
+
49084
+ git checkout ${at} -- ${drifted.map(({ path }) => quote(path)).join(" ")}
49085
+
49086
+ and land. Restoring only some of the files is a partial accept: every file
49087
+ still differing from the frozen wording is accepted.
49088
+ `;
49002
49089
  //#endregion
49003
49090
  //#region src/workflows/steps.ts
49004
49091
  const FEEDBACK = ".gtd/FEEDBACK.md";
@@ -49007,7 +49094,6 @@ const REQUIREMENTS = ".gtd/REQUIREMENTS.md";
49007
49094
  const ARCHITECTURE = ".gtd/ARCHITECTURE.md";
49008
49095
  const REVIEW = ".gtd/REVIEW.md";
49009
49096
  const QUALITY = ".gtd/QUALITY.md";
49010
- const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md";
49011
49097
  const planner = () => env.plannerModel ?? "";
49012
49098
  const coder = () => env.coderModel ?? "";
49013
49099
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
@@ -49055,20 +49141,7 @@ const fixSuite = () => agentWithSkills("fix.suite.fixing", packagesItemFixSuiteP
49055
49141
  model: coder(),
49056
49142
  system: builderSystem()
49057
49143
  });
49058
- const fixSpec = (pkg) => agentWithSkills("fix.spec.fixing", packagesItemFixSpecPrompt(pkg), {
49059
- label: "Fixing review feedback",
49060
- file: SPEC_FEEDBACK,
49061
- model: coder(),
49062
- system: builderSystem()
49063
- });
49064
- /** Review `pkg` against its spec, focused on the `failing` sections the pre-judge could not clear. */
49065
- const reviewPackage = (pkg, failing = []) => agentWithSkills("spec.review", packagesItemSpecReviewPrompt(pkg, failing), {
49066
- label: "Reviewing the package",
49067
- model: planner(),
49068
- system: specReviewerSystem(),
49069
- allowEmpty: true
49070
- });
49071
- const fix = () => agentWithSkills("fix", buildFixPrompt(), {
49144
+ const fix = (built) => agentWithSkills("fix", buildFixPrompt(built), {
49072
49145
  label: "Fixing the check",
49073
49146
  file: FEEDBACK,
49074
49147
  model: coder(),
@@ -49176,11 +49249,29 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
49176
49249
  system: reviewerSystem(),
49177
49250
  allowEmpty: true
49178
49251
  });
49252
+ //#endregion
49253
+ //#region src/workflows/health.ts
49254
+ /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
49255
+ const SETUP = ".gtd/SETUP.md";
49256
+ 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.";
49257
+ /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
49258
+ const fastSuiteGuard = () => (env.fastTestCommand ?? "").trim() === "" ? [
49259
+ `mkdir -p ${quote(dirname(SETUP))}`,
49260
+ `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
49261
+ `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
49262
+ "exit 0"
49263
+ ] : [`rm -f ${quote(SETUP)}`];
49179
49264
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
49180
- const baseline = (name, label = "Checking the baseline") => check(name, env.testCommand ?? "", {
49181
- report: FEEDBACK,
49182
- label
49183
- });
49265
+ const baseline = async (name, label = "Checking the baseline") => {
49266
+ for (;;) {
49267
+ const green = await check(name, env.testCommand ?? "", {
49268
+ report: FEEDBACK,
49269
+ label,
49270
+ preamble: fastSuiteGuard()
49271
+ });
49272
+ if (read(".gtd/SETUP.md") === void 0) return green;
49273
+ }
49274
+ };
49184
49275
  /**
49185
49276
  * Hand a red suite that repeated attempts could not fix to a person: an
49186
49277
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
@@ -49219,6 +49310,15 @@ const sameFailure = async (previous, current) => {
49219
49310
  });
49220
49311
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
49221
49312
  };
49313
+ const runSuite = (options) => {
49314
+ const fast = options.suite === "fast";
49315
+ return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
49316
+ report: FEEDBACK,
49317
+ label: "Running checks",
49318
+ sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []],
49319
+ ...fast ? { preamble: fastSuiteGuard() } : {}
49320
+ });
49321
+ };
49222
49322
  /**
49223
49323
  * Loop until the suite is green: check, and on red run `fix` — escalating
49224
49324
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
@@ -49229,11 +49329,9 @@ const healthy = async (fix, options = {}) => {
49229
49329
  let fixes = options.fixesSoFar ?? 0;
49230
49330
  let previous;
49231
49331
  for (;;) {
49232
- if (await check("health.check", env.testCommand ?? "", {
49233
- report: ".gtd/FEEDBACK.md",
49234
- label: "Running checks",
49235
- sweepOnGreen: [".gtd/ESCALATION.md"]
49236
- })) {
49332
+ const green = await runSuite(options);
49333
+ if (options.suite === "fast" && read(".gtd/SETUP.md") !== void 0) continue;
49334
+ if (green) {
49237
49335
  escalations.rounds = 0;
49238
49336
  return;
49239
49337
  }
@@ -49257,324 +49355,184 @@ const gate = (name, message) => scope(name, async () => {
49257
49355
  });
49258
49356
  });
49259
49357
  //#endregion
49260
- //#region src/workflows/diff.ts
49261
- const CONTEXT = 3;
49262
- const TOO_LARGE_LINES = 1500;
49263
- const LOCKFILES = /* @__PURE__ */ new Set([
49264
- "package-lock.json",
49265
- "npm-shrinkwrap.json",
49266
- "yarn.lock",
49267
- "pnpm-lock.yaml",
49268
- "bun.lock",
49269
- "bun.lockb",
49270
- "Cargo.lock",
49271
- "composer.lock",
49272
- "Gemfile.lock",
49273
- "poetry.lock",
49274
- "uv.lock",
49275
- "Pipfile.lock",
49276
- "go.sum",
49277
- "flake.lock",
49278
- "mix.lock",
49279
- "pubspec.lock",
49280
- "Podfile.lock",
49281
- "gradle.lockfile"
49282
- ]);
49283
- const GENERATED_GLOBS = [
49284
- "dist/**",
49285
- "build/**",
49286
- "out/**",
49287
- "coverage/**",
49288
- "node_modules/**",
49289
- "vendor/**",
49290
- ".turbo/**",
49291
- "**/__snapshots__/**",
49292
- "**/*.snap",
49293
- "**/*.min.js",
49294
- "**/*.min.css",
49295
- "**/*.map"
49296
- ];
49297
- const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
49298
- "png",
49299
- "jpg",
49300
- "jpeg",
49301
- "gif",
49302
- "webp",
49303
- "ico",
49304
- "pdf",
49305
- "zip",
49306
- "gz",
49307
- "tar",
49308
- "woff",
49309
- "woff2",
49310
- "ttf",
49311
- "otf",
49312
- "mp4",
49313
- "wasm",
49314
- "bin",
49315
- "exe",
49316
- "so",
49317
- "dylib"
49318
- ]);
49319
- const globCache = /* @__PURE__ */ new Map();
49320
- const compileGlob = (glob) => {
49321
- let pattern = "^";
49322
- let i = 0;
49323
- while (i < glob.length) {
49324
- const char = glob[i];
49325
- if (char !== "*") {
49326
- pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
49327
- i += 1;
49328
- } else if (glob[i + 1] !== "*") {
49329
- pattern += "[^/]*";
49330
- i += 1;
49331
- } else if (glob[i + 2] === "/") {
49332
- pattern += "(?:.*/)?";
49333
- i += 3;
49358
+ //#region src/workflows/scenarios.ts
49359
+ const DOC_STRING = /^(\s*)("""|```)/;
49360
+ /**
49361
+ * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
49362
+ * dropped — except inside a doc string, whose content is test input: kept
49363
+ * whole, indented relative to its opening delimiter (as Gherkin reads it).
49364
+ */
49365
+ const scenarioText = (source) => {
49366
+ const out = [];
49367
+ let doc;
49368
+ for (const line of source.split("\n")) {
49369
+ const delimiter = DOC_STRING.exec(line);
49370
+ if (doc === void 0 && delimiter !== null) {
49371
+ doc = {
49372
+ indent: delimiter[1].length,
49373
+ fence: delimiter[2]
49374
+ };
49375
+ out.push(line.trim());
49376
+ } else if (doc !== void 0 && line.trim().startsWith(doc.fence)) {
49377
+ doc = void 0;
49378
+ out.push(line.trim());
49379
+ } else if (doc !== void 0) {
49380
+ const lead = line.length - line.trimStart().length;
49381
+ out.push(line.slice(Math.min(lead, doc.indent)).trimEnd());
49334
49382
  } else {
49335
- pattern += ".*";
49336
- i += 2;
49383
+ const trimmed = line.trim();
49384
+ if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed);
49337
49385
  }
49338
49386
  }
49339
- return new RegExp(`${pattern}$`);
49387
+ return out.join("\n");
49340
49388
  };
49341
- const matchesGlob = (path, glob) => {
49342
- let regex = globCache.get(glob);
49343
- if (regex === void 0) {
49344
- regex = compileGlob(glob);
49345
- globCache.set(glob, regex);
49346
- }
49347
- return regex.test(path);
49389
+ const textOf = (path) => {
49390
+ const source = read(path);
49391
+ return source === void 0 ? void 0 : scenarioText(source);
49348
49392
  };
49349
- const extensionOf = (path) => {
49350
- const base = path.split("/").pop() ?? path;
49351
- const dot = base.lastIndexOf(".");
49352
- return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
49353
- };
49354
- const isBinary = (change) => BINARY_EXTENSIONS.has(extensionOf(change.path)) || (change.before?.includes("\0") ?? false) || (change.after?.includes("\0") ?? false);
49355
- 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);
49356
- /** `changes` with lockfiles, generated trees and binaries dropped. */
49357
- const filterChanges = (changes) => changes.filter((change) => !isExcluded(change));
49358
- const linesOf = (content) => {
49359
- if (content === void 0 || content === "") return [];
49360
- const lines = content.split("\n");
49361
- if (lines[lines.length - 1] === "") lines.pop();
49362
- return lines;
49393
+ /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
49394
+ const freezeScenarios = (paths, previous) => {
49395
+ if (paths.length === 0) return previous;
49396
+ const texts = { ...previous?.texts };
49397
+ for (const path of paths) {
49398
+ const text = textOf(path);
49399
+ if (text !== void 0) texts[path] = text;
49400
+ }
49401
+ return Object.keys(texts).length === 0 ? previous : {
49402
+ at: head(),
49403
+ texts
49404
+ };
49363
49405
  };
49364
- /** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
49406
+ /** Paths whose wording differs from the snapshot (a deleted file counts). */
49407
+ const driftedScenarios = (frozen) => Object.entries(frozen.texts).filter(([path, text]) => textOf(path) !== text).map(([path]) => path);
49408
+ const lines = (text) => text === void 0 || text === "" ? [] : text.split("\n");
49409
+ /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
49365
49410
  const lcsTable = (a, b) => {
49366
- const n = a.length;
49367
- const m = b.length;
49368
- const dp = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
49369
- 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]);
49370
- return dp;
49371
- };
49372
- /** Walk an LCS table into an edit script, favoring a deletion on a tie. */
49373
- const backtrack = (a, b, dp) => {
49374
- const ops = [];
49411
+ const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
49412
+ 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]);
49413
+ return table;
49414
+ };
49415
+ /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
49416
+ const lineDiff = (a, b) => {
49417
+ const table = lcsTable(a, b);
49418
+ const out = [];
49375
49419
  let i = 0;
49376
49420
  let j = 0;
49377
49421
  while (i < a.length && j < b.length) if (a[i] === b[j]) {
49378
- ops.push({
49379
- type: "eq",
49380
- line: a[i]
49381
- });
49382
- i++;
49383
- j++;
49384
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
49385
- ops.push({
49386
- type: "del",
49387
- line: a[i]
49388
- });
49389
49422
  i++;
49390
- } else {
49391
- ops.push({
49392
- type: "add",
49393
- line: b[j]
49394
- });
49395
49423
  j++;
49396
- }
49397
- while (i < a.length) ops.push({
49398
- type: "del",
49399
- line: a[i++]
49400
- });
49401
- while (j < b.length) ops.push({
49402
- type: "add",
49403
- line: b[j++]
49404
- });
49405
- return ops;
49406
- };
49407
- /** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
49408
- const lcsOps = (a, b) => backtrack(a, b, lcsTable(a, b));
49409
- const commonAffixes = (before, after) => {
49410
- const maxPrefix = Math.min(before.length, after.length);
49411
- let prefix = 0;
49412
- while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++;
49413
- const maxSuffix = maxPrefix - prefix;
49414
- let suffix = 0;
49415
- while (suffix < maxSuffix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
49416
- return {
49417
- prefix,
49418
- suffix
49419
- };
49424
+ } else if (table[i + 1][j] >= table[i][j + 1]) out.push(`- ${a[i++]}`);
49425
+ else out.push(`+ ${b[j++]}`);
49426
+ return [
49427
+ ...out,
49428
+ ...a.slice(i).map((l) => `- ${l}`),
49429
+ ...b.slice(j).map((l) => `+ ${l}`)
49430
+ ];
49420
49431
  };
49421
- const buildHunks = (ops) => {
49422
- const beforeLineAt = [];
49423
- const afterLineAt = [];
49424
- let bl = 1;
49425
- let al = 1;
49426
- for (const op of ops) {
49427
- beforeLineAt.push(bl);
49428
- afterLineAt.push(al);
49429
- if (op.type !== "add") bl++;
49430
- if (op.type !== "del") al++;
49431
- }
49432
- const ranges = [];
49433
- ops.forEach((op, idx) => {
49434
- if (op.type === "eq") return;
49435
- const start = Math.max(0, idx - CONTEXT);
49436
- const end = Math.min(ops.length - 1, idx + CONTEXT);
49437
- const last = ranges[ranges.length - 1];
49438
- if (last !== void 0 && start <= last[1] + 1) last[1] = Math.max(last[1], end);
49439
- else ranges.push([start, end]);
49440
- });
49441
- return ranges.map(([start, end]) => {
49442
- const slice = ops.slice(start, end + 1);
49443
- return {
49444
- beforeStart: beforeLineAt[start],
49445
- beforeCount: slice.filter((op) => op.type !== "add").length,
49446
- afterStart: afterLineAt[start],
49447
- afterCount: slice.filter((op) => op.type !== "del").length,
49448
- lines: slice.map((op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`)
49449
- };
49432
+ const wordingDrift = (frozen, path) => ({
49433
+ path,
49434
+ lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path)))
49435
+ });
49436
+ /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
49437
+ const holdWording = async (frozen) => {
49438
+ if (frozen === void 0) return;
49439
+ const drifted = driftedScenarios(frozen);
49440
+ if (drifted.length === 0) return;
49441
+ await human("scenario-wording", {
49442
+ message: scenarioWordingMessage(frozen.at, drifted.map((path) => wordingDrift(frozen, path))),
49443
+ label: "Scenario wording changed",
49444
+ acceptClean: true,
49445
+ base: frozen.at
49450
49446
  });
49447
+ const accepted = driftedScenarios(frozen);
49448
+ if (accepted.length === 0) return;
49449
+ for (const path of accepted) {
49450
+ const text = textOf(path);
49451
+ if (text === void 0) delete frozen.texts[path];
49452
+ else frozen.texts[path] = text;
49453
+ }
49454
+ frozen.at = head();
49451
49455
  };
49452
- const renderHunk = (hunk) => [`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`, ...hunk.lines];
49453
- /** One file's unified diff, or a summary line when its changed region is too large to inline. */
49454
- const renderFileDiff = (change) => {
49455
- const before = linesOf(change.before);
49456
- const after = linesOf(change.after);
49457
- const { prefix, suffix } = commonAffixes(before, after);
49458
- const beforeMiddle = before.slice(prefix, before.length - suffix);
49459
- const afterMiddle = after.slice(prefix, after.length - suffix);
49460
- if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`;
49461
- const prefixOps = before.slice(0, prefix).map((line) => ({
49462
- type: "eq",
49463
- line
49464
- }));
49465
- const suffixOps = before.slice(before.length - suffix).map((line) => ({
49466
- type: "eq",
49467
- line
49468
- }));
49469
- const ops = [
49470
- ...prefixOps,
49471
- ...lcsOps(beforeMiddle, afterMiddle),
49472
- ...suffixOps
49473
- ];
49474
- const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`;
49475
- const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`;
49476
- return [
49477
- `--- ${beforePath}`,
49478
- `+++ ${afterPath}`,
49479
- ...buildHunks(ops).flatMap(renderHunk)
49480
- ].join("\n");
49481
- };
49482
- const omissionTrailer = (paths) => `${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`;
49483
- /** 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. */
49484
- const sizeWithTrailer = (files, keepCount) => {
49485
- let used = 0;
49486
- for (let i = 0; i < keepCount; i++) used += Buffer.byteLength(files[i].text, "utf8") + (i > 0 ? 1 : 0);
49487
- if (files.length - keepCount === 0) return used;
49488
- const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path));
49489
- return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8");
49490
- };
49491
- /**
49492
- * The filtered `changes`, rendered as one unified diff (files in path order)
49493
- * and bounded to `capBytes`: over the cap, whole files are dropped from the
49494
- * end — never bytes off a hunk — and named in a trailer line, so the
49495
- * judge is told what it did not see rather than handed a silently short diff.
49496
- * The trailer itself is sized against the cap along with the files it keeps,
49497
- * so a package with many touched files can never push the total past it.
49498
- */
49499
- const packageDiff = (changes, capBytes) => {
49500
- const files = filterChanges(changes).slice().sort((a, b) => a.path.localeCompare(b.path)).map((change) => ({
49501
- path: change.path,
49502
- text: renderFileDiff(change)
49503
- }));
49504
- let keepCount = files.length;
49505
- while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--;
49506
- const dropped = files.slice(keepCount).map((f) => f.path);
49507
- return [...files.slice(0, keepCount).map((f) => f.text), ...dropped.length > 0 ? [omissionTrailer(dropped)] : []].join("\n");
49456
+ /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
49457
+ const guarded = (frozen, turn, within) => async () => {
49458
+ await turn();
49459
+ if (within === void 0) await holdWording(frozen);
49460
+ else await scope(within, () => holdWording(frozen));
49508
49461
  };
49509
49462
  //#endregion
49510
49463
  //#region src/workflows/packages.ts
49511
- const MAX_SECTIONS = 8;
49512
- const DIFF_KEY = "diff";
49513
- const sectionQuestion = (id, title) => ({
49514
- id,
49515
- primitive: "noul",
49516
- instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
49517
- 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."
49518
- });
49519
- /**
49520
- * Review one freshly built package against its spec: a pre-judge per
49521
- * section, then an agent review of the sections it did not confidently
49522
- * clear. `since` is the commit the package's own build started from — the
49523
- * pre-judge's evidence is a diff over exactly that range, never an earlier
49524
- * package's commits. Resolves `true` when approved.
49525
- */
49526
- const specReview = async (pkg, since) => {
49527
- const found = sectionBodies(read(pkg) ?? "");
49528
- const titles = found.map((section) => section.title);
49529
- const judged = titles.length > 0 && titles.length <= MAX_SECTIONS;
49530
- const evidence = {};
49531
- const questions = [];
49532
- if (judged) {
49533
- found.forEach(({ title, body }, i) => {
49534
- evidence[`section-${i + 1}`] = body;
49535
- questions.push(sectionQuestion(`section-${i + 1}`, title));
49464
+ /** Package 0: the e2e scenarios, written red. */
49465
+ const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md";
49466
+ const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
49467
+ /** The `## Tests` entries of a package file; malformed lines are ignored. */
49468
+ const declaredTests = (packageText) => {
49469
+ const found = [];
49470
+ let inTests = false;
49471
+ for (const line of packageText.split("\n")) if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line);
49472
+ else if (inTests) {
49473
+ const match = TEST_LINE.exec(line);
49474
+ if (match) found.push({
49475
+ level: match[1],
49476
+ path: match[2]
49536
49477
  });
49537
- const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1));
49538
- evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes);
49539
- }
49540
- const { answers, truncated } = await judge("spec.pre", {
49541
- questions,
49542
- evidence,
49543
- message: packagesItemSpecPreMessage(),
49544
- label: "Judging spec coverage"
49545
- });
49546
- const clearMinP = numeric(vars.specPreJudge, Infinity);
49547
- const failing = titles.filter((_, i) => {
49548
- const id = `section-${i + 1}`;
49549
- return !judged || truncated.includes(id) || truncated.includes(DIFF_KEY) || !answered(answers[id], "yes", clearMinP);
49550
- });
49551
- if (titles.length > 0 && failing.length === 0) return true;
49552
- await reviewPackage(pkg, failing);
49553
- if (wrote(".gtd/SPEC_FEEDBACK.md")) return false;
49554
- if (changes(".gtd/SPEC_FEEDBACK.md").length > 0 || changes().length === 0) return true;
49555
- return refuse("gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve");
49556
- };
49557
- /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
49558
- const packageItem = async (pkg) => {
49559
- const since = head();
49560
- await build(pkg);
49561
- for (;;) {
49562
- await healthy(fixSuite);
49563
- if (await specReview(pkg, since)) break;
49564
- await fixSpec(pkg);
49565
49478
  }
49566
- await run$3("closing", removeScript([
49479
+ return found;
49480
+ };
49481
+ /**
49482
+ * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
49483
+ * diff since `since`. The package text is read as of `since`, so a builder
49484
+ * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
49485
+ * written, existing in the tree suffices.
49486
+ */
49487
+ const requireDeclaredTests = (pkg, since) => {
49488
+ const range = changesSince(since);
49489
+ const text = range.get(pkg)?.before ?? read(pkg) ?? "";
49490
+ const satisfied = read(".gtd/SATISFIED.md") !== void 0;
49491
+ 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);
49492
+ if (missing.length === 0) return;
49493
+ 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")}`);
49494
+ };
49495
+ /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
49496
+ const packageItem = async (pkg, frozen) => {
49497
+ const from = head();
49498
+ const declared = (turn) => async () => {
49499
+ await turn();
49500
+ requireDeclaredTests(pkg, from);
49501
+ };
49502
+ await guarded(frozen, declared(() => build(pkg)))();
49503
+ await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" });
49504
+ await run$3("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), { label: "Closing out the package" });
49505
+ return {
49567
49506
  pkg,
49568
- SPEC_FEEDBACK,
49569
- ".gtd/SATISFIED.md",
49570
- ARCHITECTURE
49571
- ]), { label: "Closing out the package" });
49507
+ from,
49508
+ to: head()
49509
+ };
49572
49510
  };
49573
49511
  /** The first queued package — the one a package step works on. */
49574
49512
  const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
49513
+ const outsideGtd = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
49575
49514
  /** Build every package file under `.gtd/packages/`, in name order. */
49576
- const packages = () => scope("packages", async () => {
49577
- for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
49515
+ const packages = (carried) => scope("packages", async () => {
49516
+ let frozen = carried;
49517
+ const ranges = [];
49518
+ let touched = [];
49519
+ for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) {
49520
+ const range = await scope("item", () => packageItem(pkg, pkg === ".gtd/packages/00-e2e-scenarios.md" ? void 0 : frozen));
49521
+ ranges.push(range);
49522
+ if (pkg === ".gtd/packages/00-e2e-scenarios.md") {
49523
+ touched = changesSince(range.from).filter((c) => outsideGtd(c.path) && c.status !== "deleted");
49524
+ frozen = freezeScenarios(touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path), frozen);
49525
+ }
49526
+ }
49527
+ const paths = (status) => touched.filter((c) => c.status === status).map((c) => c.path);
49528
+ return {
49529
+ ranges,
49530
+ scenarios: {
49531
+ added: paths("added"),
49532
+ changed: paths("modified")
49533
+ },
49534
+ frozen
49535
+ };
49578
49536
  });
49579
49537
  //#endregion
49580
49538
  //#region src/workflows/planning.ts
@@ -49607,30 +49565,8 @@ const architecture = () => scope("architecture", async () => {
49607
49565
  } while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
49608
49566
  await decompose();
49609
49567
  if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
49568
+ 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");
49610
49569
  });
49611
- /** A package file name from a plan's first heading. */
49612
- const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "package";
49613
- /** Whether the settled plan needs its own architecture pass, or goes straight to one package. */
49614
- const architecturePass = async () => {
49615
- const { answers, truncated } = await judge("architecture-pre", {
49616
- questions: [{
49617
- id: "architectureWarranted",
49618
- primitive: "noul",
49619
- 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?",
49620
- criteria: "Answer yes if uncertain; a trivial, single-concern, mechanical plan with no real design decision answers no."
49621
- }],
49622
- evidence: { requirements: read(".gtd/REQUIREMENTS.md") ?? "" },
49623
- message: architecturePreMessage(),
49624
- label: "Judging whether this plan warrants an architecture pass"
49625
- });
49626
- const skip = truncated.length === 0 && answered(answers.architectureWarranted, "no", numeric(vars.architectureSkipMinP, Infinity));
49627
- const plan = read(REQUIREMENTS);
49628
- if (skip && plan !== void 0) {
49629
- await run$3("architecture-promote", moveScript(REQUIREMENTS, `.gtd/packages/01-${slug(sections(plan)[0] ?? "")}.md`), { label: "Promoting the plan straight to a package" });
49630
- return;
49631
- }
49632
- await architecture();
49633
- };
49634
49570
  //#endregion
49635
49571
  //#region src/workflows/review.ts
49636
49572
  /** Split a `qualityReviews` value into its lenses: on `,`, trimmed, blanks dropped. */
@@ -49645,10 +49581,10 @@ const qualityLap = async () => {
49645
49581
  return (read(".gtd/QUALITY.md") ?? "").length > 0 ? "findings" : "clean";
49646
49582
  };
49647
49583
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
49648
- const fixQualityFindings = async (escalations) => {
49584
+ const fixQualityFindings = async (escalations, frozen) => {
49649
49585
  for (let turns = 0; turns < 3; turns++) {
49650
- await fixQuality();
49651
- if (changes(".gtd/QUALITY.md").some((c) => c.status === "deleted")) return true;
49586
+ await guarded(frozen, fixQuality)();
49587
+ if (read(".gtd/QUALITY.md") === void 0) return true;
49652
49588
  }
49653
49589
  await escalation(escalations);
49654
49590
  return false;
@@ -49750,8 +49686,8 @@ const routeNotes = async (notes, r) => {
49750
49686
  answeredAt = head();
49751
49687
  }
49752
49688
  if (nits.length > 0) {
49753
- await fixNits(nits);
49754
- await healthy(fix, { escalations: r.escalations });
49689
+ await guarded(r.frozen, () => fixNits(nits), "review")();
49690
+ await healthy(guarded(r.frozen, fix, "review"), { escalations: r.escalations });
49755
49691
  }
49756
49692
  if (edits.length > 0) {
49757
49693
  await r.close();
@@ -49769,7 +49705,7 @@ const routeNotes = async (notes, r) => {
49769
49705
  reviewed: answeredAt
49770
49706
  };
49771
49707
  };
49772
- const finish = async (reviewed, collectedAt, escalations) => {
49708
+ const finish = async (reviewed, collectedAt, escalations, frozen) => {
49773
49709
  const round = head();
49774
49710
  const since = changesSince(reviewed);
49775
49711
  const edited = since.filter(isCodeEdit);
@@ -49802,18 +49738,19 @@ const finish = async (reviewed, collectedAt, escalations) => {
49802
49738
  return routeNotes(notes, {
49803
49739
  round,
49804
49740
  escalations,
49741
+ frozen,
49805
49742
  close,
49806
49743
  outcome,
49807
49744
  unfolded
49808
49745
  });
49809
49746
  };
49810
49747
  /** 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. */
49811
- const reviewOnce = async (base, carry, escalations) => {
49748
+ const reviewOnce = async (base, carry, escalations, frozen) => {
49812
49749
  await reviewing(base, carry);
49813
49750
  const risks = reviewRisks(read(".gtd/REVIEW.md") ?? "");
49814
49751
  if (risks.length === 0) return;
49815
- await fixRisks(risks);
49816
- await healthy(fix, { escalations });
49752
+ await guarded(frozen, () => fixRisks(risks), "review")();
49753
+ await healthy(guarded(frozen, fix, "review"), { escalations });
49817
49754
  await reviewing(base, carry);
49818
49755
  };
49819
49756
  /**
@@ -49822,17 +49759,17 @@ const reviewOnce = async (base, carry, escalations) => {
49822
49759
  * another planning lap, questions are answered in place, nits fixed in one
49823
49760
  * batch, praise dropped.
49824
49761
  */
49825
- const review = async (base, escalations = { rounds: 0 }) => {
49762
+ const review = async (base, escalations = { rounds: 0 }, frozen) => {
49826
49763
  let carry;
49827
49764
  for (;;) {
49828
- await reviewOnce(base, carry, escalations);
49765
+ await reviewOnce(base, carry, escalations, frozen);
49829
49766
  carry = void 0;
49830
49767
  let reviewed = head();
49831
49768
  let collectedAt;
49832
49769
  for (;;) {
49833
49770
  collectedAt = await converse(base, collectedAt);
49834
49771
  if (collectedAt === "missing") break;
49835
- const result = await finish(reviewed, collectedAt, escalations);
49772
+ const result = await finish(reviewed, collectedAt, escalations, frozen);
49836
49773
  if (!("next" in result)) return result;
49837
49774
  if (result.next === "rereview") {
49838
49775
  carry = result.carry;
@@ -49843,14 +49780,20 @@ const review = async (base, escalations = { rounds: 0 }) => {
49843
49780
  }
49844
49781
  };
49845
49782
  /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
49846
- const buildTail = (fixFirst, base) => scope("build", async () => {
49783
+ const buildTail = (fixFirst, base, built) => scope("build", async () => {
49784
+ const frozen = built?.frozen;
49847
49785
  const escalations = { rounds: 0 };
49786
+ const guardedFix = guarded(frozen, () => fix());
49848
49787
  let redFirst = fixFirst;
49788
+ if (!fixFirst) await healthy(guarded(frozen, () => fix(built)), {
49789
+ escalations,
49790
+ sweepOnGreen: [ARCHITECTURE]
49791
+ });
49849
49792
  let lapped = false;
49850
49793
  for (;;) {
49851
49794
  if (redFirst) {
49852
- await fix();
49853
- await healthy(fix, {
49795
+ await guardedFix();
49796
+ await healthy(guardedFix, {
49854
49797
  fixesSoFar: 1,
49855
49798
  escalations
49856
49799
  });
@@ -49858,8 +49801,8 @@ const buildTail = (fixFirst, base) => scope("build", async () => {
49858
49801
  }
49859
49802
  const lap = lapped ? "clean" : await qualityLap();
49860
49803
  lapped = true;
49861
- if (lap === "clean") return review(base, escalations);
49862
- if (await fixQualityFindings(escalations)) await healthy(fix, { escalations });
49804
+ if (lap === "clean") return review(base, escalations, frozen);
49805
+ if (await fixQualityFindings(escalations, frozen)) await healthy(guardedFix, { escalations });
49863
49806
  else redFirst = true;
49864
49807
  }
49865
49808
  });
@@ -49870,14 +49813,13 @@ const defaults = {
49870
49813
  reviewBase: "",
49871
49814
  judgeBudgetBytes: "32768",
49872
49815
  judgeIdenticalMinP: "0.7",
49873
- specPreJudge: "0.9",
49874
49816
  reviewNoteActionable: "0.7",
49875
- architectureSkipMinP: "0.85",
49876
49817
  qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
49877
49818
  };
49878
49819
  /** Environment settings. */
49879
49820
  const envDefaults = {
49880
49821
  testCommand: "npm test",
49822
+ fastTestCommand: "",
49881
49823
  plannerModel: "smart",
49882
49824
  coderModel: "base"
49883
49825
  };
@@ -49893,8 +49835,6 @@ const skills = (vars) => ({
49893
49835
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
49894
49836
  "packages.item": ["test-driven-development", "incremental-implementation"],
49895
49837
  "packages.item.fix.suite": ["debugging-and-error-recovery"],
49896
- "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
49897
- "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
49898
49838
  "packages.item.health": ["debugging-and-error-recovery"],
49899
49839
  build: ["debugging-and-error-recovery"],
49900
49840
  "build.health": ["debugging-and-error-recovery"],
@@ -49905,6 +49845,17 @@ const skills = (vars) => ({
49905
49845
  ...Object.fromEntries(lensesOf(vars.qualityReviews ?? "").map((lens) => [`build.quality.${lens}`, builtInLenses[lens]?.skills ?? [lens]]))
49906
49846
  });
49907
49847
  //#endregion
49848
+ //#region src/workflows/access.ts
49849
+ const access = (vars) => ({
49850
+ design: { write: [] },
49851
+ architecture: { write: [".gtd/REQUIREMENTS.md"] },
49852
+ "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
49853
+ "build.review": { write: [".gtd/REVIEW.md"] },
49854
+ "build.review.fix.nits": {},
49855
+ "build.review.fix.risks": {},
49856
+ ...Object.fromEntries(lensesOf(vars.qualityReviews ?? "").map((lens) => [`build.quality.${lens}`, { write: [] }]))
49857
+ });
49858
+ //#endregion
49908
49859
  //#region src/workflows/unified.ts
49909
49860
  var unified_exports = /* @__PURE__ */ __exportAll({
49910
49861
  ARCHITECTURE: () => ARCHITECTURE,
@@ -49914,14 +49865,15 @@ var unified_exports = /* @__PURE__ */ __exportAll({
49914
49865
  QUALITY: () => QUALITY,
49915
49866
  REQUIREMENTS: () => REQUIREMENTS,
49916
49867
  REVIEW: () => REVIEW,
49917
- SPEC_FEEDBACK: () => SPEC_FEEDBACK,
49868
+ SCENARIO_PACKAGE: () => SCENARIO_PACKAGE,
49869
+ SETUP: () => SETUP,
49870
+ access: () => access,
49918
49871
  afterTail: () => afterTail,
49919
49872
  agentWithSkills: () => agentWithSkills,
49920
49873
  answerProductQuestions: () => answerProductQuestions,
49921
49874
  answerReviewQuestions: () => answerReviewQuestions,
49922
49875
  answerTechnicalQuestions: () => answerTechnicalQuestions,
49923
49876
  architecture: () => architecture,
49924
- architecturePass: () => architecturePass,
49925
49877
  author: () => author,
49926
49878
  awaitReview: () => awaitReview,
49927
49879
  base: () => base,
@@ -49930,24 +49882,29 @@ var unified_exports = /* @__PURE__ */ __exportAll({
49930
49882
  buildTail: () => buildTail,
49931
49883
  builtInLenses: () => builtInLenses,
49932
49884
  collecting: () => collecting,
49885
+ declaredTests: () => declaredTests,
49933
49886
  decompose: () => decompose,
49934
49887
  default: () => unified,
49935
49888
  defaults: () => defaults,
49936
49889
  describeEscalation: () => describeEscalation,
49937
49890
  design: () => design,
49891
+ driftedScenarios: () => driftedScenarios,
49938
49892
  envDefaults: () => envDefaults,
49939
49893
  escalate: () => escalate,
49940
49894
  escalation: () => escalation,
49941
49895
  escalationExhausted: () => escalationExhausted,
49896
+ fastSuiteGuard: () => fastSuiteGuard,
49942
49897
  fix: () => fix,
49943
49898
  fixNits: () => fixNits,
49944
49899
  fixQuality: () => fixQuality,
49945
49900
  fixQualityFindings: () => fixQualityFindings,
49946
49901
  fixRisks: () => fixRisks,
49947
- fixSpec: () => fixSpec,
49948
49902
  fixSuite: () => fixSuite,
49903
+ freezeScenarios: () => freezeScenarios,
49949
49904
  gate: () => gate,
49905
+ guarded: () => guarded,
49950
49906
  healthy: () => healthy,
49907
+ holdWording: () => holdWording,
49951
49908
  lensesOf: () => lensesOf,
49952
49909
  nextPackage: () => nextPackage,
49953
49910
  ordinaryStart: () => ordinaryStart,
@@ -49956,19 +49913,20 @@ var unified_exports = /* @__PURE__ */ __exportAll({
49956
49913
  planAndBuild: () => planAndBuild,
49957
49914
  qualityLap: () => qualityLap,
49958
49915
  reUnwind: () => reUnwind,
49916
+ requireDeclaredTests: () => requireDeclaredTests,
49959
49917
  retryQuestion: () => retryQuestion,
49960
49918
  review: () => review,
49961
49919
  reviewMissing: () => reviewMissing,
49962
- reviewPackage: () => reviewPackage,
49963
49920
  reviewQuality: () => reviewQuality,
49964
49921
  reviewing: () => reviewing,
49922
+ scenarioText: () => scenarioText,
49965
49923
  skills: () => skills,
49966
- specReview: () => specReview,
49967
49924
  steering: () => steering,
49968
49925
  summary: () => summary,
49969
49926
  threads: () => threads,
49970
49927
  triage: () => triage$1,
49971
- unwind: () => unwind
49928
+ unwind: () => unwind,
49929
+ wordingDrift: () => wordingDrift
49972
49930
  });
49973
49931
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
49974
49932
  const unwind = () => {
@@ -49994,11 +49952,13 @@ const reUnwind = async (feedback) => {
49994
49952
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
49995
49953
  const planAndBuild = async (firstBase) => {
49996
49954
  let base = firstBase;
49955
+ let carried;
49997
49956
  for (;;) {
49998
49957
  await design(base);
49999
- await architecturePass();
50000
- await packages();
50001
- const outcome = await buildTail(false, base);
49958
+ await architecture();
49959
+ const built = await packages(carried);
49960
+ carried = built.frozen;
49961
+ const outcome = await buildTail(false, base, built);
50002
49962
  if (outcome.verdict === "signoff") return;
50003
49963
  await reUnwind(outcome);
50004
49964
  base = outcome.base;
@@ -50798,6 +50758,25 @@ const skillsJsonSchema = {
50798
50758
  items: { type: "string" }
50799
50759
  }
50800
50760
  };
50761
+ /** The `access:` shape: scope full name -> `{ read?, write? }` (`compileAccessMap`). An entry REPLACES the scope's access wholesale; the key is unvalidatable here for the same reason as `skills:`. */
50762
+ const accessJsonSchema = {
50763
+ type: "object",
50764
+ description: "Flat scope full-name -> { read?, write? } map of glob arrays. Each entry REPLACES the named scope's declared access wholesale (never merges into it) and reaches every nested scope that sets none of its own; a missing side is unrestricted, [] allows nothing, and {} lifts every restriction. read and write are independent: write grants no read. A key that is not a scope running a turn is a load error listing the known scopes — the schema itself cannot validate a key, only a value's shape.",
50765
+ additionalProperties: {
50766
+ type: "object",
50767
+ additionalProperties: false,
50768
+ properties: {
50769
+ read: {
50770
+ type: "array",
50771
+ items: { type: "string" }
50772
+ },
50773
+ write: {
50774
+ type: "array",
50775
+ items: { type: "string" }
50776
+ }
50777
+ }
50778
+ }
50779
+ };
50801
50780
  /**
50802
50781
  * A real (not `Unknown`) schema, unlike `vars`/`modes`: it is a flat settings
50803
50782
  * struct with no per-mode map to compile, so its JSON Schema derives from the
@@ -50825,7 +50804,8 @@ const ConfigSchema = Struct({
50825
50804
  judge: optional$1(JudgeSchema),
50826
50805
  modes: optional$1(Unknown.annotations({ jsonSchema: modesJsonSchema })),
50827
50806
  ui: optional$1(UiSchema),
50828
- skills: optional$1(Unknown.annotations({ jsonSchema: skillsJsonSchema }))
50807
+ skills: optional$1(Unknown.annotations({ jsonSchema: skillsJsonSchema })),
50808
+ access: optional$1(Unknown.annotations({ jsonSchema: accessJsonSchema }))
50829
50809
  });
50830
50810
  //#endregion
50831
50811
  //#region src/workflow/vars.ts
@@ -50997,6 +50977,33 @@ const compileSkillsMap = (raw) => {
50997
50977
  diagnostics
50998
50978
  };
50999
50979
  };
50980
+ /** A flat `scope full name -> { read?, write? }` map; a malformed value is a load error and is dropped. */
50981
+ const compileAccessMap = (raw) => {
50982
+ const diagnostics = [];
50983
+ if (raw === void 0) return {
50984
+ access: {},
50985
+ diagnostics
50986
+ };
50987
+ if (!isPlainObject$3(raw)) {
50988
+ diagnostics.push(err(["access"], `"access" must be a mapping of scope name -> { read?, write? }, got ${describeType(raw)}`));
50989
+ return {
50990
+ access: {},
50991
+ diagnostics
50992
+ };
50993
+ }
50994
+ const access = Object.create(null);
50995
+ for (const [key, value] of Object.entries(raw)) {
50996
+ const fault = accessShapeFault(value);
50997
+ if (fault === void 0) access[key] = value;
50998
+ else diagnostics.push(err(["access", key], `"access.${key}": ${fault}`));
50999
+ }
51000
+ return {
51001
+ access,
51002
+ diagnostics
51003
+ };
51004
+ };
51005
+ /** The `.gtdrc` `access:` load-error message for a key that is not a known scope. */
51006
+ const unknownAccessKeyMessage = (key, known) => `"access.${key}" is not a scope that runs a turn — known scopes: ${known.join(", ")}`;
51000
51007
  /** The `.gtdrc` `skills:` load-error message for a key that is not a known scope. */
51001
51008
  const unknownSkillsKeyMessage = (key, known) => `"skills.${key}" is not a scope that runs a turn — known scopes: ${known.join(", ")}`;
51002
51009
  const MODE_COMMAND_KEYS = ["format", "validate"];
@@ -51168,6 +51175,20 @@ const compileConfig = (layers) => {
51168
51175
  })));
51169
51176
  }
51170
51177
  const { skills: rcSkills } = compileSkillsMap(mergedConfig["skills"]);
51178
+ const accessKeys = [];
51179
+ for (const layer of layers) {
51180
+ if (!isPlainObject$3(layer.value)) continue;
51181
+ const own = compileAccessMap(layer.value["access"]);
51182
+ diagnostics.push(...own.diagnostics.map((d) => ({
51183
+ ...d,
51184
+ origin: layer.origin
51185
+ })));
51186
+ accessKeys.push(...Object.keys(own.access).map((key) => ({
51187
+ key,
51188
+ origin: layer.origin
51189
+ })));
51190
+ }
51191
+ const { access: rcAccess } = compileAccessMap(mergedConfig["access"]);
51171
51192
  const seeded = Object.fromEntries(BUILT_IN_MODE_NAMES.map((name) => [name, { validate: seededValidateCommand(name) }]));
51172
51193
  return {
51173
51194
  rcVars,
@@ -51179,6 +51200,8 @@ const compileConfig = (layers) => {
51179
51200
  ...ui !== void 0 ? { ui } : {},
51180
51201
  rcSkills,
51181
51202
  skillsKeys,
51203
+ access: rcAccess,
51204
+ accessKeys,
51182
51205
  diagnostics: dedupeDiagnostics(sortDiagnostics(diagnostics, layerOrder))
51183
51206
  };
51184
51207
  };
@@ -58110,6 +58133,9 @@ const load = gen(function* () {
58110
58133
  skills: loaded.skills,
58111
58134
  configuredSkills: compiled.rcSkills,
58112
58135
  skillsKeys: compiled.skillsKeys,
58136
+ access: loaded.access,
58137
+ configuredAccess: compiled.access,
58138
+ accessKeys: compiled.accessKeys,
58113
58139
  skillsOrigin: module?.filepath ?? "(built-in default)",
58114
58140
  initial
58115
58141
  },
@@ -58187,6 +58213,12 @@ const skillsExport = (exports) => {
58187
58213
  const record = checkSkillsRecord(value);
58188
58214
  return () => record;
58189
58215
  };
58216
+ /** Read a module's optional `access` export — a scope full name -> `{ read?, write? }` record, or a function of the vars returning one. */
58217
+ const accessExport = (exports) => {
58218
+ const value = exports["access"] ?? {};
58219
+ if (typeof value === "function") return (vars) => value(vars);
58220
+ return () => value;
58221
+ };
58190
58222
  const optionalFunction = (exports, name) => {
58191
58223
  const value = exports[name];
58192
58224
  if (value === void 0) return void 0;
@@ -58215,6 +58247,7 @@ const fromModule = (exported, origin, dependencyFiles) => {
58215
58247
  envDefaults,
58216
58248
  steering: stringRecord(exports, "steering"),
58217
58249
  skills: skillsExport(exports),
58250
+ access: accessExport(exports),
58218
58251
  summary: optionalFunction(exports, "summary"),
58219
58252
  base: optionalFunction(exports, "base"),
58220
58253
  origin,
@@ -58337,7 +58370,10 @@ var ConfigService = class ConfigService extends Tag("ConfigService")() {
58337
58370
  //#endregion
58338
58371
  //#region src/workflow/init.ts
58339
58372
  const SCHEMA_URL = "https://cdn.jsdelivr.net/npm/@pmelab/gtd/schema.json";
58340
- const INIT_ENV = { testCommand: "npm test" };
58373
+ const INIT_ENV = {
58374
+ testCommand: "npm test",
58375
+ fastTestCommand: ""
58376
+ };
58341
58377
  const MODES_SUGGESTION = {
58342
58378
  qa: { format: "npx prettier --write \"$GTD_FILE\"" },
58343
58379
  review: { format: "npx prettier --write \"$GTD_FILE\"" }
@@ -58355,6 +58391,16 @@ const checkShape = (value) => {
58355
58391
  for (const [scope, names] of Object.entries(value)) if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new Error(`skills export: "${scope}" must be an array of skill names`);
58356
58392
  return value;
58357
58393
  };
58394
+ /** Every `.gtdrc` key that names no known scope is a load error, one per file that wrote it. */
58395
+ const checkKeys = (keys, known, message, section) => {
58396
+ const sorted = [...known].sort();
58397
+ return failOnErrors(dedupeDiagnostics(sortDiagnostics(keys.filter(({ key }) => !sorted.includes(key)).map(({ key, origin }) => ({
58398
+ severity: "error",
58399
+ path: [section, key],
58400
+ message: message(key, sorted),
58401
+ origin
58402
+ })), [...new Set(keys.map((k) => k.origin))])));
58403
+ };
58358
58404
  /**
58359
58405
  * Skill lists for the settings actually in use. `.gtdrc` `skills:` keys are
58360
58406
  * judged here, not at config load: only now are the pinned/entry vars known.
@@ -58364,15 +58410,29 @@ const resolveScopeSkills = (def, vars) => gen(function* () {
58364
58410
  try: () => checkShape(def.skills(vars)),
58365
58411
  catch: (e) => fail$4(def.skillsOrigin, e instanceof Error ? e.message : String(e))
58366
58412
  });
58367
- const known = Object.keys(skills).sort();
58368
- yield* failOnErrors(dedupeDiagnostics(sortDiagnostics(def.skillsKeys.filter(({ key }) => !known.includes(key)).map(({ key, origin }) => ({
58369
- severity: "error",
58370
- path: ["skills", key],
58371
- message: unknownSkillsKeyMessage(key, known),
58372
- origin
58373
- })), [...new Set(def.skillsKeys.map((k) => k.origin))])));
58413
+ yield* checkKeys(def.skillsKeys, Object.keys(skills), unknownSkillsKeyMessage, "skills");
58374
58414
  return skills;
58375
58415
  });
58416
+ const checkAccessShape = (value) => {
58417
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("access export must return an object of scope -> { read?, write? }");
58418
+ for (const [scope, access] of Object.entries(value)) {
58419
+ const fault = accessShapeFault(access);
58420
+ if (fault !== void 0) throw new Error(`access export: "${scope}": ${fault}`);
58421
+ }
58422
+ return value;
58423
+ };
58424
+ /**
58425
+ * Access for the settings actually in use. A `.gtdrc` `access:` key may name
58426
+ * any scope the workflow gives skills or access to.
58427
+ */
58428
+ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
58429
+ const access = yield* try_({
58430
+ try: () => checkAccessShape(def.access(vars)),
58431
+ catch: (e) => fail$4(def.skillsOrigin, e instanceof Error ? e.message : String(e))
58432
+ });
58433
+ yield* checkKeys(def.accessKeys, [.../* @__PURE__ */ new Set([...Object.keys(skills), ...Object.keys(access)])], unknownAccessKeyMessage, "access");
58434
+ return access;
58435
+ });
58376
58436
  //#endregion
58377
58437
  //#region src/judges/providers/fixed.ts
58378
58438
  const fail$3 = (message) => {
@@ -63696,6 +63756,8 @@ const renderFormat = (fmt, ...args) => {
63696
63756
  let i = 0;
63697
63757
  return fmt.replace(/%s/g, () => args[i++] ?? "");
63698
63758
  };
63759
+ /** The first line of the landing refusal for a turn that wrote outside its `write` access — a stable marker drivers and tests grep for. */
63760
+ const ACCESS_REFUSAL = "gtd land: this turn wrote outside its write access — revert these paths, then run `gtd land` again:";
63699
63761
  //#endregion
63700
63762
  //#region src/wire/Demand.ts
63701
63763
  /**
@@ -63724,7 +63786,7 @@ const stallDiagnosis = (state, actor) => `stalled at "${state}": the last gtd(${
63724
63786
  /**
63725
63787
  * Assemble one `Demand` — the ONLY place a rest's resolved content becomes
63726
63788
  * `Demand.content` (`stallDiagnosis` at `kind === "stalled"`, the rendered
63727
- * content otherwise), and the only place `session`/`validate`/`skills` are
63789
+ * content otherwise), and the only place `session`/`validate`/`skills`/`access` are
63728
63790
  * attached, which the `prompt` variant's own type makes impossible to do at
63729
63791
  * any other kind.
63730
63792
  */
@@ -63732,12 +63794,17 @@ const demandOf = (input) => {
63732
63794
  const { rendered, kind, session, validate } = input;
63733
63795
  const content = kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor) : rendered.content;
63734
63796
  const skills = rendered.skills !== void 0 && rendered.skills.length > 0 ? rendered.skills : void 0;
63797
+ const access = rendered.access ?? {
63798
+ read: null,
63799
+ write: null
63800
+ };
63735
63801
  return kind === "prompt" ? {
63736
63802
  kind,
63737
63803
  content,
63738
63804
  session,
63739
63805
  validate,
63740
- skills
63806
+ skills,
63807
+ access
63741
63808
  } : {
63742
63809
  kind,
63743
63810
  content
@@ -63781,7 +63848,7 @@ const assertNeverDemand = (demand) => {
63781
63848
  };
63782
63849
  /**
63783
63850
  * The ONE exhaustive switch translating a `Demand` into its wire
63784
- * `session`/`validate`/`skills` triple — only the `prompt` variant carries
63851
+ * `session`/`validate`/`skills`/`access` set — only the `prompt` variant carries
63785
63852
  * any of them, and every other member is listed explicitly (not a
63786
63853
  * `default:` fallthrough) so adding a sixth `BeatKind` without adding its
63787
63854
  * case here fails `tsc` at the `assertNeverDemand` call, not silently
@@ -63792,7 +63859,8 @@ const dispatchFieldsOf = (demand) => {
63792
63859
  case "prompt": return {
63793
63860
  session: demand.session,
63794
63861
  validate: demand.validate,
63795
- skills: demand.skills
63862
+ skills: demand.skills,
63863
+ access: demand.access
63796
63864
  };
63797
63865
  case "capture":
63798
63866
  case "message":
@@ -63800,19 +63868,20 @@ const dispatchFieldsOf = (demand) => {
63800
63868
  case "stalled": return {
63801
63869
  session: void 0,
63802
63870
  validate: void 0,
63803
- skills: void 0
63871
+ skills: void 0,
63872
+ access: void 0
63804
63873
  };
63805
63874
  default: return assertNeverDemand(demand);
63806
63875
  }
63807
63876
  };
63808
63877
  /**
63809
- * Flatten a `Demand` plus its `BeatStatus` into the single 20-key document
63878
+ * Flatten a `Demand` plus its `BeatStatus` into the single 21-key document
63810
63879
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
63811
63880
  * `costByModel` are omitted together, exactly when no cost was recorded
63812
63881
  * (`cost <= 0`).
63813
63882
  */
63814
63883
  const beatDocument = (demand, status) => {
63815
- const { session, validate, skills } = dispatchFieldsOf(demand);
63884
+ const { session, validate, skills, access } = dispatchFieldsOf(demand);
63816
63885
  const hasCost = status.cost > 0;
63817
63886
  return {
63818
63887
  kind: demand.kind,
@@ -63834,7 +63903,8 @@ const beatDocument = (demand, status) => {
63834
63903
  cost: hasCost ? status.cost : void 0,
63835
63904
  costByModel: hasCost ? status.costByModel : void 0,
63836
63905
  judge: status.judge,
63837
- skills
63906
+ skills,
63907
+ access
63838
63908
  };
63839
63909
  };
63840
63910
  const renderBeatJson = (document) => JSON.stringify(document) + "\n";
@@ -63971,6 +64041,195 @@ const renderLandPlain = (fields, command = "land") => fields.subject !== null ?
63971
64041
  //#region src/Workflow.ts
63972
64042
  const knownModes = (def) => Object.keys(def.modes);
63973
64043
  //#endregion
64044
+ //#region src/step/planStep.ts
64045
+ /**
64046
+ * At the human review gate an unconditional `gtd uncheck '<file>'` runs
64047
+ * ahead of the commit, resetting every checkbox before `git add -A` picks it
64048
+ * up — a tick is read-progress, never sign-off.
64049
+ */
64050
+ const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
64051
+ kind: "uncheck",
64052
+ file: snapshot.file
64053
+ }] : [];
64054
+ const planStep = (snapshot, opts = {}) => {
64055
+ const landing = snapshot.landing;
64056
+ if (landing.kind === "refusal") return {
64057
+ kind: "refusal",
64058
+ message: landing.message
64059
+ };
64060
+ if (landing.kind === "noop") return {
64061
+ kind: "noop",
64062
+ state: snapshot.state,
64063
+ settled: landing.settled
64064
+ };
64065
+ const cost = opts.cost === void 0 ? {} : { cost: {
64066
+ cost: opts.cost,
64067
+ ...opts.model !== void 0 ? { model: opts.model } : {}
64068
+ } };
64069
+ if (landing.kind === "attempt") {
64070
+ const message = formatCommitMessage({
64071
+ actor: snapshot.actor,
64072
+ to: snapshot.state,
64073
+ ...cost
64074
+ });
64075
+ return {
64076
+ kind: "commit",
64077
+ state: snapshot.state,
64078
+ to: snapshot.state,
64079
+ subject: landing.subject,
64080
+ steps: [{
64081
+ kind: "gitWrite",
64082
+ write: {
64083
+ kind: "commitAll",
64084
+ message
64085
+ }
64086
+ }, {
64087
+ kind: "outcome",
64088
+ outcome: {
64089
+ kind: "commit",
64090
+ subject: landing.subject
64091
+ }
64092
+ }]
64093
+ };
64094
+ }
64095
+ const message = formatCommitMessage({
64096
+ ...landing.spec,
64097
+ ...cost,
64098
+ ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
64099
+ });
64100
+ const subject = message.split("\n")[0];
64101
+ const outcome = landing.to === snapshot.state ? {
64102
+ kind: "outcome",
64103
+ outcome: {
64104
+ kind: "commit",
64105
+ subject
64106
+ }
64107
+ } : {
64108
+ kind: "outcome",
64109
+ outcome: {
64110
+ kind: "transition",
64111
+ from: snapshot.state,
64112
+ to: landing.to
64113
+ }
64114
+ };
64115
+ return {
64116
+ kind: "commit",
64117
+ state: snapshot.state,
64118
+ to: landing.to,
64119
+ subject,
64120
+ steps: [
64121
+ ...uncheckStep(snapshot),
64122
+ {
64123
+ kind: "gitWrite",
64124
+ write: {
64125
+ kind: "commitAll",
64126
+ message
64127
+ }
64128
+ },
64129
+ outcome
64130
+ ]
64131
+ };
64132
+ };
64133
+ //#endregion
64134
+ //#region src/step/accessRefusal.ts
64135
+ /**
64136
+ * The landing refusal for changes outside `write`, or `undefined` when every
64137
+ * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
64138
+ * runs no fix turn of its own.
64139
+ */
64140
+ const accessRefusal = (changes, write) => {
64141
+ if (write === null) return void 0;
64142
+ const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
64143
+ if (offending.length === 0) return void 0;
64144
+ return [
64145
+ ACCESS_REFUSAL,
64146
+ ...offending.map((path) => ` - ${path}`),
64147
+ `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
64148
+ ].join("\n");
64149
+ };
64150
+ //#endregion
64151
+ //#region src/step/planEntry.ts
64152
+ const refusal = (message) => ({
64153
+ kind: "refusal",
64154
+ message
64155
+ });
64156
+ const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
64157
+ const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
64158
+ if (environmentOnly.length > 0) return yield* fail$7(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it under "env:" in .gtdrc or with a GTD_<NAME> environment variable`));
64159
+ const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
64160
+ if (undeclared.length > 0) return refusal(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`);
64161
+ });
64162
+ /**
64163
+ * `gtd --entry <name>`: start a brand NEW process at a workflow entry, writing
64164
+ * an opening commit that carries every process setting as a `Gtd-Var:` trailer, plus a
64165
+ * `Gtd-Review-Base:` trailer when the entry fixes the process's diff base.
64166
+ * All validation is a REFUSAL, not an Effect failure.
64167
+ */
64168
+ const planEntry = (current, actor, entry) => gen(function* () {
64169
+ const { state: name, commandLabel, vars: varOverrides } = entry;
64170
+ if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before entering`);
64171
+ if (current.entryRefusal !== void 0) return refusal(`${commandLabel}: ${current.entryRefusal}`);
64172
+ const config = yield* (yield* ConfigService).load;
64173
+ const declaredNames = Object.keys({
64174
+ ...config.workflowVars,
64175
+ ...config.rcVars
64176
+ });
64177
+ const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, declaredNames, Object.keys({
64178
+ ...config.workflowEnv,
64179
+ ...config.rcEnv
64180
+ }));
64181
+ if (overrideRefusal !== void 0) return overrideRefusal;
64182
+ let base;
64183
+ const baseOf = current.def.base;
64184
+ const vars = resolveVars(config.workflowVars, config.rcVars, varOverrides, (yield* Host).env);
64185
+ const multiline = multilineSetting(vars);
64186
+ 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`);
64187
+ const template = yield* try_({
64188
+ try: () => baseOf?.(name, vars),
64189
+ catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
64190
+ });
64191
+ if (baseOf !== void 0 && template !== void 0) {
64192
+ const resolved = yield* resolveBase(name, commandLabel, baseOf, template);
64193
+ if (typeof resolved !== "string") return resolved;
64194
+ base = resolved;
64195
+ }
64196
+ const subject = formatSubject(actor, name);
64197
+ return {
64198
+ kind: "entry",
64199
+ state: name,
64200
+ subject,
64201
+ steps: [{
64202
+ kind: "gitWrite",
64203
+ write: {
64204
+ kind: "commitAll",
64205
+ message: formatCommitMessage({
64206
+ actor,
64207
+ to: name,
64208
+ ...base !== void 0 ? { reviewBase: base } : {},
64209
+ vars
64210
+ })
64211
+ }
64212
+ }, {
64213
+ kind: "outcome",
64214
+ outcome: {
64215
+ kind: "commit",
64216
+ subject
64217
+ }
64218
+ }]
64219
+ };
64220
+ });
64221
+ /** The entry's diff base: a commitish that resolves, is an ancestor of HEAD, and is not HEAD. */
64222
+ const resolveBase = (name, commandLabel, base, value) => gen(function* () {
64223
+ const rendered = value.trim();
64224
+ if (rendered === "") return refusal(`${commandLabel}: "${name}"'s reviewBase template rendered blank — template: ${base.toString()}; pass the base with --var`);
64225
+ const git = yield* GitService;
64226
+ const resolvedBase = yield* either(git.resolveRef(rendered));
64227
+ if (resolvedBase._tag === "Left") return refusal(`${commandLabel}: "${rendered}" does not resolve to a commit`);
64228
+ if (!(yield* git.isAncestor(resolvedBase.right, "HEAD"))) return refusal(`${commandLabel}: "${rendered}" is not an ancestor of HEAD`);
64229
+ if (resolvedBase.right === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: "${rendered}" is HEAD — nothing to review`);
64230
+ return resolvedBase.right;
64231
+ });
64232
+ //#endregion
63974
64233
  //#region src/Edge.ts
63975
64234
  const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
63976
64235
  const ACTORS = /* @__PURE__ */ new Set([
@@ -64240,6 +64499,8 @@ const replayFor = (setup, pending) => replay({
64240
64499
  budgetBytes: setup.budget,
64241
64500
  skills: setup.skills,
64242
64501
  configuredSkills: setup.def.configuredSkills,
64502
+ access: setup.access,
64503
+ configuredAccess: setup.def.configuredAccess,
64243
64504
  ...pending !== void 0 ? { pending } : {}
64244
64505
  });
64245
64506
  const replayError = (outcome) => {
@@ -64268,14 +64529,15 @@ const commonOf = (step) => {
64268
64529
  ...optional("mode", options.mode)
64269
64530
  };
64270
64531
  };
64271
- const promptDef = (common, request, skills) => ({
64532
+ const promptDef = (common, request, skills, access) => ({
64272
64533
  ...common,
64273
64534
  kind: "prompt",
64274
64535
  content: request.prompt,
64275
64536
  ...optional("model", request.options.model),
64276
64537
  ...optional("system", request.options.system),
64277
64538
  ...optional("allowEmpty", request.options.allowEmpty),
64278
- ...optional("skills", skills)
64539
+ ...optional("skills", skills),
64540
+ ...optional("access", access)
64279
64541
  });
64280
64542
  const scriptDef = (common, request) => ({
64281
64543
  ...common,
@@ -64301,7 +64563,7 @@ const stepDefOf = (step) => {
64301
64563
  const request = step.request;
64302
64564
  const common = commonOf(step);
64303
64565
  switch (request.kind) {
64304
- case "agent": return promptDef(common, request, step.skills);
64566
+ case "agent": return promptDef(common, request, step.skills, step.access);
64305
64567
  case "run": return scriptDef(common, request);
64306
64568
  case "judge": return judgeDef(common, request, step);
64307
64569
  case "human": return messageDef(common, request, step);
@@ -64357,7 +64619,8 @@ const hintsOf = (def) => ({
64357
64619
  ...optional("judge", def.judge),
64358
64620
  ...optional("system", def.system),
64359
64621
  ...optional("mode", def.mode),
64360
- ...optional("skills", def.skills)
64622
+ ...optional("skills", def.skills),
64623
+ ...optional("access", def.access)
64361
64624
  });
64362
64625
  const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
64363
64626
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -64379,6 +64642,7 @@ const restAt = (ref, entryOverrides = {}) => gen(function* () {
64379
64642
  const run = yield* computeProcessRun(git, def, ref);
64380
64643
  const { vars, env } = settingsFor(config, run.pinnedVars, host.env, entryOverrides);
64381
64644
  const skills = yield* resolveScopeSkills(def, vars);
64645
+ const access = yield* resolveScopeAccess(def, skills, vars);
64382
64646
  const budget = yield* try_({
64383
64647
  try: () => judgeBudgetBytes(vars),
64384
64648
  catch: (e) => e instanceof Error ? e : new Error(String(e))
@@ -64404,6 +64668,7 @@ const restAt = (ref, entryOverrides = {}) => gen(function* () {
64404
64668
  env,
64405
64669
  budget,
64406
64670
  skills,
64671
+ access,
64407
64672
  workspace,
64408
64673
  base,
64409
64674
  episode,
@@ -64453,6 +64718,7 @@ const entryRefusal = (rest, name, entryVars) => gen(function* () {
64453
64718
  const host = yield* Host;
64454
64719
  const { vars, env } = settingsFor(config, {}, host.env, entryVars);
64455
64720
  const skills = yield* resolveScopeSkills(rest.def, vars);
64721
+ const access = yield* resolveScopeAccess(rest.def, skills, vars);
64456
64722
  const workspace = rest.setup.workspace;
64457
64723
  const outcome = yield* promise(() => replay({
64458
64724
  flow: rest.def.flow,
@@ -64469,7 +64735,9 @@ const entryRefusal = (rest, name, entryVars) => gen(function* () {
64469
64735
  start: "",
64470
64736
  budgetBytes: rest.setup.budget,
64471
64737
  skills,
64472
- configuredSkills: rest.def.configuredSkills
64738
+ configuredSkills: rest.def.configuredSkills,
64739
+ access,
64740
+ configuredAccess: rest.def.configuredAccess
64473
64741
  }));
64474
64742
  if (outcome.kind === "refused") return outcome.message;
64475
64743
  if (outcome.kind === "rest") return outcome.entryRead ? void 0 : `"${name}" is not an enterable state — this workflow reads no entry`;
@@ -64561,6 +64829,13 @@ const landingTo = (rest, to) => {
64561
64829
  const decideLanding = (rest, verdicts) => gen(function* () {
64562
64830
  const early = cleanLanding(rest);
64563
64831
  if (early !== void 0) return early;
64832
+ if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
64833
+ const message = accessRefusal(rest.changes, rest.stepDef.access.write);
64834
+ if (message !== void 0) return {
64835
+ kind: "refusal",
64836
+ message
64837
+ };
64838
+ }
64564
64839
  const outcome = yield* promise(() => replayFor(rest.setup, {
64565
64840
  tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
64566
64841
  ...verdicts !== void 0 ? { verdicts } : {}
@@ -64604,178 +64879,6 @@ const summaryFor = (run) => gen(function* () {
64604
64879
  });
64605
64880
  });
64606
64881
  //#endregion
64607
- //#region src/step/planStep.ts
64608
- /**
64609
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
64610
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
64611
- * up — a tick is read-progress, never sign-off.
64612
- */
64613
- const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
64614
- kind: "uncheck",
64615
- file: snapshot.file
64616
- }] : [];
64617
- const planStep = (snapshot, opts = {}) => {
64618
- const landing = snapshot.landing;
64619
- if (landing.kind === "refusal") return {
64620
- kind: "refusal",
64621
- message: landing.message
64622
- };
64623
- if (landing.kind === "noop") return {
64624
- kind: "noop",
64625
- state: snapshot.state,
64626
- settled: landing.settled
64627
- };
64628
- const cost = opts.cost === void 0 ? {} : { cost: {
64629
- cost: opts.cost,
64630
- ...opts.model !== void 0 ? { model: opts.model } : {}
64631
- } };
64632
- if (landing.kind === "attempt") {
64633
- const message = formatCommitMessage({
64634
- actor: snapshot.actor,
64635
- to: snapshot.state,
64636
- ...cost
64637
- });
64638
- return {
64639
- kind: "commit",
64640
- state: snapshot.state,
64641
- to: snapshot.state,
64642
- subject: landing.subject,
64643
- steps: [{
64644
- kind: "gitWrite",
64645
- write: {
64646
- kind: "commitAll",
64647
- message
64648
- }
64649
- }, {
64650
- kind: "outcome",
64651
- outcome: {
64652
- kind: "commit",
64653
- subject: landing.subject
64654
- }
64655
- }]
64656
- };
64657
- }
64658
- const message = formatCommitMessage({
64659
- ...landing.spec,
64660
- ...cost,
64661
- ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
64662
- });
64663
- const subject = message.split("\n")[0];
64664
- const outcome = landing.to === snapshot.state ? {
64665
- kind: "outcome",
64666
- outcome: {
64667
- kind: "commit",
64668
- subject
64669
- }
64670
- } : {
64671
- kind: "outcome",
64672
- outcome: {
64673
- kind: "transition",
64674
- from: snapshot.state,
64675
- to: landing.to
64676
- }
64677
- };
64678
- return {
64679
- kind: "commit",
64680
- state: snapshot.state,
64681
- to: landing.to,
64682
- subject,
64683
- steps: [
64684
- ...uncheckStep(snapshot),
64685
- {
64686
- kind: "gitWrite",
64687
- write: {
64688
- kind: "commitAll",
64689
- message
64690
- }
64691
- },
64692
- outcome
64693
- ]
64694
- };
64695
- };
64696
- //#endregion
64697
- //#region src/step/planEntry.ts
64698
- const refusal = (message) => ({
64699
- kind: "refusal",
64700
- message
64701
- });
64702
- const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
64703
- const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
64704
- if (environmentOnly.length > 0) return yield* fail$7(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it under "env:" in .gtdrc or with a GTD_<NAME> environment variable`));
64705
- const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
64706
- if (undeclared.length > 0) return refusal(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`);
64707
- });
64708
- /**
64709
- * `gtd --entry <name>`: start a brand NEW process at a workflow entry, writing
64710
- * an opening commit that carries every process setting as a `Gtd-Var:` trailer, plus a
64711
- * `Gtd-Review-Base:` trailer when the entry fixes the process's diff base.
64712
- * All validation is a REFUSAL, not an Effect failure.
64713
- */
64714
- const planEntry = (current, actor, entry) => gen(function* () {
64715
- const { state: name, commandLabel, vars: varOverrides } = entry;
64716
- if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before entering`);
64717
- if (current.entryRefusal !== void 0) return refusal(`${commandLabel}: ${current.entryRefusal}`);
64718
- const config = yield* (yield* ConfigService).load;
64719
- const declaredNames = Object.keys({
64720
- ...config.workflowVars,
64721
- ...config.rcVars
64722
- });
64723
- const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, declaredNames, Object.keys({
64724
- ...config.workflowEnv,
64725
- ...config.rcEnv
64726
- }));
64727
- if (overrideRefusal !== void 0) return overrideRefusal;
64728
- let base;
64729
- const baseOf = current.def.base;
64730
- const vars = resolveVars(config.workflowVars, config.rcVars, varOverrides, (yield* Host).env);
64731
- const multiline = multilineSetting(vars);
64732
- 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`);
64733
- const template = yield* try_({
64734
- try: () => baseOf?.(name, vars),
64735
- catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
64736
- });
64737
- if (baseOf !== void 0 && template !== void 0) {
64738
- const resolved = yield* resolveBase(name, commandLabel, baseOf, template);
64739
- if (typeof resolved !== "string") return resolved;
64740
- base = resolved;
64741
- }
64742
- const subject = formatSubject(actor, name);
64743
- return {
64744
- kind: "entry",
64745
- state: name,
64746
- subject,
64747
- steps: [{
64748
- kind: "gitWrite",
64749
- write: {
64750
- kind: "commitAll",
64751
- message: formatCommitMessage({
64752
- actor,
64753
- to: name,
64754
- ...base !== void 0 ? { reviewBase: base } : {},
64755
- vars
64756
- })
64757
- }
64758
- }, {
64759
- kind: "outcome",
64760
- outcome: {
64761
- kind: "commit",
64762
- subject
64763
- }
64764
- }]
64765
- };
64766
- });
64767
- /** The entry's diff base: a commitish that resolves, is an ancestor of HEAD, and is not HEAD. */
64768
- const resolveBase = (name, commandLabel, base, value) => gen(function* () {
64769
- const rendered = value.trim();
64770
- if (rendered === "") return refusal(`${commandLabel}: "${name}"'s reviewBase template rendered blank — template: ${base.toString()}; pass the base with --var`);
64771
- const git = yield* GitService;
64772
- const resolvedBase = yield* either(git.resolveRef(rendered));
64773
- if (resolvedBase._tag === "Left") return refusal(`${commandLabel}: "${rendered}" does not resolve to a commit`);
64774
- if (!(yield* git.isAncestor(resolvedBase.right, "HEAD"))) return refusal(`${commandLabel}: "${rendered}" is not an ancestor of HEAD`);
64775
- if (resolvedBase.right === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: "${rendered}" is HEAD — nothing to review`);
64776
- return resolvedBase.right;
64777
- });
64778
- //#endregion
64779
64882
  //#region src/RetainedHistory.ts
64780
64883
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
64781
64884
  const HISTORY_REF = "refs/worktree/gtd/history";
@@ -76757,7 +76860,8 @@ never stored, so a plain peek is exactly as safe to call as a dispatch would
76757
76860
  be — and, when the state declares a validatable steering file, \`validate\`
76758
76861
  (the script that formats then validates it), and, when the turn's scope declares
76759
76862
  skills, \`skills\` (an array of the skill names, already trimmed and split —
76760
- \`--json=skills.0\` reads the first). When set: \`model\`, \`memory\`,
76863
+ \`--json=skills.0\` reads the first), and always \`access\`
76864
+ (\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
76761
76865
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
76762
76866
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
76763
76867
  \`cost\`, \`costByModel\`.
@@ -76856,6 +76960,13 @@ const DRIVER_OBLIGATIONS = `
76856
76960
  turn sees only those. An absent field means none. A CLI that cannot
76857
76961
  restrict relies on the preamble gtd already puts in the prompt — the
76858
76962
  reference body's \`claude -p\` does exactly that.
76963
+ 12. Map each prompt turn's \`--json=access.read\` / \`--json=access.write\`
76964
+ (arrays of globs, \`null\` = unrestricted) onto the chosen CLI's sandbox
76965
+ (Codex \`workspace-write\` plus writable roots) or a container with
76966
+ \`:ro\` / \`:rw\` bind mounts. \`access\` is a prompt-only field. A CLI
76967
+ that cannot restrict relies on the preamble and gtd's landing check. When
76968
+ \`gtd land\` refuses naming an access violation, re-prompt the turn with
76969
+ the refusal text and land again, or stop.
76859
76970
  `;
76860
76971
  const RECOVERY = `
76861
76972
  ## Recovery