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

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.
@@ -0,0 +1,9 @@
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
+ export declare function checkWorkingTreeClean(repoRoot: string): BranchCheck;
@@ -0,0 +1,53 @@
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.checkWorkingTreeClean = checkWorkingTreeClean;
12
+ const git_js_1 = require("./git.js");
13
+ function expectedBranchFor(ticket) {
14
+ if (ticket.work?.branch)
15
+ return ticket.work.branch;
16
+ if (ticket.relationships?.parent)
17
+ return `todo/${ticket.relationships.parent}`;
18
+ return `todo/${ticket.id}`;
19
+ }
20
+ function checkOnExpectedBranch(ticket, currentBranch) {
21
+ const expected = expectedBranchFor(ticket);
22
+ if (currentBranch === expected)
23
+ return { ok: true };
24
+ return {
25
+ ok: false,
26
+ message: `Refusing to close ${ticket.id}: HEAD is on '${currentBranch}', expected '${expected}'.\n` +
27
+ ` Run \`todo work ${ticket.id}\` to switch to the right branch, ` +
28
+ `or pass --force to override.`,
29
+ };
30
+ }
31
+ function checkBranchHasTodoCommit(ticket, repoRoot, commitPrefix) {
32
+ const base = ticket.work?.base_branch ?? (0, git_js_1.getDefaultBranch)(repoRoot);
33
+ const messages = (0, git_js_1.getCommitMessagesBetween)(base, "HEAD", repoRoot);
34
+ const needle = `${commitPrefix}${ticket.id}`;
35
+ const found = messages.some((m) => m.includes(needle));
36
+ if (found)
37
+ return { ok: true };
38
+ return {
39
+ ok: false,
40
+ message: `Refusing to close ${ticket.id}: no commit since ${base} has message containing '${needle}'.\n` +
41
+ ` Either amend a commit to include the prefix, or pass --force ` +
42
+ `if this ticket genuinely has no code change attached.`,
43
+ };
44
+ }
45
+ function checkWorkingTreeClean(repoRoot) {
46
+ if (!(0, git_js_1.hasUncommittedChanges)(repoRoot))
47
+ return { ok: true };
48
+ return {
49
+ ok: false,
50
+ message: "Refusing to switch branches: working tree has uncommitted changes.\n" +
51
+ " Commit, stash, or discard them first.",
52
+ };
53
+ }
package/dist/cli.js CHANGED
@@ -8,12 +8,14 @@ const dedup_js_1 = require("./commands/dedup.js");
8
8
  const edit_js_1 = require("./commands/edit.js");
9
9
  const export_js_1 = require("./commands/export.js");
10
10
  const init_js_1 = require("./commands/init.js");
11
+ const install_hooks_js_1 = require("./commands/install-hooks.js");
11
12
  const link_js_1 = require("./commands/link.js");
12
13
  const list_js_1 = require("./commands/list.js");
13
14
  const new_js_1 = require("./commands/new.js");
14
15
  const next_js_1 = require("./commands/next.js");
15
16
  const scan_js_1 = require("./commands/scan.js");
16
17
  const show_js_1 = require("./commands/show.js");
18
+ const sync_js_1 = require("./commands/sync.js");
17
19
  const transition_js_1 = require("./commands/transition.js");
18
20
  const work_js_1 = require("./commands/work.js");
19
21
  const program = new commander_1.Command();
@@ -35,4 +37,6 @@ program
35
37
  (0, link_js_1.registerLink)(program);
36
38
  (0, scan_js_1.registerScan)(program);
37
39
  (0, dedup_js_1.registerDedup)(program);
40
+ (0, install_hooks_js_1.registerInstallHooks)(program);
41
+ (0, sync_js_1.registerSync)(program);
38
42
  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,35 @@ 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
