@pmelab/gtd 64.1.2 → 64.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "64.1.2",
3
+ "version": "64.3.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -433,22 +433,25 @@ commit. Its guide is
433
433
  Prefer a phone or another device over your editor? `gtd ui` opens the file whose
434
434
  turn it is as a small web app, over Tailscale — including a step that names no
435
435
  format, or one nothing recognizes, which still opens as plain markdown structure
436
- instead of refusing to start: read, plus a note on any block. It also binds on
437
- an idle worktree — nothing pending yet — opening `.gtd/TODO.md` free-form with a
438
- bare textfield so you can sketch from your phone; the sketch stays un-triaged on
439
- disk until you run `gtd` again, from anywhere, to pick it up. A description
440
- renders inline markdown — bold, code, strikethrough, links, and images — instead
441
- of literal asterisks and backticks. A note you add from the phone starts a
442
- thread: a footnote holding a `H:`/`A:` conversation. Threads show as a
443
- conversation under their block, question, chunk, or hunk — or, in a review chunk
444
- with no file pointers, under each line of its lists, so a summary's entries take
445
- a note apiece — and one the agent answered last carries a "waiting on you"
446
- badge. Tap it to reply — your reply is added as a new `H:` entry; while the
447
- agent has yet to answer, reopening it edits your last entry instead. Tap an
448
- already-answered question to leave a note on it; the agent may overturn the
449
- answer. A `findings` file lists its findings, each with a fix checkbox and, once
450
- ticked, an optional line of guidance; Done stays disabled while the file holds
451
- anything its gate would refuse. See
436
+ instead of refusing to start: read, plus a note on any block and tickable
437
+ task-list boxes (each list is pick-one: ticking a box unticks its siblings, and
438
+ a ticked box can't be cleared from the phone). It also binds on an idle worktree
439
+ — nothing pending yet — opening `.gtd/TODO.md` free-form with a bare textfield
440
+ so you can sketch from your phone; the sketch stays un-triaged on disk until you
441
+ run `gtd` again, from anywhere, to pick it up. A description renders inline
442
+ markdown — bold, code, strikethrough, links, and images — instead of literal
443
+ asterisks and backticks. A note you add from the phone starts a thread: a
444
+ footnote holding a `H:`/`A:` conversation. Threads show as a conversation under
445
+ their block, question, chunk, or hunk — or, in a review chunk with no file
446
+ pointers, under each line of its lists, so a summary's entries take a note
447
+ apiece — and one the agent answered last carries a "waiting on you" badge. Tap
448
+ it to reply — your reply is added as a new `H:` entry; while the agent has yet
449
+ to answer, reopening it edits your last entry instead. Tap an already-answered
450
+ question to leave a note on it; the agent may overturn the answer. A `findings`
451
+ file lists its findings, each with a fix checkbox and, once ticked, an optional
452
+ line of guidance; Done stays disabled while the file holds anything its gate
453
+ would refuse. Done saves and leaves the server running; it exits once the
454
+ worktree moves on to another step. See
452
455
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
453
456
  for its `ui:` settings.
454
457
 
@@ -472,6 +475,15 @@ unknown key. `gtd.config.ts` is the only repository config: gtd 23 removed the
472
475
  home directory — fails every command until you delete it. See
473
476
  [Migrating from `.gtdrc`](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#migrating-from-gtdrc).
474
477
 
478
+ ## Large untracked trees
479
+
480
+ `gtd next`, `gtd land`, `gtd judge answer` and `gtd start` refuse when the
481
+ worktree holds more than 1,000 untracked, un-ignored files (a landing would
482
+ commit them all). The error names the count and the largest untracked
483
+ directories; add them to `.gitignore` (or `.git/info/exclude`) or delete them,
484
+ then run again. Fixed limit, no config key. Every other command — read-only ones
485
+ like `gtd check --open-threads`, `gtd judge`, `gtd validate` — never refuses.
486
+
475
487
  ## Checking a range for stray files
476
488
 
477
489
  ```sh
@@ -16486,6 +16486,58 @@ const appendEdit = (content, text) => {
16486
16486
  const newText = trimmed.length > 0 ? `${trimmed}${eol}${eol}${normalizedText}${eol}` : `${normalizedText}${eol}`;
16487
16487
  return wholeDocumentEdit(content, newText);
16488
16488
  };
16489
+ /** Every task-list item with its sibling task items (direct children of one list only). */
16490
+ const taskGroups = (node, out) => {
16491
+ const n = node;
16492
+ if (n.type === "list") out.push(n.children.filter((i) => i.checked === true || i.checked === false));
16493
+ for (const c of n.children ?? []) taskGroups(c, out);
16494
+ };
16495
+ /** The box sits on the first child's line, which is not the marker line when a bare `-` precedes it. */
16496
+ const boxEdit = (lines, item, newText) => {
16497
+ const line = toLspPosition((item.children[0]?.position ?? item.position).start).line;
16498
+ const m = /\[( |x|X)\]/.exec(lines[line] ?? "");
16499
+ if (!m) return void 0;
16500
+ const c = m.index + 1;
16501
+ return {
16502
+ range: {
16503
+ start: {
16504
+ line,
16505
+ character: c
16506
+ },
16507
+ end: {
16508
+ line,
16509
+ character: c + 1
16510
+ }
16511
+ },
16512
+ newText
16513
+ };
16514
+ };
16515
+ /** Pick-one tick: sets the box on `line` and unticks its ticked siblings, in one edit set. Ticking an already-ticked box is an empty edit set. */
16516
+ const tickEdits = (content, line) => {
16517
+ const lines = content.split("\n");
16518
+ const groups = [];
16519
+ taskGroups(parseMarkdown(content), groups);
16520
+ const startLine = (i) => toLspPosition(i.position.start).line;
16521
+ const group = groups.find((g) => g.some((i) => startLine(i) === line));
16522
+ if (!group) return {
16523
+ ok: false,
16524
+ reason: "anchor-not-found"
16525
+ };
16526
+ const target = group.find((i) => startLine(i) === line);
16527
+ if (target.checked === true) return {
16528
+ ok: true,
16529
+ edits: []
16530
+ };
16531
+ const edits = group.filter((i) => i === target || i.checked === true).map((i) => boxEdit(lines, i, i === target ? "x" : " "));
16532
+ if (edits.some((e) => e === void 0)) return {
16533
+ ok: false,
16534
+ reason: "anchor-not-found"
16535
+ };
16536
+ return {
16537
+ ok: true,
16538
+ edits
16539
+ };
16540
+ };
16489
16541
  /**
16490
16542
  * Free-form's `apply`: only a `paragraph` anchor resolves (the only kind
16491
16543
  * `view` ever reports), and it only ever APPENDS, at or past the document's
@@ -16500,6 +16552,11 @@ const freeFormApply = (content, anchor, opts) => {
16500
16552
  ok: false,
16501
16553
  reason: "anchor-not-found"
16502
16554
  };
16555
+ if (opts.checked === true) return tickEdits(content, anchor.line);
16556
+ if (opts.checked === false) return {
16557
+ ok: false,
16558
+ reason: "pick-never-clears"
16559
+ };
16503
16560
  const text = opts.text ?? "";
16504
16561
  const tree = parseMarkdown(content);
16505
16562
  const lastLineIndex = content.split(/\r?\n/).length - 1;
@@ -1,6 +1,6 @@
1
1
  import { C as moveScript, D as revertScript, E as restoreScript, H as installContext, K as markedOptions, O as showScript, S as checkScript, T as removeScript, Y as protocolMismatch, i as configure, k as writeScript$1, n as configProblems, o as isFactoryWorkflow, p as numeric, s as answered, t as flows_exports, x as applyScript } from "./flows-dnIE_qbe.mjs";
2
2
  import { M as renderFormat, O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, y as demandOf } from "./wire-CaPJ9Tme.mjs";
3
- import { F as headingSectionBodies, I as formatFinding, a as sections, c as openThreads, d as formatFromSpec, i as sectionBodies, l as steeringFormatFor, o as threads, r as openQuestions, s as BUILT_IN_MODE_NAMES, t as parseCodeThreads, u as unansweredQuestions } from "./steering-CQzL-lX1.mjs";
3
+ import { F as headingSectionBodies, I as formatFinding, a as sections, c as openThreads, d as formatFromSpec, i as sectionBodies, l as steeringFormatFor, o as threads, r as openQuestions, s as BUILT_IN_MODE_NAMES, t as parseCodeThreads, u as unansweredQuestions } from "./steering-DEh_MS40.mjs";
4
4
  import { createRequire, isBuiltin } from "node:module";
5
5
  import * as Crypto from "node:crypto";
6
6
  import { createHash } from "node:crypto";
@@ -20448,14 +20448,20 @@ const stripCommitSeam = (tail) => {
20448
20448
  const afterNul = tail.startsWith("\0") ? tail.slice(1) : tail;
20449
20449
  return afterNul.startsWith("\n") ? afterNul.slice(1) : afterNul;
20450
20450
  };
20451
+ const ARGV_CHUNK = 500;
20452
+ const chunked = (items) => {
20453
+ const out = [];
20454
+ for (let i = 0; i < items.length; i += ARGV_CHUNK) out.push(items.slice(i, i + ARGV_CHUNK));
20455
+ return out;
20456
+ };
20451
20457
  /**
20452
20458
  * `path → blob object id` at `ref`; a path absent from the map doesn't exist
20453
20459
  * there. An unresolvable `ref` (an empty repo) yields an empty map, so every
20454
20460
  * candidate reads as "not at `ref`".
20455
20461
  */
20456
- const blobsAtRef = (exec, ref, paths) => exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(catch_(() => succeed$1("")), map((out) => {
20462
+ const blobsAtRef = (exec, ref, paths) => forEach$1(chunked(paths), (chunk) => exec("git", "ls-tree", "-r", "-z", ref, "--", ...chunk).pipe(catch_(() => succeed$1(""))), { concurrency: 1 }).pipe(map((outs) => {
20457
20463
  const blobs = /* @__PURE__ */ new Map();
20458
- for (const entry of splitNul(out)) {
20464
+ for (const entry of outs.flatMap(splitNul)) {
20459
20465
  const tab = entry.indexOf(" ");
20460
20466
  if (tab === -1) continue;
20461
20467
  const [, type, oid] = entry.slice(0, tab).split(" ");
@@ -20476,7 +20482,7 @@ const blobsAtRef = (exec, ref, paths) => exec("git", "ls-tree", "-r", "-z", ref,
20476
20482
  * A symlink is the residual inexactness: hash-object hashes the target's
20477
20483
  * content rather than the link text, and reads as `M`, never `D`.
20478
20484
  */
20479
- const hashObjects = (exec, paths) => exec("git", "hash-object", "--", ...paths).pipe(map((out) => new Map(out.trim().split("\n").map((oid, i) => [paths[i] ?? "", oid.trim()]))), catch_(() => succeed$1(/* @__PURE__ */ new Map())));
20485
+ const hashObjects = (exec, paths) => forEach$1(chunked(paths), (chunk) => exec("git", "hash-object", "--", ...chunk).pipe(map((out) => out.trim().split("\n").map((oid, i) => [chunk[i] ?? "", oid.trim()]))), { concurrency: 1 }).pipe(map((entries) => new Map(entries.flat())), catch_(() => succeed$1(/* @__PURE__ */ new Map())));
20480
20486
  /**
20481
20487
  * Classify every untracked path against `ref`'s tree, by CONTENT: not at
20482
20488
  * `ref` → `A`; different bytes → `M`; identical bytes → no entry. The latter
@@ -20590,6 +20596,7 @@ const makeGitImpl = (spawner, root) => {
20590
20596
  }
20591
20597
  return all;
20592
20598
  }),
20599
+ untrackedPaths: () => exec("git", "ls-files", "--others", "--exclude-standard", "-z").pipe(map(splitNul)),
20593
20600
  stagedPaths: () => exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(catch_(() => succeed$1("")), map((out) => parseNameStatus(splitNul(out)))),
20594
20601
  resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map((s) => s.trim()), flatMap((hash) => resolvedRefOrCorrupted(ref, hash))),
20595
20602
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map((s) => some(s.trim())), catch_(() => succeed$1(none()))),
@@ -26425,6 +26432,19 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
26425
26432
  }
26426
26433
  };
26427
26434
  });
26435
+ const UNTRACKED_LIMIT = 1e3;
26436
+ const refuseHugeUntracked = gen(function* () {
26437
+ const paths = yield* (yield* GitService).untrackedPaths();
26438
+ if (paths.length <= UNTRACKED_LIMIT) return;
26439
+ const dirs = /* @__PURE__ */ new Map();
26440
+ for (const p of paths) {
26441
+ const i = p.lastIndexOf("/");
26442
+ const d = i === -1 ? "." : p.slice(0, i);
26443
+ dirs.set(d, (dirs.get(d) ?? 0) + 1);
26444
+ }
26445
+ const top = [...dirs].sort((a, b) => b[1] - a[1]).slice(0, 3);
26446
+ return yield* fail$4(/* @__PURE__ */ new Error(`gtd: ${paths.length} untracked files (limit ${UNTRACKED_LIMIT}); largest untracked directories: ${top.map(([d, n]) => `${d} (${n})`).join(", ")}. Add them to .gitignore or delete them.`));
26447
+ });
26428
26448
  const pendingChanges = (git, withIndex) => gen(function* () {
26429
26449
  const worktree = yield* git.changedPaths();
26430
26450
  if (!withIndex) return worktree;
@@ -27667,6 +27687,7 @@ const landingScript = (required) => combinedScript(required, "");
27667
27687
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
27668
27688
  /** Decides `gtd land`'s one transition without performing it. Refusals fail the Effect; a no-op returns `subject: null` and empty scripts. */
27669
27689
  const planLanding = (opts = {}, current) => gen(function* () {
27690
+ yield* refuseHugeUntracked;
27670
27691
  const rest = current ?? (yield* currentRest);
27671
27692
  const snapshot = yield* snapshotFromRest(rest, opts.judge);
27672
27693
  const outcome = planStep(snapshot, opts);
@@ -27848,6 +27869,7 @@ const startWorkflow = (workflow, varOverrides, json, out, commandLabel) => gen(f
27848
27869
  const startable = config.workflowNames.length > 0 ? `startable: ${config.workflowNames.join(", ")}` : "this config exports no workflow besides its default, which an ordinary start runs";
27849
27870
  return yield* fail$4(new GtdUsageError(`${commandLabel}: unknown workflow "${workflow}" — ${startable}`));
27850
27871
  }
27872
+ yield* refuseHugeUntracked;
27851
27873
  const rest = yield* restAt(void 0, varOverrides);
27852
27874
  const started = yield* startRefusal(rest, resolved, varOverrides);
27853
27875
  const plan = yield* planStart({
@@ -28104,6 +28126,7 @@ const restBeatKind = (rest) => beatKindOf({
28104
28126
  * document's own `kind` field, never the exit code.
28105
28127
  */
28106
28128
  const runNextCommand = (json, out) => gen(function* () {
28129
+ yield* refuseHugeUntracked;
28107
28130
  const rest = yield* currentRest;
28108
28131
  const rendered = yield* renderRest(rest);
28109
28132
  const fields = yield* gatherBeatDocument(rest, rendered);
@@ -28271,6 +28294,7 @@ const runUiCliCommand = (command, out) => gen(function* () {
28271
28294
  ...command.host !== void 0 ? { host: command.host } : {},
28272
28295
  ...command.port !== void 0 ? { port: command.port } : {},
28273
28296
  selfSigned: command.selfSigned,
28297
+ ...command.http === true ? { http: true } : {},
28274
28298
  dev: command.dev
28275
28299
  },
28276
28300
  config: config.ui,
@@ -28502,7 +28526,7 @@ const FLAGS = [
28502
28526
  },
28503
28527
  scopeError: "gtd: --port is only valid for `gtd ui`",
28504
28528
  valueHint: "<n>",
28505
- help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host the port to bind (default: any free one)"
28529
+ help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host or --http the port to bind (default: any free one)"
28506
28530
  },
28507
28531
  {
28508
28532
  name: "--host",
@@ -28524,6 +28548,16 @@ const FLAGS = [
28524
28548
  valueHint: "",
28525
28549
  help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key"
28526
28550
  },
28551
+ {
28552
+ name: "--http",
28553
+ arity: 0,
28554
+ repeatable: false,
28555
+ scope: (kind) => kind === "ui",
28556
+ decode: () => succeed$5(true),
28557
+ scopeError: "gtd: --http is only valid for `gtd ui`",
28558
+ valueHint: "",
28559
+ help: "Serve plain HTTP for a local TLS-terminating proxy; binds 127.0.0.1 unless a loopback --host is given"
28560
+ },
28527
28561
  {
28528
28562
  name: "--dev",
28529
28563
  arity: 0,
@@ -28834,7 +28868,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
28834
28868
  arity: "none",
28835
28869
  group: "Inspect",
28836
28870
  summary: "Serve the web and phone client",
28837
- details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`
28871
+ details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing. Done saves the steering file and keeps serving; the server exits when the worktree moves on to another step, or on a signal.`
28838
28872
  },
28839
28873
  {
28840
28874
  token: "guide",
@@ -29239,18 +29273,22 @@ const parseArgv = (argv) => {
29239
29273
  verbose
29240
29274
  };
29241
29275
  }
29242
- if (kind === "ui") return {
29243
- kind: "command",
29244
- command: {
29245
- kind: "ui",
29246
- ...bag["--host"] !== void 0 ? { host: bag["--host"] } : {},
29247
- ...bag["--port"] !== void 0 ? { port: bag["--port"] } : {},
29248
- selfSigned: bag["--self-signed"] ?? false,
29249
- dev: bag["--dev"] ?? false
29250
- },
29251
- json,
29252
- verbose
29253
- };
29276
+ if (kind === "ui") {
29277
+ if (bag["--http"] === true && bag["--self-signed"] === true) return usagePlan("gtd: --http and --self-signed cannot be combined", jsonSeen);
29278
+ return {
29279
+ kind: "command",
29280
+ command: {
29281
+ kind: "ui",
29282
+ ...bag["--host"] !== void 0 ? { host: bag["--host"] } : {},
29283
+ ...bag["--port"] !== void 0 ? { port: bag["--port"] } : {},
29284
+ selfSigned: bag["--self-signed"] ?? false,
29285
+ ...bag["--http"] === true ? { http: true } : {},
29286
+ dev: bag["--dev"] ?? false
29287
+ },
29288
+ json,
29289
+ verbose
29290
+ };
29291
+ }
29254
29292
  if (kind === "check") return {
29255
29293
  kind: "command",
29256
29294
  command: {
@@ -29641,6 +29679,10 @@ var InMemRepo = class InMemRepo {
29641
29679
  changedPathsWorktree(base) {
29642
29680
  return diffTrees(this.treeAt(base), new Map(this.worktree));
29643
29681
  }
29682
+ /** Worktree paths the index does not carry. */
29683
+ untrackedPaths() {
29684
+ return [...this.worktree.keys()].filter((k) => !this.index.has(k));
29685
+ }
29644
29686
  /** The index vs HEAD — the port's `stagedPaths`. */
29645
29687
  stagedPaths() {
29646
29688
  return diffTrees(this.treeAt(void 0), this.index);
@@ -29971,6 +30013,7 @@ const makeGitReaderOps = (repo, root) => ({
29971
30013
  commitHistory: (base, head) => succeed$1(repo.commitHistory(base, head)),
29972
30014
  subjectHistory: (pageSize, skip, head) => succeed$1(repo.subjectHistory(pageSize, skip, head)),
29973
30015
  changedPaths: (base) => succeed$1(repo.changedPathsWorktree(base)),
30016
+ untrackedPaths: () => succeed$1(repo.untrackedPaths()),
29974
30017
  stagedPaths: () => succeed$1(repo.stagedPaths()),
29975
30018
  diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip)),
29976
30019
  authorIdent: () => succeed$1(some({