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

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
@@ -148,13 +148,32 @@ For `main`, enable:
148
148
 
149
149
  - **Require a pull request before merging** — the agent stops at the PR; you click Merge.
150
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`.
151
+ - **Require linear history** — enforces the skill's `--no-ff` rule for *deliverable* commits, keeping the resolution-commit SHAs stored in `.todo/done/<id>.json` resolvable. (The `.todo/`-only state commits carry no such reference and are safe to squash or batch.)
152
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
153
 
154
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
155
 
156
156
  ---
157
157
 
158
+ ## Configuration
159
+
160
+ Settings live in `.todo/config.json` (created by `todo init`). The keys that shape the git workflow:
161
+
162
+ | Key | Values | Default | Effect |
163
+ |-----|--------|---------|--------|
164
+ | `behavior.commit_prefix` | string | `todo:` | Prefix that ties commits to tickets |
165
+ | `behavior.branch_mode` | `per-ticket` \| `managed` | `per-ticket` | `per-ticket`: todo manages a `todo/<id>` branch and runs the branch guards. `managed`: **you** (or a PR flow) own branching — `todo work` does no git ops and `todo close` drops the branch guards and auto-records state |
166
+ | `behavior.guard_mode` | `advisory` \| `strict` | `advisory` | `advisory`: a failed branch/commit-prefix guard warns and proceeds. `strict`: it's a hard error (exit 1) — opt in when you want git to enforce the conventions |
167
+
168
+ **Pick a profile:**
169
+ - **PR-based / protected `main`** → `branch_mode: "managed"`. todo becomes a pure state-and-rationale tracker and leaves git entirely to you.
170
+ - **Agent-driven, todo owns the branches** → `branch_mode: "per-ticket"` + `guard_mode: "strict"` (this repo's own setting) to make the conventions enforced rather than suggested.
171
+ - **Default** (`per-ticket` + `advisory`) suits solo/local work: the structure is there, but a guard never blocks you.
172
+
173
+ `todo doctor` reconciles committed `.todo/` state against git reality (missing/unreachable resolution commits, done-parents with open children, active tickets whose branch is gone, misfiled tickets, dangling references). Run it in CI — it exits non-zero on errors (`--strict` also fails on warnings).
174
+
175
+ ---
176
+
158
177
  ## Ticket Types
159
178
 
160
179
  | Type | When to use |
@@ -164,6 +183,7 @@ Once protected, the closing step becomes `todo pr` (push branch + open PR + stop
164
183
  | `refactor` | Code change with no behavior change |
165
184
  | `chore` | Tooling, config, dependency updates, cleanup |
166
185
  | `debt` | Known-bad code that needs to be addressed later |
186
+ | `investigation` | A question to answer or decision to make; the deliverable is a documented conclusion, not code |
167
187
 
168
188
  ---
169
189
 
@@ -178,9 +198,12 @@ What's required before closing each ticket type:
178
198
  | `refactor` | Commit SHA, tests still passing |
179
199
  | `chore` | Commit SHA |
180
200
  | `debt` | Commit SHA, note explaining what changed |
201
+ | `investigation` | A resolution **note** (the conclusion); commit optional, no test required |
181
202
 
182
203
  Close command: `todo close <id> --note "what you did" --test tests/foo.ts::test_name`
183
204
 
205
+ `todo close --commit-state` records the `.todo/` state change in the same step, so a failed close can never be followed by a stray "close" commit. It's the recommended way to close.
206
+
184
207
  ---
185
208
 
186
209
  ## Commit Message Convention
@@ -203,9 +226,9 @@ The `<id>` is the full 8-char ticket ID. Always include it. This ties commits to
203
226
 
204
227
  1. **Closing before committing** — `todo close` captures HEAD. If you haven't committed the fix, the resolution commit is wrong. Always commit code first.
205
228
 
206
- 2. **Forgetting `git add .todo/`** — Ticket files live in `.todo/`. They must be committed. After any `todo` command that writes, stage `.todo/`.
229
+ 2. **Forgetting `git add .todo/`** — Ticket files live in `.todo/`. They must be committed. After any `todo` command that writes, stage `.todo/`. (`todo close --commit-state` does this for you atomically — see Workflow — which is the recommended way to avoid this footgun entirely.)
207
230
 
208
- 3. **Squash merging breaks commit refs** — Tickets store resolution commits. Squash merging replaces them with a new SHA. The linked commit disappears. Use `--no-ff` merges or update the resolution commit after squash.
231
+ 3. **Squash merging can break commit refs** — A ticket's `resolution.commit` points at a real *code* commit. Squash-merging that commit replaces its SHA and orphans the reference, so never squash the deliverable commits — use `--no-ff` (or update the resolution commit after a squash). This does **not** apply to the `.todo/`-only state commits (`todo:<id> — close`, plan commits): nothing points at them — the resolution SHA lives *inside* the ticket file, not in `.todo/` history — so they may be squashed or batched (e.g. one `.todo/` commit per PR) to keep mainline history clean.
209
232
 
210
233
  4. **Using a short prefix when IDs are ambiguous** — If two tickets share a prefix, commands fail. Use more characters of the ID.
211
234
 
package/README.md CHANGED
@@ -1,4 +1,207 @@
1
1
  # todo
2
- Making working on work work
3
2
 
4
- https://www.npmjs.com/package/@4-r-c-4-n-4/todo
3
+ **Git-native work tracking for coding agents.**
4
+
5
+ [![npm](https://img.shields.io/npm/v/@4-r-c-4-n-4/todo)](https://www.npmjs.com/package/@4-r-c-4-n-4/todo)
6
+
7
+ `todo` is a ticket tracker that lives *inside* your repository. Tickets are JSON files committed alongside your code in `.todo/`, so work state travels with the branch, survives clones, and is reviewable in a diff. It's designed so a coding agent can run the whole loop — capture a problem, attach reasoning, do the work, and close with proof — but it's just as usable by hand.
8
+
9
+ Two things make it more than a TODO list:
10
+
11
+ - **A rationale trail.** `todo analyze` attaches structured reasoning (blame → hypothesis → evidence → conclusion) to a ticket, turning it into a lightweight ADR that's there when you reopen the work months later.
12
+ - **A done contract.** A ticket can't close without proof — a resolution commit, and (for bugs) a test reference. Closes are self-documenting by construction.
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g @4-r-c-4-n-4/todo
20
+ ```
21
+
22
+ Requires **Node ≥ 20** and a **git repository**. Then, from the repo root:
23
+
24
+ ```bash
25
+ todo init # creates .todo/ and a config file
26
+ git add .todo && git commit -m "chore: init todo"
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ # 1. Capture a problem
35
+ id=$(todo new "Login throws on empty password" --type bug --source human)
36
+
37
+ # 2. Start work (creates branch todo/<id>, marks the ticket active)
38
+ todo work "$id"
39
+
40
+ # 3. ...write the fix and a test, then commit with the ticket prefix
41
+ git commit -am "todo:$id — guard against empty password"
42
+
43
+ # 4. Close with proof, recording the .todo/ state in the same step
44
+ todo close "$id" \
45
+ --test tests/auth.test.ts::rejects_empty_password \
46
+ --note "Restored the null guard dropped in 3f9a1c2" \
47
+ --commit-state
48
+ ```
49
+
50
+ That's the standalone-ticket loop. For multi-step features, see **Feature builds** below.
51
+
52
+ ---
53
+
54
+ ## Core concepts
55
+
56
+ **Tickets** are JSON files under `.todo/open/` (live) and `.todo/done/` (terminal). Each has a stable 8-char id, a type, a state, a summary, and optional analysis, relationships, and resolution.
57
+
58
+ **States:** `open` → `active` → `done` (plus `blocked`, and the terminal `wontfix` / `duplicate`).
59
+
60
+ **Types and what it takes to close each (the done contract):**
61
+
62
+ | Type | Required to close |
63
+ |------|-------------------|
64
+ | `bug` | Commit + test file **and** function |
65
+ | `feature` | Commit + a test *or* a note |
66
+ | `refactor` | Commit |
67
+ | `chore` | Commit |
68
+ | `debt` | Commit (note recommended) |
69
+ | `investigation` | A **note** (the documented conclusion) — commit optional, no test needed |
70
+ | *parent* | A note, and every child in a terminal state |
71
+
72
+ `investigation` exists for work whose deliverable is a *decision*, not code — a benchmark, a design call, a "where should this live?" question. It closes on the conclusion.
73
+
74
+ ---
75
+
76
+ ## Two workflow modes
77
+
78
+ `todo` does not force a branching workflow on you. Pick the one that matches your repo with `behavior.branch_mode`:
79
+
80
+ ### `per-ticket` (default) — todo manages branches
81
+
82
+ `todo work <id>` creates `todo/<id>` (children share the parent's branch); you merge it back with `--no-ff` when done. Branch-convention guards run on `close`. Best for agent-driven repos where todo owns the git choreography.
83
+
84
+ ```bash
85
+ todo work <id>
86
+ git commit -am "todo:<id> — ..."
87
+ todo close <id> --note "..." --commit-state
88
+ git checkout main && git merge --no-ff todo/<id> && git branch -d todo/<id>
89
+ ```
90
+
91
+ ### `managed` — you own branches (PRs, protected `main`)
92
+
93
+ `todo work` performs **no git operations**, and `close` drops the branch guards and records state automatically. `todo` becomes a pure state-and-rationale tracker and leaves git entirely to you and your PR flow.
94
+
95
+ ```jsonc
96
+ // .todo/config.json
97
+ { "behavior": { "branch_mode": "managed" } }
98
+ ```
99
+
100
+ ```bash
101
+ git checkout -b feature/login-fix # your branch, your rules
102
+ todo work <id> # just marks the ticket active
103
+ # ...commit however you like...
104
+ todo close <id> --note "..." # no branch guard; state auto-committed
105
+ ```
106
+
107
+ ### Strict vs. advisory guards
108
+
109
+ In `per-ticket` mode, the branch-convention checks (are you on the right branch? does a commit reference this ticket?) are **advisory by default** — they warn and proceed. Set `behavior.guard_mode: "strict"` to make them hard errors (exit 1) when you want git to enforce the conventions. The real done contract (commit exists, test/note present) is always enforced.
110
+
111
+ ---
112
+
113
+ ## Feature builds (parent + children)
114
+
115
+ Decompose a feature into an ordered parent + children. Children share the parent's branch and are worked in sequence.
116
+
117
+ ```bash
118
+ parent=$(todo new "OAuth2 login" --type feature --source agent)
119
+ c1=$(todo new "Add /auth/callback route" --type chore --parent "$parent")
120
+ c2=$(todo new "Validate session middleware" --type chore --parent "$parent")
121
+
122
+ todo work "$c1" # creates todo/<parent>
123
+ # ...implement, commit, close $c1 --commit-state...
124
+ todo next "$parent" # activates the next open child on the same branch
125
+ # ...repeat...
126
+ todo close "$parent" --note "All subtasks shipped" --commit-state
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Command reference
132
+
133
+ | Command | What it does |
134
+ |---------|--------------|
135
+ | `todo init` | Initialize `.todo/` in the current git repo |
136
+ | `todo new [summary]` | Create a ticket (`--type`, `--source`, `--parent`, `--tags`, `--file`, `--pipe`) |
137
+ | `todo list` | List tickets (`--state`, `--type`, `--tag`, `--file`, `--sort`, `--json`) |
138
+ | `todo show <id>` | Show ticket detail (`--raw` for JSON) |
139
+ | `todo edit <id>` | Edit fields (`--summary`, `--type`, `--tags`, `--parent`, …) |
140
+ | `todo work <id>` | Start/resume work (`--skip-branch`, `--branch`, `--actor`) |
141
+ | `todo next <parent>` | Activate the next open child on the current branch |
142
+ | `todo analyze <id>` | Append a reasoning entry (`--type`, `--content`, `--confidence`) |
143
+ | `todo transition <id> <state>` | Move a ticket to any state |
144
+ | `todo close <id>` | Close as done (`--commit`, `--test`, `--note`, `--commit-state`, `--force`) |
145
+ | `todo link <id> --to <target>` | Link to a commit, file, or ticket (`--relation`) |
146
+ | `todo scan` | Create tickets from `TODO`/`FIXME`/… comments in the tree |
147
+ | `todo dedup` | Find potential duplicate tickets |
148
+ | `todo doctor` | Reconcile `.todo/` against git reality and report drift |
149
+ | `todo export` | Dump tickets to stdout as JSON |
150
+ | `todo pr` | Push the `todo/<id>` branch and open/update a GitHub PR |
151
+ | `todo sync` | Push ticket state to a Hermes Kanban board |
152
+ | `todo install-hooks` | Install git hooks that enforce the conventions |
153
+
154
+ Run `todo <command> --help` for full options.
155
+
156
+ ---
157
+
158
+ ## Configuration
159
+
160
+ Settings live in `.todo/config.json` (created by `todo init`). The keys that shape the workflow:
161
+
162
+ | Key | Values | Default | Effect |
163
+ |-----|--------|---------|--------|
164
+ | `behavior.commit_prefix` | string | `todo:` | Prefix that ties commits to tickets |
165
+ | `behavior.branch_mode` | `per-ticket` \| `managed` | `per-ticket` | Whether todo manages branches or leaves git to you (see above) |
166
+ | `behavior.guard_mode` | `advisory` \| `strict` | `advisory` | Whether failed branch guards warn or hard-fail |
167
+ | `intake.scan_patterns` | string[] | `TODO, FIXME, HACK, XXX` | Comment markers `todo scan` picks up |
168
+ | `intake.dedup_strategy` | `fingerprint` \| `file-line` \| `semantic` | `fingerprint` | How duplicates are detected |
169
+ | `display.id_length` | number | `8` | Characters of the id shown in lists |
170
+ | `display.date_format` | `relative` \| `iso` | `relative` | Date rendering |
171
+
172
+ **Profiles at a glance:**
173
+ - **PR-based / protected `main`** → `branch_mode: "managed"`.
174
+ - **Agent-driven, todo owns branches** → `branch_mode: "per-ticket"` + `guard_mode: "strict"`.
175
+ - **Solo / local** → the defaults: structure is there, but a guard never blocks you.
176
+
177
+ ---
178
+
179
+ ## `todo doctor`
180
+
181
+ Because state lives in git and is edited by agents, the `.todo/` store can drift from reality. `todo doctor` reconciles them and reports:
182
+
183
+ - done tickets whose resolution commit is missing (orphaned by a squash/rebase) or unreachable from `HEAD`
184
+ - done parents with a still-open child
185
+ - active tickets whose branch was deleted
186
+ - tickets misfiled in the wrong directory for their state
187
+ - dangling parent/child/dependency references
188
+
189
+ It exits non-zero on errors (and on warnings too with `--strict`), so it drops straight into CI. `--json` for machine output.
190
+
191
+ ---
192
+
193
+ ## For coding agents
194
+
195
+ `todo` ships a set of **skills** (under `skills/`) that drive the lifecycle end-to-end: `todo-capture` (intake any signal into a ticket), `todo-triage` (classify raw captures), `todo-plan` (decompose a feature), and `todo-implement` (write the code and close with proof). They encode the conventions so an agent follows them reliably.
196
+
197
+ `TODO_ACTOR` sets the identity recorded on transitions when git's `user.name` isn't the right attribution.
198
+
199
+ ---
200
+
201
+ ## Learn more
202
+
203
+ [**BIBLE.md**](./BIBLE.md) is the full doctrine — the lifecycle, the branch workflow, branch-protection setup, and the reasoning behind the done contract.
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](./LICENSE).
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
2
  // Branch-convention guards used by `todo close` and `todo work`.
3
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.
4
+ // These encode the conventions documented in the todo-implement skill. They
5
+ // are *advisory by default* the caller warns and proceeds — because a
6
+ // standalone tool should not litigate a branching workflow its user did not
7
+ // choose. A project that wants git to enforce the conventions opts in with
8
+ // behavior.guard_mode = "strict", which turns these into hard preconditions
9
+ // (agents follow exit codes far more reliably than prose). managed branch_mode
10
+ // skips them entirely. Each function therefore just *reports* a check result;
11
+ // the severity decision lives in the command.
7
12
  Object.defineProperty(exports, "__esModule", { value: true });
8
13
  exports.expectedBranchFor = expectedBranchFor;
9
14
  exports.checkOnExpectedBranch = checkOnExpectedBranch;
@@ -39,9 +44,7 @@ function checkBranchHasTodoCommit(ticket, repoRoot, commitPrefix) {
39
44
  return { ok: true };
40
45
  return {
41
46
  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.`,
47
+ message: `no commit since ${base} has a message containing '${needle}'.`,
45
48
  };
