@4-r-c-4-n-4/todo 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BIBLE.md CHANGED
@@ -140,6 +140,19 @@ git branch -d todo/<parent-id>
140
140
 
141
141
  If you need manual control instead of a loop, use `todo work --skip-branch <child-id>` to activate a child without a redundant checkout. Do NOT use plain `todo work` for subsequent children on a shared branch — it performs a no-op checkout and prints confusing resume output.
142
142
 
143
+ ### Branch Protection (recommended)
144
+
145
+ The local-merge step above is convenient for solo work, but `todo` is built for agent-driven flows where unreviewed code shouldn't land on `main` silently. GitHub's branch-protection rules close that gap without adding any process inside the tool — turn them on once and the same `todo` workflow stops bypassing review.
146
+
147
+ For `main`, enable:
148
+
149
+ - **Require a pull request before merging** — the agent stops at the PR; you click Merge.
150
+ - **Require status checks to pass** — your CI's `Lint, typecheck, test` job. Catches the kind of regression that this session's commit `b6709ed` (missing `pretest` hook) would have stopped on red.
151
+ - **Require linear history** — enforces the skill's `--no-ff` rule. Squash merges would orphan the resolution-commit SHAs stored in `.todo/done/<id>.json`.
152
+ - **Require approvals (optional)** — even for a solo repo, setting "1 approval" forces you to actually open the PR and skim the diff before clicking through. Cheap insurance against autopilot merges.
153
+
154
+ Once protected, the closing step becomes `todo pr` (push branch + open PR + stop) instead of a local merge. The local-merge sequence in the previous sections remains valid for unprotected repos or quick solo work.
155
+
143
156
  ---
144
157
 
145
158
  ## Ticket Types