+ const commitCheck = (0, branch_guard_js_1.checkBranchHasTodoCommit)(ticket, repoRoot, (0, config_js_1.getCommitPrefix)(config));
40
+ if (!commitCheck.ok) {
41
+ console.error(`Error: ${commitCheck.message}`);
42
+ process.exit(1);
43
+ }
44
+ }
45
+ else {
46
+ console.error("Warning: --force used; skipping branch guards.");
47
+ }
22
48
  // Resolve commit
23
49
  let commit;
24
50
  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 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
+ }>;
package/dist/hermes.js ADDED
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ // Hermes Kanban push-sync client.
3
+ //
4
+ // .todo/ remains the source of truth; this module only pushes state out
5
+ // to a Hermes dashboard's Kanban plugin API at /api/plugins/kanban/.
6
+ // We persist the Hermes task id on each ticket under
7
+ // ``external_refs.hermes_task_id`` so re-syncs hit the same card.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.STATE_TO_KANBAN = void 0;
10
+ exports.resolveHermesConfig = resolveHermesConfig;
11
+ exports.makeKanbanClient = makeKanbanClient;
12
+ exports.planSyncActions = planSyncActions;
13
+ exports.applySyncActions = applySyncActions;
14
+ exports.STATE_TO_KANBAN = {
15
+ open: "todo",
16
+ active: "running",
17
+ blocked: "blocked",
18
+ done: "done",
19
+ wontfix: "archived",
20
+ duplicate: "archived",
21
+ };
22
+ function resolveHermesConfig(cfg, env = process.env, boardOverride) {
23
+ if (!cfg) {
24
+ return {
25
+ error: "Hermes config missing. Add a `hermes` block to .todo/config.json:\n" +
26
+ ' { "hermes": { "dashboard_url": "http://localhost:8765", ' +
27
+ '"board": "<slug>", "session_token": "<token>" } }\n' +
28
+ " session_token can also come from HERMES_SESSION_TOKEN env.",
29
+ };
30
+ }
31
+ const dashboard_url = cfg.dashboard_url?.replace(/\/$/, "");
32
+ const board = boardOverride ?? cfg.board;
33
+ const session_token = cfg.session_token ?? env["HERMES_SESSION_TOKEN"];
34
+ if (!dashboard_url)
35
+ return { error: "Hermes config missing dashboard_url." };
36
+ if (!board)
37
+ return { error: "Hermes config missing board." };
38
+ if (!session_token) {
39
+ return {
40
+ error: "Hermes session token missing. Set hermes.session_token in " +
41
+ ".todo/config.json or HERMES_SESSION_TOKEN in the environment.",
42
+ };
43
+ }
44
+ return { dashboard_url, board, session_token };
45
+ }
46
+ function makeKanbanClient(cfg, fetchImpl = fetch) {
47
+ const headers = {
48
+ Authorization: `Bearer ${cfg.session_token}`,
49
+ "Content-Type": "application/json",
50
+ };
51
+ const base = `${cfg.dashboard_url}/api/plugins/kanban`;
52
+ const boardQ = `?board=${encodeURIComponent(cfg.board)}`;
53
+ async function expectJson(res) {
54
+ if (!res.ok) {
55
+ const text = await res.text().catch(() => "");
56
+ throw new Error(`Hermes ${res.status} ${res.statusText}: ${text || "(no body)"}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ return {
61
+ async getBoard() {
62
+ const res = await fetchImpl(`${base}/board${boardQ}`, { headers });
63
+ return (await expectJson(res));
64
+ },
65
+ async getTask(id) {
66
+ const res = await fetchImpl(`${base}/tasks/${id}${boardQ}`, { headers });
67
+ if (res.status === 404)
68
+ return null;
69
+ const body = (await expectJson(res));
70
+ return body.task;
71
+ },
72
+ async createTask(payload) {
73
+ const res = await fetchImpl(`${base}/tasks${boardQ}`, {
74
+ method: "POST",
75
+ headers,
76
+ body: JSON.stringify(payload),
77
+ });
78
+ const body = (await expectJson(res));
79
+ return body.task;
80
+ },
81
+ async updateStatus(id, status) {
82
+ const res = await fetchImpl(`${base}/tasks/${id}${boardQ}`, {
83
+ method: "PATCH",
84
+ headers,
85
+ body: JSON.stringify({ status }),
86
+ });
87
+ const body = (await expectJson(res));
88
+ return body.task;
89
+ },
90
+ };
91
+ }
92
+ function planSyncActions(tickets, existingByHermesId) {
93
+ const actions = [];
94
+ for (const t of tickets) {
95
+ const targetStatus = exports.STATE_TO_KANBAN[t.state];
96
+ if (!targetStatus) {
97
+ actions.push({
98
+ kind: "skip",
99
+ ticket: t,
100
+ reason: `no kanban mapping for state '${t.state}'`,
101
+ });
102
+ continue;
103
+ }
104
+ const hermesId = t.external_refs?.hermes_task_id;
105
+ if (!hermesId) {
106
+ actions.push({ kind: "create", ticket: t, targetStatus });
107
+ continue;
108
+ }
109
+ const existing = existingByHermesId.get(hermesId);
110
+ if (!existing) {
111
+ // Cached id no longer exists on the board; re-create.
112
+ actions.push({ kind: "create", ticket: t, targetStatus });
113
+ continue;
114
+ }
115
+ if (existing.status === targetStatus) {
116
+ actions.push({
117
+ kind: "noop",
118
+ ticket: t,
119
+ hermesId,
120
+ status: targetStatus,
121
+ });
122
+ }
123
+ else {
124
+ actions.push({
125
+ kind: "update",
126
+ ticket: t,
127
+ hermesId,
128
+ fromStatus: existing.status,
129
+ toStatus: targetStatus,
130
+ });
131
+ }
132
+ }
133
+ return actions;
134
+ }
135
+ async function applySyncActions(actions, client, onPersistId) {
136
+ let created = 0;
137
+ let updated = 0;
138
+ let skipped = 0;
139
+ let noop = 0;
140
+ for (const a of actions) {
141
+ if (a.kind === "create") {
142
+ const task = await client.createTask({
143
+ title: a.ticket.summary,
144
+ body: a.ticket.description,
145
+ idempotency_key: a.ticket.id,
146
+ });
147
+ onPersistId(a.ticket, task.id);
148
+ if (task.status !== a.targetStatus) {
149
+ await client.updateStatus(task.id, a.targetStatus);
150
+ }
151
+ created++;
152
+ }
153
+ else if (a.kind === "update") {
154
+ await client.updateStatus(a.hermesId, a.toStatus);
155
+ updated++;
156
+ }
157
+ else if (a.kind === "noop") {
158
+ noop++;
159
+ }
160
+ else {
161
+ skipped++;
162
+ }
163
+ }
164
+ return { created, updated, skipped, noop };
165
+ }
@@ -0,0 +1,23 @@
1
+ export type HookName = "prepare-commit-msg" | "post-commit";
2
+ export declare const SENTINEL_PREFIX = "# @todo-managed-hook";
3
+ export declare function hookSentinel(name: HookName, version: number): string;
4
+ export declare function renderPrepareCommitMsg(commitPrefix: string): string;
5
+ export declare function renderPostCommit(): string;
6
+ export interface HookStatus {
7
+ exists: boolean;
8
+ managed: boolean;
9
+ path: string;
10
+ }
11
+ export declare function hookPath(repoRoot: string, name: HookName): string;
12
+ export declare function readHookStatus(repoRoot: string, name: HookName): HookStatus;
13
+ export interface InstallResult {
14
+ action: "created" | "replaced" | "refused" | "removed";
15
+ path: string;
16
+ reason?: string;
17
+ }
18
+ export declare function installHook(repoRoot: string, name: HookName, content: string, opts?: {
19
+ force?: boolean;
20
+ }): InstallResult;
21
+ export declare function uninstallHook(repoRoot: string, name: HookName, opts?: {
22
+ force?: boolean;
23
+ }): InstallResult;