46
49
  }
47
50
  /**
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ const commander_1 = require("commander");
7
7
  const analyze_js_1 = require("./commands/analyze.js");
8
8
  const close_js_1 = require("./commands/close.js");
9
9
  const dedup_js_1 = require("./commands/dedup.js");
10
+ const doctor_js_1 = require("./commands/doctor.js");
10
11
  const edit_js_1 = require("./commands/edit.js");
11
12
  const export_js_1 = require("./commands/export.js");
12
13
  const init_js_1 = require("./commands/init.js");
@@ -46,4 +47,5 @@ program
46
47
  (0, install_hooks_js_1.registerInstallHooks)(program);
47
48
  (0, sync_js_1.registerSync)(program);
48
49
  (0, pr_js_1.registerPr)(program);
50
+ (0, doctor_js_1.registerDoctor)(program);
49
51
  program.parse(process.argv);
@@ -16,14 +16,31 @@ function registerClose(program) {
16
16
  .option("--test <file::func>", "test file and function (colon-separated)")
17
17
  .option("--note <text>", "resolution note")
18
18
  .option("--checkout", "git checkout base_branch after closing")
19
+ .option("--commit-state", "atomically git-commit the .todo/ state change (no separate manual commit needed)")
19
20
  .option("--force", "skip branch and commit-message guards")
20
21
  .action((id, opts) => {
21
22
  const ctx = (0, context_js_1.getContext)(true);
22
23
  const { repoRoot, config } = ctx;
23
24
  try {
24
25
  const ticket = (0, ticket_js_1.readTicketByPrefix)(repoRoot, id);
25
- // Branch-convention guards (skippable with --force).
26
- if (!opts.force) {
26
+ const managed = (0, config_js_1.getBranchMode)(config) === "managed";
27
+ const strict = (0, config_js_1.getGuardMode)(config) === "strict";
28
+ // Branch-convention guards. Skipped entirely in managed mode (the
29
+ // user owns branching) or with --force. Otherwise they are advisory
30
+ // by default — warn and proceed — and only hard-fail when the
31
+ // project opts into behavior.guard_mode = "strict". This keeps the
32
+ // tool from litigating a workflow you are not using, while still
33
+ // letting agent-driven repos enforce the conventions.
34
+ if (!opts.force && !managed) {
35
+ // Report a guard failure at the configured severity. In strict
36
+ // mode it exits 1; otherwise it warns and execution continues.
37
+ const reportGuard = (message) => {
38
+ if (strict) {
39
+ console.error(`Error: ${message}`);
40
+ process.exit(1);
41
+ }
42
+ console.error(`Warning: ${message} (advisory — proceeding)`);
43
+ };
27
44
  let currentBranch = "";
28
45
  try {
29
46
  currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
@@ -32,22 +49,21 @@ function registerClose(program) {
32
49
  // Detached HEAD or other; treat as a branch mismatch.
33
50
  }
34
51
  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)) {
52
+ if (!branchCheck.ok)
53
+ reportGuard(branchCheck.message ?? "");
54
+ // The commit-prefix grep is a heuristic over commit messages, not
55
+ // the real done-contract (commit-exists + test/note, enforced in
56
+ // state.ts). An explicit --commit names the deliverable, so skip
57
+ // it; a parent with all children closed carries no commit of its
58
+ // own.
59
+ if (!opts.commit && !(0, branch_guard_js_1.isParentWithAllChildrenClosed)(ticket, repoRoot)) {
43
60
  const commitCheck = (0, branch_guard_js_1.checkBranchHasTodoCommit)(ticket, repoRoot, (0, config_js_1.getCommitPrefix)(config));
44
61
  if (!commitCheck.ok) {
45
- console.error(`Error: ${commitCheck.message}`);
46
- process.exit(1);
62
+ reportGuard(`${commitCheck.message} Pass --commit <sha> to point close at the deliverable`);
47
63
  }
48
64
  }
49
65
  }
50
- else {
66
+ else if (opts.force) {
51
67
  console.error("Warning: --force used; skipping branch guards.");
52
68
  }
53
69
  // Resolve commit
@@ -88,6 +104,23 @@ function registerClose(program) {
88
104
  }
89
105
  (0, ticket_js_1.writeTicket)(repoRoot, updated);
90
106
  console.log(`Closed ${updated.id}`);
107
+ // Atomically record the state change. This only runs once the
108
+ // close has already succeeded on disk, so the "close" commit can
109
+ // never describe a ticket that did not actually close — the
110
+ // phantom-commit hazard of a manual `git commit` after a failed
111
+ // close. Runs before --checkout so the commit lands on the ticket
112
+ // branch before switching away.
113
+ if (opts.commitState || managed) {
114
+ const message = `${(0, config_js_1.getCommitPrefix)(config)}${updated.id} — close`;
115
+ try {
116
+ (0, git_js_1.commitTodoState)(message, repoRoot);
117
+ console.log(`Recorded .todo/ state: ${message}`);
118
+ }
119
+ catch (err) {
120
+ console.error(`Warning: closed ${updated.id} but could not commit .todo/ state: ${err.message}\n` +
121
+ " Commit it manually: git add -A .todo && git commit");
122
+ }
123
+ }
91
124
  // Checkout base branch if requested
92
125
  if (opts.checkout) {
93
126
  const targetBranch = updated.work?.base_branch ?? (0, git_js_1.getDefaultBranch)(repoRoot);
@@ -0,0 +1,17 @@
1
+ import { Command } from "commander";
2
+ type Severity = "error" | "warning";
3
+ interface Issue {
4
+ severity: Severity;
5
+ ticket: string;
6
+ message: string;
7
+ }
8
+ /**
9
+ * Reconcile committed .todo/ state against git reality and report drift.
10
+ * Because state lives in git and is edited by agents, the file can silently
11
+ * diverge from what actually happened (a done ticket whose resolution commit
12
+ * was never merged, an active ticket whose branch was deleted, a ticket in
13
+ * the wrong directory for its state). `doctor` turns that into a report.
14
+ */
15
+ export declare function collectIssues(repoRoot: string): Issue[];
16
+ export declare function registerDoctor(program: Command): void;
17
+ export {};
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectIssues = collectIssues;
4
+ exports.registerDoctor = registerDoctor;
5
+ const context_js_1 = require("../context.js");
6
+ const errors_js_1 = require("../errors.js");
7
+ const git_js_1 = require("../git.js");
8
+ const ticket_js_1 = require("../ticket.js");
9
+ /**
10
+ * Reconcile committed .todo/ state against git reality and report drift.
11
+ * Because state lives in git and is edited by agents, the file can silently
12
+ * diverge from what actually happened (a done ticket whose resolution commit
13
+ * was never merged, an active ticket whose branch was deleted, a ticket in
14
+ * the wrong directory for its state). `doctor` turns that into a report.
15
+ */
16
+ function collectIssues(repoRoot) {
17
+ const issues = [];
18
+ const add = (severity, ticket, message) => issues.push({ severity, ticket, message });
19
+ const openTickets = (0, ticket_js_1.listTickets)(repoRoot, "open");
20
+ const doneTickets = (0, ticket_js_1.listTickets)(repoRoot, "done");
21
+ const all = [...openTickets, ...doneTickets];
22
+ const byId = new Map(all.map((t) => [t.id, t]));
23
+ let head;
24
+ try {
25
+ head = (0, git_js_1.resolveHEAD)(repoRoot);
26
+ }
27
+ catch {
28
+ head = undefined;
29
+ }
30
+ // Directory must agree with state: open/ holds non-terminal, done/ terminal.
31
+ for (const t of openTickets) {
32
+ if (ticket_js_1.TERMINAL_STATES.includes(t.state)) {
33
+ add("error", t.id, `is in .todo/open/ but its state is '${t.state}' (terminal) — file is in the wrong directory`);
34
+ }
35
+ }
36
+ for (const t of doneTickets) {
37
+ if (!ticket_js_1.TERMINAL_STATES.includes(t.state)) {
38
+ add("error", t.id, `is in .todo/done/ but its state is '${t.state}' (non-terminal) — file is in the wrong directory`);
39
+ }
40
+ }
41
+ for (const t of all) {
42
+ // Resolution integrity for done tickets.
43
+ if (t.state === "done") {
44
+ const sha = t.resolution?.commit;
45
+ if (!sha) {
46
+ add("error", t.id, "is done but records no resolution commit");
47
+ }
48
+ else if (!(0, git_js_1.commitExists)(sha, repoRoot)) {
49
+ add("error", t.id, `resolution commit ${sha} does not exist in the repository (orphaned by a squash/rebase?)`);
50
+ }
51
+ else if (head && !(0, git_js_1.isAncestor)(sha, "HEAD", repoRoot)) {
52
+ add("warning", t.id, `resolution commit ${sha.slice(0, 8)} is not reachable from HEAD (closed on an unmerged branch?)`);
53
+ }
54
+ }
55
+ // A done parent must have no non-terminal children.
56
+ const children = t.relationships?.children ?? [];
57
+ if (ticket_js_1.TERMINAL_STATES.includes(t.state) && children.length > 0) {
58
+ for (const childId of children) {
59
+ const child = byId.get(childId);
60
+ if (child && !ticket_js_1.TERMINAL_STATES.includes(child.state)) {
61
+ add("error", t.id, `is ${t.state} but child ${childId} is still '${child.state}'`);
62
+ }
63
+ }
64
+ }
65
+ // An active ticket whose branch was deleted has lost its working context.
66
+ if (t.state === "active" && t.work?.branch) {
67
+ if (!(0, git_js_1.branchExists)(t.work.branch, repoRoot)) {
68
+ add("warning", t.id, `is active but its branch '${t.work.branch}' no longer exists`);
69
+ }
70
+ }
71
+ // Dangling relationship references.
72
+ const rel = t.relationships;
73
+ if (rel?.parent && !byId.has(rel.parent)) {
74
+ add("warning", t.id, `references missing parent ${rel.parent}`);
75
+ }
76
+ for (const childId of children) {
77
+ if (!byId.has(childId)) {
78
+ add("warning", t.id, `references missing child ${childId}`);
79
+ }
80
+ }
81
+ for (const depId of rel?.depends_on ?? []) {
82
+ if (!byId.has(depId)) {
83
+ add("warning", t.id, `references missing dependency ${depId}`);
84
+ }
85
+ }
86
+ }
87
+ return issues;
88
+ }
89
+ function registerDoctor(program) {
90
+ program
91
+ .command("doctor")
92
+ .description("Reconcile committed .todo/ state against git reality and report drift")
93
+ .option("--json", "output issues as a JSON array")
94
+ .option("--strict", "exit non-zero when any issue is found, including warnings")
95
+ .action((opts) => {
96
+ const ctx = (0, context_js_1.getContext)(true);
97
+ const { repoRoot } = ctx;
98
+ try {
99
+ const issues = collectIssues(repoRoot);
100
+ const errors = issues.filter((i) => i.severity === "error");
101
+ const warnings = issues.filter((i) => i.severity === "warning");
102
+ if (opts.json) {
103
+ console.log(JSON.stringify(issues, null, 2));
104
+ }
105
+ else if (issues.length === 0) {
106
+ console.log("todo doctor: no issues found — .todo/ is consistent.");
107
+ }
108
+ else {
109
+ for (const i of issues) {
110
+ const tag = i.severity === "error" ? "ERROR" : "warn ";
111
+ console.log(`${tag} ${i.ticket} ${i.message}`);
112
+ }
113
+ console.log(`\n${errors.length} error(s), ${warnings.length} warning(s).`);
114
+ }
115
+ const failed = opts.strict ? issues.length > 0 : errors.length > 0;
116
+ if (failed)
117
+ process.exit(1);
118
+ }
119
+ catch (err) {
120
+ (0, errors_js_1.handleError)(err);
121
+ }
122
+ });
123
+ }
@@ -10,6 +10,7 @@ const VALID_TYPES = [
10
10
  "refactor",
11
11
  "chore",
12
12
  "debt",
13
+ "investigation",
13
14
  ];