@@ -0,0 +1,18 @@
1
+ import type { Ticket } from "./types.js";
2
+ export declare function expectedBranchFor(ticket: Ticket): string;
3
+ export interface BranchCheck {
4
+ ok: boolean;
5
+ message?: string;
6
+ }
7
+ export declare function checkOnExpectedBranch(ticket: Ticket, currentBranch: string): BranchCheck;
8
+ export declare function checkBranchHasTodoCommit(ticket: Ticket, repoRoot: string, commitPrefix: string): BranchCheck;
9
+ /**
10
+ * True iff the ticket has at least one child AND every child is in a
11
+ * terminal state (done/wontfix/duplicate). Parents in this shape carry no
12
+ * code commit of their own — the work lives in children whose commits use
13
+ * `todo:<child-id>` prefixes — so the commit-prefix branch guard should
14
+ * skip them. Children with missing files are treated as non-terminal so
15
+ * the guard stays conservative.
16
+ */
17
+ export declare function isParentWithAllChildrenClosed(ticket: Ticket, repoRoot: string): boolean;
18
+ export declare function checkWorkingTreeClean(repoRoot: string): BranchCheck;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ // Branch-convention guards used by `todo close` and `todo work`.
3
+ //
4
+ // These turn the conventions documented in the todo-implement skill into
5
+ // hard preconditions: agents follow exit codes far more reliably than
6
+ // prose, so every drift gets a clear, actionable error.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.expectedBranchFor = expectedBranchFor;
9
+ exports.checkOnExpectedBranch = checkOnExpectedBranch;
10
+ exports.checkBranchHasTodoCommit = checkBranchHasTodoCommit;
11
+ exports.isParentWithAllChildrenClosed = isParentWithAllChildrenClosed;
12
+ exports.checkWorkingTreeClean = checkWorkingTreeClean;
13
+ const git_js_1 = require("./git.js");
14
+ const ticket_js_1 = require("./ticket.js");
15
+ function expectedBranchFor(ticket) {
16
+ if (ticket.work?.branch)
17
+ return ticket.work.branch;
18
+ if (ticket.relationships?.parent)
19
+ return `todo/${ticket.relationships.parent}`;
20
+ return `todo/${ticket.id}`;
21
+ }
22
+ function checkOnExpectedBranch(ticket, currentBranch) {
23
+ const expected = expectedBranchFor(ticket);
24
+ if (currentBranch === expected)
25
+ return { ok: true };
26
+ return {
27
+ ok: false,
28
+ message: `Refusing to close ${ticket.id}: HEAD is on '${currentBranch}', expected '${expected}'.\n` +
29
+ ` Run \`todo work ${ticket.id}\` to switch to the right branch, ` +
30
+ `or pass --force to override.`,
31
+ };
32
+ }
33
+ function checkBranchHasTodoCommit(ticket, repoRoot, commitPrefix) {
34
+ const base = ticket.work?.base_branch ?? (0, git_js_1.getDefaultBranch)(repoRoot);
35
+ const messages = (0, git_js_1.getCommitMessagesBetween)(base, "HEAD", repoRoot);
36
+ const needle = `${commitPrefix}${ticket.id}`;
37
+ const found = messages.some((m) => m.includes(needle));
38
+ if (found)
39
+ return { ok: true };
40
+ return {
41
+ ok: false,
42
+ message: `Refusing to close ${ticket.id}: no commit since ${base} has message containing '${needle}'.\n` +
43
+ ` Either amend a commit to include the prefix, or pass --force ` +
44
+ `if this ticket genuinely has no code change attached.`,
45
+ };
46
+ }
47
+ /**
48
+ * True iff the ticket has at least one child AND every child is in a
49
+ * terminal state (done/wontfix/duplicate). Parents in this shape carry no
50
+ * code commit of their own — the work lives in children whose commits use
51
+ * `todo:<child-id>` prefixes — so the commit-prefix branch guard should
52
+ * skip them. Children with missing files are treated as non-terminal so
53
+ * the guard stays conservative.
54
+ */
55
+ function isParentWithAllChildrenClosed(ticket, repoRoot) {
56
+ const children = ticket.relationships?.children ?? [];
57
+ if (children.length === 0)
58
+ return false;
59
+ for (const childId of children) {
60
+ let child;
61
+ try {
62
+ child = (0, ticket_js_1.readTicket)(repoRoot, childId);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ if (!ticket_js_1.TERMINAL_STATES.includes(child.state))
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ function checkWorkingTreeClean(repoRoot) {
73
+ if (!(0, git_js_1.hasUncommittedChanges)(repoRoot))
74
+ return { ok: true };
75
+ return {
76
+ ok: false,
77
+ message: "Refusing to switch branches: working tree has uncommitted changes.\n" +
78
+ " Commit, stash, or discard them first.",
79
+ };
80
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
4
6
  const commander_1 = require("commander");
5
7
  const analyze_js_1 = require("./commands/analyze.js");
6
8
  const close_js_1 = require("./commands/close.js");
@@ -8,19 +10,25 @@ const dedup_js_1 = require("./commands/dedup.js");
8
10
  const edit_js_1 = require("./commands/edit.js");
9
11
  const export_js_1 = require("./commands/export.js");
10
12
  const init_js_1 = require("./commands/init.js");
13
+ const install_hooks_js_1 = require("./commands/install-hooks.js");
11
14
  const link_js_1 = require("./commands/link.js");
12
15
  const list_js_1 = require("./commands/list.js");
13
16
  const new_js_1 = require("./commands/new.js");
14
17
  const next_js_1 = require("./commands/next.js");
18
+ const pr_js_1 = require("./commands/pr.js");
15
19
  const scan_js_1 = require("./commands/scan.js");
16
20
  const show_js_1 = require("./commands/show.js");
21
+ const sync_js_1 = require("./commands/sync.js");
17
22
  const transition_js_1 = require("./commands/transition.js");
18
23
  const work_js_1 = require("./commands/work.js");
24
+ // Read version from the package.json shipped alongside dist/. Avoids drift
25
+ // between the published npm version and what `todo --version` prints.
26
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf8"));
19
27
  const program = new commander_1.Command();
20
28
  program
21
29
  .name("todo")
22
30
  .description("Git-native work tracking for coding agents")
23
- .version("1.0.0");
31
+ .version(pkg.version);
24
32
  (0, init_js_1.registerInit)(program);
25
33
  (0, new_js_1.registerNew)(program);
26
34
  (0, list_js_1.registerList)(program);
@@ -35,4 +43,7 @@ program
35
43
  (0, link_js_1.registerLink)(program);
36
44
  (0, scan_js_1.registerScan)(program);
37
45
  (0, dedup_js_1.registerDedup)(program);
46
+ (0, install_hooks_js_1.registerInstallHooks)(program);
47
+ (0, sync_js_1.registerSync)(program);
48
+ (0, pr_js_1.registerPr)(program);
38
49
  program.parse(process.argv);
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerClose = registerClose;
4
+ const branch_guard_js_1 = require("../branch-guard.js");
5
+ const config_js_1 = require("../config.js");
4
6
  const context_js_1 = require("../context.js");
5
7
  const errors_js_1 = require("../errors.js");
6
8
  const git_js_1 = require("../git.js");
@@ -14,11 +16,40 @@ function registerClose(program) {
14
16
  .option("--test <file::func>", "test file and function (colon-separated)")
15
17
  .option("--note <text>", "resolution note")
16
18
  .option("--checkout", "git checkout base_branch after closing")
19
+ .option("--force", "skip branch and commit-message guards")
17
20
  .action((id, opts) => {
18
21
  const ctx = (0, context_js_1.getContext)(true);
19
- const { repoRoot } = ctx;
22
+ const { repoRoot, config } = ctx;
20
23
  try {
21
24
  const ticket = (0, ticket_js_1.readTicketByPrefix)(repoRoot, id);
25
+ // Branch-convention guards (skippable with --force).
26
+ if (!opts.force) {
27
+ let currentBranch = "";
28
+ try {
29
+ currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
30
+ }
31
+ catch {
32
+ // Detached HEAD or other; treat as a branch mismatch.
33
+ }
34
+ const branchCheck = (0, branch_guard_js_1.checkOnExpectedBranch)(ticket, currentBranch);
35
+ if (!branchCheck.ok) {
36
+ console.error(`Error: ${branchCheck.message}`);
37
+ process.exit(1);
38
+ }
39
+ // Parents with all-terminal children carry no code commit of
40
+ // their own — skip the commit-prefix check for them. The
41
+ // branch-match check above still fires.
42
+ if (!(0, branch_guard_js_1.isParentWithAllChildrenClosed)(ticket, repoRoot)) {
43
+ const commitCheck = (0, branch_guard_js_1.checkBranchHasTodoCommit)(ticket, repoRoot, (0, config_js_1.getCommitPrefix)(config));
44
+ if (!commitCheck.ok) {
45
+ console.error(`Error: ${commitCheck.message}`);
46
+ process.exit(1);
47
+ }
48
+ }
49
+ }
50
+ else {
51
+ console.error("Warning: --force used; skipping branch guards.");
52
+ }
22
53
  // Resolve commit
23
54
  let commit;
24
55
  if (opts.commit) {
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerInstallHooks(program: Command): void;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerInstallHooks = registerInstallHooks;
4
+ const config_js_1 = require("../config.js");
5
+ const context_js_1 = require("../context.js");
6
+ const errors_js_1 = require("../errors.js");
7
+ const hooks_js_1 = require("../hooks.js");
8
+ function reportInstall(name, res) {
9
+ if (res.action === "refused") {
10
+ console.error(`Error: ${res.reason}`);
11
+ return false;
12
+ }
13
+ console.log(`${name}: ${res.action} ${res.path}`);
14
+ return true;
15
+ }
16
+ function reportUninstall(name, res) {
17
+ if (res.action === "refused") {
18
+ console.error(`Error: ${res.reason}`);
19
+ return false;
20
+ }
21
+ console.log(res.reason
22
+ ? `${name}: ${res.reason} (${res.path})`
23
+ : `${name}: removed ${res.path}`);
24
+ return true;
25
+ }
26
+ function selectHooks(opts) {
27
+ if (opts.onlySync)
28
+ return ["post-commit"];
29
+ if (opts.withSync)
30
+ return ["prepare-commit-msg", "post-commit"];
31
+ return ["prepare-commit-msg"];
32
+ }
33
+ function renderHook(name, config) {
34
+ if (name === "prepare-commit-msg") {
35
+ return (0, hooks_js_1.renderPrepareCommitMsg)((0, config_js_1.getCommitPrefix)(config));
36
+ }
37
+ return (0, hooks_js_1.renderPostCommit)();
38
+ }
39
+ function registerInstallHooks(program) {
40
+ program
41
+ .command("install-hooks")
42
+ .description("Install git hooks enforcing todo conventions (prepare-commit-msg, optionally post-commit auto-sync)")
43
+ .option("--force", "overwrite hooks not managed by todo")
44
+ .option("--uninstall", "remove managed hooks instead of installing")
45
+ .option("--with-sync", "also install the post-commit hook that runs `todo sync --quiet` after each commit")
46
+ .option("--only-sync", "install or uninstall only the post-commit auto-sync hook (skip prepare-commit-msg)")
47
+ .action((opts) => {
48
+ const ctx = (0, context_js_1.getContext)(true);
49
+ const { repoRoot, config } = ctx;
50
+ try {
51
+ const hooks = selectHooks(opts);
52
+ let failed = false;
53
+ for (const name of hooks) {
54
+ if (opts.uninstall) {
55
+ const res = (0, hooks_js_1.uninstallHook)(repoRoot, name, {
56
+ force: !!opts.force,
57
+ });
58
+ if (!reportUninstall(name, res))
59
+ failed = true;
60
+ }
61
+ else {
62
+ const content = renderHook(name, config);
63
+ const res = (0, hooks_js_1.installHook)(repoRoot, name, content, {
64
+ force: !!opts.force,
65
+ });
66
+ if (!reportInstall(name, res))
67
+ failed = true;
68
+ }
69
+ }
70
+ if (failed)
71
+ process.exit(1);
72
+ }
73
+ catch (err) {
74
+ (0, errors_js_1.handleError)(err);
75
+ }
76
+ });
77
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerPr(program: Command): void;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerPr = registerPr;
4
+ const context_js_1 = require("../context.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ const git_js_1 = require("../git.js");
7
+ const pr_js_1 = require("../pr.js");
8
+ const ticket_js_1 = require("../ticket.js");
9
+ function registerPr(program) {
10
+ program
11
+ .command("pr")
12
+ .description("Push the current todo/<id> branch and open (or update) a GitHub PR")
13
+ .option("--base <branch>", "PR base branch (default: repo default)")
14
+ .option("--draft", "open the PR as a draft")
15
+ .action((opts) => {
16
+ const ctx = (0, context_js_1.getContext)(true);
17
+ const { repoRoot } = ctx;
18
+ try {
19
+ let branch;
20
+ try {
21
+ branch = (0, git_js_1.getCurrentBranch)(repoRoot);
22
+ }
23
+ catch {
24
+ console.error("Error: could not resolve current branch (detached HEAD?).");
25
+ process.exit(1);
26
+ }
27
+ const id = (0, pr_js_1.branchToTicketId)(branch);
28
+ if (!id) {
29
+ console.error(`Error: not on a todo/<id> branch (HEAD is on '${branch}').\n` +
30
+ " Run `todo work <id>` first.");
31
+ process.exit(1);
32
+ }
33
+ let ticket;
34
+ try {
35
+ ticket = (0, ticket_js_1.readTicket)(repoRoot, id);
36
+ }
37
+ catch {
38
+ console.error(`Error: branch '${branch}' references ticket '${id}' but no .todo/ ` +
39
+ "file exists for it. Was it deleted?");
40
+ process.exit(1);
41
+ }
42
+ const outcome = (0, pr_js_1.runPr)(repoRoot, branch, ticket, { base: opts.base, draft: !!opts.draft }, (0, pr_js_1.defaultPrEnv)(repoRoot));
43
+ if (outcome.kind === "error") {
44
+ console.error(`Error: ${outcome.message}`);
45
+ process.exit(1);
46
+ }
47
+ console.log(outcome.kind === "created"
48
+ ? `Opened PR: ${outcome.url}`
49
+ : `Updated PR: ${outcome.url}`);
50
+ }
51
+ catch (err) {
52
+ (0, errors_js_1.handleError)(err);
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerSync(program: Command): void;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerSync = registerSync;
4
+ const context_js_1 = require("../context.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ const hermes_js_1 = require("../hermes.js");
7
+ const ticket_js_1 = require("../ticket.js");
8
+ function summariseAction(a) {
9
+ if (a.kind === "create") {
10
+ return `+ create ${a.ticket.id} (${a.ticket.state}) → ${a.targetStatus}`;
11
+ }
12
+ if (a.kind === "update") {
13
+ return `~ update ${a.ticket.id} ${a.fromStatus} → ${a.toStatus}`;
14
+ }
15
+ if (a.kind === "noop") {
16
+ return `= ${a.ticket.id} unchanged (${a.status})`;
17
+ }
18
+ return `! skip ${a.ticket.id}: ${a.reason}`;
19
+ }
20
+ function registerSync(program) {
21
+ program
22
+ .command("sync")
23
+ .description("Push ticket state to a Hermes Kanban board")
24
+ .option("--dry-run", "show what would change without contacting Hermes")
25
+ .option("--quiet", "only print errors")
26
+ .option("--board <slug>", "override the configured board slug")
27
+ .action(async (opts) => {
28
+ const ctx = (0, context_js_1.getContext)(true);
29
+ const { repoRoot, config } = ctx;
30
+ try {
31
+ const resolved = (0, hermes_js_1.resolveHermesConfig)(config.hermes, process.env, opts.board);
32
+ if ("error" in resolved) {
33
+ console.error(`Error: ${resolved.error}`);
34
+ process.exit(1);
35
+ }
36
+ const open = (0, ticket_js_1.listTickets)(repoRoot, "open");
37
+ const done = (0, ticket_js_1.listTickets)(repoRoot, "done");
38
+ const tickets = [...open, ...done];
39
+ const client = (0, hermes_js_1.makeKanbanClient)(resolved);
40
+ const existingByHermesId = new Map();
41
+ if (!opts.dryRun) {
42
+ const board = await client.getBoard();
43
+ for (const col of board.columns) {
44
+ for (const task of col.tasks) {
45
+ // Tasks come back with their per-column status; trust it.
46
+ existingByHermesId.set(task.id, { ...task, status: col.name });
47
+ }
48
+ }
49
+ }
50
+ else {
51
+ // In dry-run we can't reach the server; treat ids we have cached
52
+ // in tickets as existing (best-effort preview only).
53
+ for (const t of tickets) {
54
+ const hid = t.external_refs?.hermes_task_id;
55
+ if (hid) {
56
+ existingByHermesId.set(hid, {
57
+ id: hid,
58
+ title: t.summary,
59
+ status: "?",
60
+ });
61
+ }
62
+ }
63
+ }
64
+ const actions = (0, hermes_js_1.planSyncActions)(tickets, existingByHermesId);
65
+ if (!opts.quiet) {
66
+ for (const a of actions)
67
+ console.log(summariseAction(a));
68
+ }
69
+ if (opts.dryRun) {
70
+ if (!opts.quiet)
71
+ console.log("(dry run — no requests made)");
72
+ return;
73
+ }
74
+ const stats = await (0, hermes_js_1.applySyncActions)(actions, client, (ticket, hermesId) => {
75
+ const refs = ticket.external_refs ?? {};
76
+ refs.hermes_task_id = hermesId;
77
+ refs.hermes_board = resolved.board;
78
+ ticket.external_refs = refs;
79
+ ticket.updated_at = new Date().toISOString();
80
+ (0, ticket_js_1.writeTicket)(repoRoot, ticket);
81
+ });
82
+ if (!opts.quiet) {
83
+ console.log(`done: ${stats.created} created, ${stats.updated} updated, ` +
84
+ `${stats.noop} unchanged, ${stats.skipped} skipped`);
85
+ }
86
+ // fetch keep-alive sockets can hold the event loop open for ~30s
87
+ // past completion; for a CLI we want a snappy exit.
88
+ process.exit(0);
89
+ }
90
+ catch (err) {
91
+ (0, errors_js_1.handleError)(err);
92
+ }
93
+ });
94
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerWork = registerWork;
4
+ const branch_guard_js_1 = require("../branch-guard.js");
4
5
  const context_js_1 = require("../context.js");
5
6
  const errors_js_1 = require("../errors.js");
6
7
  const git_js_1 = require("../git.js");
@@ -51,6 +52,27 @@ function registerWork(program) {
51
52
  }
52
53
  }
53
54
  const defaultBranch = (0, git_js_1.getDefaultBranch)(repoRoot);
55
+ // Dirty-tree guard: refuse to leave a different todo/* branch
56
+ // with uncommitted work. We let `main`/other branches through —
57
+ // only the cross-ticket case is the one that loses work.
58
+ if (!opts.skipBranch) {
59
+ let currentBranch = "";
60
+ try {
61
+ currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
62
+ }
63
+ catch {
64
+ currentBranch = "";
65
+ }
66
+ const onDifferentTodoBranch = currentBranch.startsWith("todo/") && currentBranch !== branch;
67
+ if (onDifferentTodoBranch) {
68
+ const clean = (0, branch_guard_js_1.checkWorkingTreeClean)(repoRoot);
69
+ if (!clean.ok) {
70
+ console.error(`Error: ${clean.message}\n You are on ${currentBranch}; ` +
71
+ `target branch is ${branch}.`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ }
54
76
  if (opts.skipBranch) {
55
77
  // --no-branch: orchestrator mode — activate on current branch without any git ops
56
78
  const currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
package/dist/git.d.ts CHANGED
@@ -17,3 +17,6 @@ export declare function getLastCommitForFile(path: string, cwd?: string): string
17
17
  export declare function createBranch(name: string, cwd?: string): void;
18
18
  export declare function checkoutBranch(name: string, cwd?: string): void;
19
19
  export declare function getGitUserName(cwd?: string): string;
20
+ export declare function hasUncommittedChanges(cwd?: string): boolean;
21
+ export declare function getCommitMessagesBetween(base: string, head: string, cwd?: string): string[];
22
+ export declare function getGitDir(cwd?: string): string;
package/dist/git.js CHANGED
@@ -17,6 +17,9 @@ exports.getLastCommitForFile = getLastCommitForFile;
17
17
  exports.createBranch = createBranch;
18
18
  exports.checkoutBranch = checkoutBranch;
19
19
  exports.getGitUserName = getGitUserName;
20
+ exports.hasUncommittedChanges = hasUncommittedChanges;
21
+ exports.getCommitMessagesBetween = getCommitMessagesBetween;
22
+ exports.getGitDir = getGitDir;
20
23
  const node_child_process_1 = require("node:child_process");
21
24
  class GitError extends Error {
22
25
  cause;
@@ -117,3 +120,24 @@ function checkoutBranch(name, cwd = process.cwd()) {
117
120
  function getGitUserName(cwd = process.cwd()) {
118
121
  return exec(["config", "user.name"], cwd);
119
122
  }
123
+ function hasUncommittedChanges(cwd = process.cwd()) {
124
+ const out = exec(["status", "--porcelain"], cwd);
125
+ return out.length > 0;
126
+ }
127
+ function getCommitMessagesBetween(base, head, cwd = process.cwd()) {
128
+ try {
129
+ const out = exec(["log", `${base}..${head}`, "--format=%s%n%b%n--END--"], cwd);
130
+ if (!out)
131
+ return [];
132
+ return out
133
+ .split("\n--END--")
134
+ .map((s) => s.trim())
135
+ .filter((s) => s.length > 0);
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ function getGitDir(cwd = process.cwd()) {
142
+ return exec(["rev-parse", "--git-dir"], cwd);
143
+ }
@@ -0,0 +1,59 @@
1
+ import type { HermesConfig, State, Ticket } from "./types.js";
2
+ export declare const STATE_TO_KANBAN: Record<State, string>;
3
+ export interface ResolvedHermesConfig {
4
+ dashboard_url: string;
5
+ board: string;
6
+ session_token: string;
7
+ }
8
+ export declare function resolveHermesConfig(cfg: HermesConfig | undefined, env?: NodeJS.ProcessEnv, boardOverride?: string): ResolvedHermesConfig | {
9
+ error: string;
10
+ };
11
+ export interface KanbanTask {
12
+ id: string;
13
+ title: string;
14
+ status: string;
15
+ body?: string;
16
+ }
17
+ export interface KanbanClient {
18
+ getBoard(): Promise<{
19
+ columns: {
20
+ name: string;
21
+ tasks: KanbanTask[];
22
+ }[];
23
+ }>;
24
+ getTask(id: string): Promise<KanbanTask | null>;
25
+ createTask(payload: {
26
+ title: string;
27
+ body?: string;
28
+ idempotency_key?: string;
29
+ }): Promise<KanbanTask>;
30
+ updateStatus(id: string, status: string): Promise<KanbanTask>;
31
+ }
32
+ export declare function makeKanbanClient(cfg: ResolvedHermesConfig, fetchImpl?: typeof fetch): KanbanClient;
33
+ export type SyncAction = {
34
+ kind: "create";
35
+ ticket: Ticket;
36
+ targetStatus: string;
37
+ } | {
38
+ kind: "update";
39
+ ticket: Ticket;
40
+ hermesId: string;
41
+ fromStatus: string;
42
+ toStatus: string;
43
+ } | {
44
+ kind: "noop";
45
+ ticket: Ticket;
46
+ hermesId: string;
47
+ status: string;
48
+ } | {
49
+ kind: "skip";
50
+ ticket: Ticket;
51
+ reason: string;
52
+ };
53
+ export declare function planSyncActions(tickets: Ticket[], existingByHermesId: Map<string, KanbanTask>): SyncAction[];
54
+ export declare function applySyncActions(actions: SyncAction[], client: KanbanClient, onPersistId: (ticket: Ticket, hermesId: string) => void): Promise<{
55
+ created: number;
56
+ updated: number;
57
+ skipped: number;
58
+ noop: number;
59
+ }>;