14
15
  function registerEdit(program) {
15
16
  program
@@ -12,6 +12,7 @@ const VALID_TYPES = [
12
12
  "refactor",
13
13
  "chore",
14
14
  "debt",
15
+ "investigation",
15
16
  ];
16
17
  const VALID_SOURCES = [
17
18
  "log",
@@ -24,7 +25,7 @@ function registerNew(program) {
24
25
  program
25
26
  .command("new [summary]")
26
27
  .description("Create a new ticket")
27
- .option("-t, --type <type>", "ticket type: bug|feature|refactor|chore|debt", "chore")
28
+ .option("-t, --type <type>", "ticket type: bug|feature|refactor|chore|debt|investigation", "chore")
28
29
  .option("-s, --source <source>", "source type: log|test|agent|human|comment", "human")
29
30
  .option("-f, --file <path>", "associate a file path")
30
31
  .option("-l, --lines <start,end>", "line range for the file (e.g. 10,20)")
@@ -14,6 +14,7 @@ const VALID_TYPES = [
14
14
  "refactor",
15
15
  "chore",
16
16
  "debt",
17
+ "investigation",
17
18
  ];
18
19
  function registerScan(program) {
19
20
  program
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerWork = registerWork;
4
4
  const branch_guard_js_1 = require("../branch-guard.js");
5
+ const config_js_1 = require("../config.js");
5
6
  const context_js_1 = require("../context.js");
6
7
  const errors_js_1 = require("../errors.js");
7
8
  const git_js_1 = require("../git.js");
@@ -16,9 +17,12 @@ function registerWork(program) {
16
17
  .option("--actor <name>", "override actor (also reads TODO_ACTOR env)")
17
18
  .action((id, opts) => {
18
19
  const ctx = (0, context_js_1.getContext)(true);
19
- const { repoRoot } = ctx;
20
+ const { repoRoot, config } = ctx;
20
21
  try {
21
22
  const ticket = (0, ticket_js_1.readTicketByPrefix)(repoRoot, id);
23
+ // In managed branch_mode the user (or a PR flow) owns branching,
24
+ // so `work` performs no git ops — same as passing --skip-branch.
25
+ const skipBranch = opts.skipBranch || (0, config_js_1.getBranchMode)(config) === "managed";
22
26
  // Check not terminal
23
27
  if (ticket_js_1.TERMINAL_STATES.includes(ticket.state)) {
24
28
  console.error(`Error: ticket ${ticket.id} is in terminal state '${ticket.state}'. Cannot work on it.`);
@@ -55,7 +59,7 @@ function registerWork(program) {
55
59
  // Dirty-tree guard: refuse to leave a different todo/* branch
56
60
  // with uncommitted work. We let `main`/other branches through —
57
61
  // only the cross-ticket case is the one that loses work.
58
- if (!opts.skipBranch) {
62
+ if (!skipBranch) {
59
63
  let currentBranch = "";
60
64
  try {
61
65
  currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
@@ -73,7 +77,7 @@ function registerWork(program) {
73
77
  }
74
78
  }
75
79
  }
76
- if (opts.skipBranch) {
80
+ if (skipBranch) {
77
81
  // --no-branch: orchestrator mode — activate on current branch without any git ops
78
82
  const currentBranch = (0, git_js_1.getCurrentBranch)(repoRoot);
79
83
  if (ticket.state !== "active") {
package/dist/config.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import type { Config } from "./types.js";
1
+ import type { BranchMode, Config, GuardMode } from "./types.js";
2
2
  export declare const DEFAULT_CONFIG: Config;
3
3
  export declare function loadConfig(repoRoot: string): Config;
4
4
  export declare function getTodoDir(repoRoot: string): string;
5
5
  export declare function ensureTodoDir(repoRoot: string): void;
6
6
  export declare function getIdLength(config: Config): number;
7
7
  export declare function getCommitPrefix(config: Config): string;
8
+ export declare function getBranchMode(config: Config): BranchMode;
9
+ export declare function getGuardMode(config: Config): GuardMode;
package/dist/config.js CHANGED
@@ -7,11 +7,17 @@ exports.getTodoDir = getTodoDir;
7
7
  exports.ensureTodoDir = ensureTodoDir;
8
8
  exports.getIdLength = getIdLength;
9
9
  exports.getCommitPrefix = getCommitPrefix;
10
+ exports.getBranchMode = getBranchMode;
11
+ exports.getGuardMode = getGuardMode;
10
12
  const node_fs_1 = require("node:fs");
11
13
  const node_path_1 = require("node:path");
12
14
  exports.DEFAULT_CONFIG = {
13
15
  project: { name: "" },
14
- behavior: { commit_prefix: "todo:" },
16
+ behavior: {
17
+ commit_prefix: "todo:",
18
+ branch_mode: "per-ticket",
19
+ guard_mode: "advisory",
20
+ },
15
21
  intake: {
16
22
  dedup_strategy: "fingerprint",
17
23
  scan_patterns: ["TODO", "FIXME", "HACK", "XXX"],
@@ -75,3 +81,9 @@ function getIdLength(config) {
75
81
  function getCommitPrefix(config) {
76
82
  return config.behavior?.commit_prefix ?? "todo:";
77
83
  }
84
+ function getBranchMode(config) {
85
+ return config.behavior?.branch_mode ?? "per-ticket";
86
+ }
87
+ function getGuardMode(config) {
88
+ return config.behavior?.guard_mode ?? "advisory";
89
+ }
package/dist/git.d.ts CHANGED
@@ -17,6 +17,17 @@ 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
+ /**
21
+ * Stage the .todo/ directory (additions, modifications, AND deletions — a
22
+ * close moves the file from open/ to done/) and commit it. Used by
23
+ * `todo close --commit-state` to make close-and-record atomic: the state
24
+ * file is recorded by the same command that closed the ticket, so a failed
25
+ * close can never be followed by a stray manual "close" commit that desyncs
26
+ * committed .todo/ from reality. Returns the resulting HEAD sha. If nothing
27
+ * is staged (state already committed), no commit is made and current HEAD is
28
+ * returned — a close that already succeeded on disk must not error out here.
29
+ */
30
+ export declare function commitTodoState(message: string, cwd?: string): string;
20
31
  export declare function hasUncommittedChanges(cwd?: string): boolean;
21
32
  export declare function getCommitMessagesBetween(base: string, head: string, cwd?: string): string[];
22
33
  export declare function getGitDir(cwd?: string): string;
package/dist/git.js CHANGED
@@ -17,6 +17,7 @@ exports.getLastCommitForFile = getLastCommitForFile;
17
17
  exports.createBranch = createBranch;
18
18
  exports.checkoutBranch = checkoutBranch;
19
19
  exports.getGitUserName = getGitUserName;
20
+ exports.commitTodoState = commitTodoState;
20
21
  exports.hasUncommittedChanges = hasUncommittedChanges;
21
22
  exports.getCommitMessagesBetween = getCommitMessagesBetween;
22
23
  exports.getGitDir = getGitDir;
@@ -120,6 +121,24 @@ function checkoutBranch(name, cwd = process.cwd()) {
120
121
  function getGitUserName(cwd = process.cwd()) {
121
122
  return exec(["config", "user.name"], cwd);
122
123
  }
124
+ /**
125
+ * Stage the .todo/ directory (additions, modifications, AND deletions — a
126
+ * close moves the file from open/ to done/) and commit it. Used by
127
+ * `todo close --commit-state` to make close-and-record atomic: the state
128
+ * file is recorded by the same command that closed the ticket, so a failed
129
+ * close can never be followed by a stray manual "close" commit that desyncs
130
+ * committed .todo/ from reality. Returns the resulting HEAD sha. If nothing
131
+ * is staged (state already committed), no commit is made and current HEAD is
132
+ * returned — a close that already succeeded on disk must not error out here.
133
+ */
134
+ function commitTodoState(message, cwd = process.cwd()) {
135
+ exec(["add", "-A", ".todo"], cwd);
136
+ const staged = exec(["diff", "--cached", "--name-only", "--", ".todo"], cwd);
137
+ if (staged.length === 0)
138
+ return resolveHEAD(cwd);
139
+ exec(["commit", "-m", message], cwd);
140
+ return resolveHEAD(cwd);
141
+ }
123
142
  function hasUncommittedChanges(cwd = process.cwd()) {
124
143
  const out = exec(["status", "--porcelain"], cwd);
125
144
  return out.length > 0;
package/dist/state.js CHANGED
@@ -58,6 +58,19 @@ function validateTransition(ticket, targetState, params, repoRoot) {
58
58
  }
59
59
  // commit is optional for parent (defaults to HEAD — caller handles HEAD resolution)
60
60
  }
61
+ else if (ticket.type === "investigation") {
62
+ // Investigation/decision: the deliverable is a documented conclusion,
63
+ // not a code change. Require a resolution note (the conclusion, which
64
+ // should reference any doc/finding). A commit is optional — close
65
+ // still defaults it to HEAD for provenance — but if one is given it
66
+ // must exist.
67
+ if (!params.note) {
68
+ throw new Error(`Investigation ticket requires a resolution note (the documented conclusion)`);
69
+ }
70
+ if (commit && !(0, git_js_1.commitExists)(commit, repoRoot)) {
71
+ throw new Error(`Commit '${commit}' does not exist in the repository`);
72
+ }
73
+ }
61
74
  else {
62
75
  // Non-parent: commit always required
63
76
  if (!commit) {
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- export type TicketType = "bug" | "feature" | "refactor" | "chore" | "debt";
1
+ export type TicketType = "bug" | "feature" | "refactor" | "chore" | "debt" | "investigation";
2
2
  export type State = "open" | "active" | "blocked" | "done" | "wontfix" | "duplicate";
3
3
  export type SourceType = "log" | "test" | "agent" | "human" | "comment";
4
+ export type BranchMode = "per-ticket" | "managed";
5
+ export type GuardMode = "advisory" | "strict";
4
6
  export type AnalysisType = "blame" | "hypothesis" | "evidence" | "conclusion";
5
7
  export type Source = {
6
8
  type: "log";
@@ -87,6 +89,8 @@ export interface Config {
87
89
  };
88
90
  behavior?: {
89
91
  commit_prefix?: string;
92
+ branch_mode?: BranchMode;
93
+ guard_mode?: GuardMode;
90
94
  };
91
95
  intake?: {
92
96
  dedup_strategy?: "fingerprint" | "file-line" | "semantic";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4-r-c-4-n-4/todo",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Git-native work tracking for coding agents",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {