@nickmeriano/task 0.8.0 → 0.10.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.
Files changed (115) hide show
  1. package/README.md +137 -22
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"claim.test.d.ts","sourceRoot":"","sources":["../src/claim.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
1
+ {"version":3,"file":"claim.test.d.ts","sourceRoot":"","sources":["../src/claim.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG"}
@@ -6,15 +6,23 @@
6
6
  * 1. Two concurrent `task claim <same-id>`: exactly one exits 0, the other
7
7
  * exits 1 — git's atomic ref creation is the lock.
8
8
  * 2. Validation failures exit 2 before anything touches git.
9
- * 3. `task list --claimable` is the queue: position order, minus blocked,
10
- * needs-human, and already-claimed tickets.
9
+ * 3. `task list --claimable` is the queue: position order, minus blocked
10
+ * and already-claimed tickets — open asks never exclude a ticket.
11
11
  * 4. `task claim --release` deletes the branch both sides and the ticket is
12
12
  * claimable again.
13
+ * 5. `task claim --lock-only` takes the same lock without touching the
14
+ * checkout — a branch-pinned (even dirty) session can hold it — and
15
+ * `--release` works on it symmetrically.
16
+ * 6. Deletions are verified: against a remote that silently drops deletion
17
+ * pushes (TAS-8b7s9), release and sweep fail loudly instead of lying.
18
+ * 7. `task sweep` deletes claim branches whose tickets are done, canceled or
19
+ * missing on the current checkout, and leaves live ones — and any branch
20
+ * named outside the claim scheme — alone.
13
21
  */
14
22
  import assert from "node:assert/strict";
15
23
  import { test } from "node:test";
16
24
  import { execFile, spawnSync } from "node:child_process";
17
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
25
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
26
  import { readFileSync } from "node:fs";
19
27
  import { tmpdir } from "node:os";
20
28
  import { join } from "node:path";
@@ -35,24 +43,44 @@ function sh(cwd, command, ...args) {
35
43
  }
36
44
  /** The CLI as callers see it — a subprocess with an exit code. */
37
45
  function cli(cwd, ...args) {
46
+ return cliEnv(cwd, {}, ...args);
47
+ }
48
+ /** Like cli(), with extra env — the delete-dropping git shim rides in PATH. */
49
+ function cliEnv(cwd, env, ...args) {
38
50
  return new Promise((resolve) => {
39
- execFile(process.execPath, ["--experimental-strip-types", CLI, ...args], { cwd }, (error, stdout, stderr) => {
51
+ execFile(process.execPath, ["--experimental-strip-types", CLI, ...args], { cwd, env: { ...process.env, ...env } }, (error, stdout, stderr) => {
40
52
  resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
41
53
  });
42
54
  });
43
55
  }
56
+ /**
57
+ * A PATH whose `git` silently swallows branch-deletion pushes — exit 0,
58
+ * nothing deleted — and delegates everything else to the real git. This is
59
+ * the observed cloud-proxy behavior TAS-8b7s9 documents: the deletion "works"
60
+ * while the ref survives, which is exactly what verified release must catch.
61
+ * POSIX-only by construction, like the failure it reproduces.
62
+ */
63
+ function deleteDroppingPath() {
64
+ const dir = mkdtempSync(join(tmpdir(), "task-fake-git-"));
65
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }));
66
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
67
+ const shim = join(dir, "git");
68
+ writeFileSync(shim, `#!/bin/sh\nfor a in "$@"; do case "$a" in --delete) exit 0;; esac; done\nexec "${real}" "$@"\n`);
69
+ chmodSync(shim, 0o755);
70
+ return `${dir}:${process.env.PATH ?? ""}`;
71
+ }
44
72
  /**
45
73
  * A bare origin holding a board with the claimable shapes:
46
- * 1 "Ready one" todo
47
- * 2 "Ready two" todo
48
- * 3 "Blocked" todo, blocked by 1
49
- * 4 "For a person" todo, needs-human
50
- * 5 "Still parked" backlog
51
- * 6 "Jumped queue" todo, moved to the top of the column after creation —
52
- * claimable order must be [6, 1, 2], proving position
53
- * order beats number order.
74
+ * one "Ready one" todo
75
+ * two "Ready two" todo
76
+ * blocked "Blocked" todo, blocked by `one`
77
+ * person "For a person" todo, carrying an open ask
78
+ * parked "Still parked" backlog
79
+ * jumper "Jumped queue" todo, moved to the top of the column after
80
+ * creation — claimable order must be [jumper, one, two], proving
81
+ * position order beats creation order.
54
82
  */
55
- function fixture(claimPrefix) {
83
+ function fixture(branchPrefix) {
56
84
  const home = tempDir();
57
85
  const bare = join(home, "origin.git");
58
86
  sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare);
@@ -61,20 +89,29 @@ function fixture(claimPrefix) {
61
89
  sh(seed, "git", "config", "user.name", "Test");
62
90
  sh(seed, "git", "config", "user.email", "test@example.com");
63
91
  const store = initProject(seed, { name: "claim board", prefix: "CLM" });
64
- if (claimPrefix !== undefined) {
92
+ if (branchPrefix !== undefined) {
65
93
  const configPath = join(seed, ".task", "config.json");
66
94
  const config = JSON.parse(readFileSync(configPath, "utf8"));
67
- config.claimPrefix = claimPrefix;
95
+ config.claims = { branchPrefix };
68
96
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
69
97
  }
70
- store.create({ title: "Ready one", status: "todo" });
71
- store.create({ title: "Ready two", status: "todo" });
72
- store.create({ title: "Blocked", status: "todo", blockedBy: [1] });
73
- store.create({ title: "For a person", status: "todo", needsHuman: true });
74
- store.create({ title: "Still parked", status: "backlog" });
98
+ const one = store.create({ title: "Ready one", status: "todo" });
99
+ const two = store.create({ title: "Ready two", status: "todo" });
100
+ const blocked = store.create({ title: "Blocked", status: "todo", blockedBy: [one.key] });
101
+ const person = store.create({ title: "For a person", status: "todo" });
102
+ store.addAsk(person.key, "Approve the vendor contract", "claude");
103
+ const parked = store.create({ title: "Still parked", status: "backlog" });
75
104
  const jumper = store.create({ title: "Jumped queue", status: "backlog" });
76
105
  // A status move without an explicit slot lands on top of the new column.
77
- store.update(jumper.number, { status: "todo" });
106
+ store.update(jumper.key, { status: "todo" });
107
+ const keys = {
108
+ one: one.key,
109
+ two: two.key,
110
+ blocked: blocked.key,
111
+ person: person.key,
112
+ parked: parked.key,
113
+ jumper: jumper.key,
114
+ };
78
115
  sh(seed, "git", "add", "-A");
79
116
  sh(seed, "git", "commit", "--quiet", "-m", "board");
80
117
  sh(seed, "git", "push", "--quiet", "-u", "origin", "main");
@@ -86,113 +123,120 @@ function fixture(claimPrefix) {
86
123
  sh(dir, "git", "config", "user.email", "test@example.com");
87
124
  return dir;
88
125
  };
89
- return { bare, clone };
126
+ return { bare, clone, keys };
90
127
  }
91
128
  test("two concurrent claims of one ticket: exactly one wins", async () => {
92
- const { bare, clone } = fixture();
129
+ const { bare, clone, keys } = fixture();
93
130
  const a = clone();
94
131
  const b = clone();
95
- const [ra, rb] = await Promise.all([cli(a, "claim", "1"), cli(b, "claim", "1")]);
132
+ const [ra, rb] = await Promise.all([cli(a, "claim", keys.one), cli(b, "claim", keys.one)]);
96
133
  assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`);
97
134
  const winner = ra.code === 0 ? a : b;
98
135
  const loser = ra.code === 0 ? rb : ra;
99
136
  assert.match(loser.stderr, /already claimed|rejected/);
100
137
  // The winner is left on the claim branch, flip committed and pushed.
101
- assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), "task/claim/clm-1");
102
- assert.match(readFileSync(join(winner, ".task", "tickets", "1", "ticket.md"), "utf8"), /status: in_progress/);
103
- assert.match(sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"), /refs\/heads\/task\/claim\/clm-1/);
138
+ const branch = `task/claim/clm-${keys.one}`;
139
+ assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch);
140
+ assert.match(readFileSync(join(winner, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: in_progress/);
141
+ assert.ok(sh(winner, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`));
104
142
  // Origin's default branch never saw the flip — the claim rides the branch.
105
143
  assert.equal(sh(winner, "git", "diff", "--stat", "origin/main..HEAD", "--", "README.md"), "");
106
- assert.match(sh(winner, "git", "show", "origin/main:.task/tickets/1/ticket.md"), /status: todo/);
144
+ assert.match(sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`), /status: todo/);
107
145
  void bare;
108
146
  });
109
147
  test("unclaimable tickets exit 2, before anything touches origin", async () => {
110
- const { clone } = fixture();
148
+ const { clone, keys } = fixture();
111
149
  const dir = clone();
112
- assert.equal((await cli(dir, "claim", "5")).code, 2); // backlog
113
- assert.equal((await cli(dir, "claim", "3")).code, 2); // blocked
114
- assert.equal((await cli(dir, "claim", "4")).code, 2); // needs a human
115
- assert.equal((await cli(dir, "claim", "99")).code, 2); // missing
116
- assert.match((await cli(dir, "claim", "3")).stderr, /blocked by CLM-1/);
150
+ assert.equal((await cli(dir, "claim", keys.parked)).code, 2); // backlog
151
+ assert.equal((await cli(dir, "claim", keys.blocked)).code, 2); // blocked
152
+ assert.equal((await cli(dir, "claim", "zz9zz")).code, 2); // missing
153
+ assert.match((await cli(dir, "claim", keys.blocked)).stderr, new RegExp(`blocked by CLM-${keys.one}`));
117
154
  // A dirty tree refuses too — claiming switches branches. Only *tracked*
118
155
  // modifications count: untracked files ride a branch switch untouched
119
156
  // (release deliberately leaves one — the reason comment).
120
157
  const gitignore = join(dir, ".task", ".gitignore");
121
158
  writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`);
122
- const dirty = await cli(dir, "claim", "2");
159
+ const dirty = await cli(dir, "claim", keys.two);
123
160
  assert.equal(dirty.code, 2);
124
161
  assert.match(dirty.stderr, /working tree is dirty/);
125
162
  sh(dir, "git", "checkout", "--", ".task/.gitignore");
126
163
  writeFileSync(join(dir, "scratch.txt"), "untracked is fine");
127
- assert.equal((await cli(dir, "claim", "2")).code, 0);
164
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0);
128
165
  // Nothing above created a stray claim branch for the failed ids.
129
166
  const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*");
130
- assert.deepEqual(remote.split("\n").map((l) => l.split("refs/heads/")[1]), ["task/claim/clm-2"]);
167
+ assert.deepEqual(remote.split("\n").map((l) => l.split("refs/heads/")[1]), [`task/claim/clm-${keys.two}`]);
131
168
  });
132
169
  test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
133
- const { clone } = fixture();
170
+ const { clone, keys } = fixture();
134
171
  const a = clone();
135
- assert.equal((await cli(a, "claim", "6")).code, 0);
172
+ assert.equal((await cli(a, "claim", keys.jumper)).code, 0);
136
173
  const b = clone();
137
174
  const listed = await cli(b, "list", "--claimable", "--json");
138
175
  assert.equal(listed.code, 0);
139
176
  const { tasks } = JSON.parse(listed.stdout);
140
- // 6 claimed, 3 blocked, 4 needs-human, 5 not todo — position order keeps
141
- // 1 before 2 (creation order within the column).
142
- assert.deepEqual(tasks.map((t) => t.number), [1, 2]);
177
+ // jumper claimed, blocked blocked, parked not todo — position order keeps
178
+ // one before two (creation order within the column). person carries an open
179
+ // ask and is claimable anyway: asks gate done, not claim.
180
+ assert.deepEqual(tasks.map((t) => t.key), [keys.one, keys.two, keys.person]);
143
181
  assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1);
144
182
  });
145
183
  test("release deletes the branch everywhere and reopens the claim", async () => {
146
- const { clone } = fixture();
184
+ const { clone, keys } = fixture();
147
185
  const dir = clone();
148
- assert.equal((await cli(dir, "claim", "2")).code, 0);
149
- assert.equal((await cli(dir, "claim", "2")).code, 1); // locally claimed too
150
- const released = await cli(dir, "claim", "--release", "2", "--comment", "trying another shape");
186
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0);
187
+ assert.equal((await cli(dir, "claim", keys.two)).code, 1); // locally claimed too
188
+ const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape");
151
189
  assert.equal(released.code, 0, released.stderr);
152
- assert.match(released.stdout, /released CLM-2/);
190
+ assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout);
153
191
  // Back on the default branch, branch gone on both sides, ticket todo again.
154
192
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main");
155
- assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-2"), "");
156
- assert.match(readFileSync(join(dir, ".task", "tickets", "2", "ticket.md"), "utf8"), /status: todo/);
157
- assert.equal((await cli(dir, "claim", "2")).code, 0);
193
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "");
194
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"), /status: todo/);
195
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0);
158
196
  // Releasing an unclaimed ticket is a quiet no-op, not an error.
159
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "noop");
197
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop");
160
198
  assert.equal(noop.code, 0);
161
199
  assert.match(noop.stdout, /wasn't claimed/);
162
200
  });
163
- test("a configured claimPrefix moves the whole namespace", async () => {
164
- const { clone } = fixture("claude/task/");
201
+ test("a configured claims.branchPrefix moves the whole namespace", async () => {
202
+ const { clone, keys } = fixture("claude/task/");
165
203
  const dir = clone();
166
- assert.equal((await cli(dir, "claim", "1")).code, 0);
167
- assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "claude/task/clm-1");
168
- assert.match(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), /refs\/heads\/claude\/task\/clm-1/);
204
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0);
205
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), `claude/task/clm-${keys.one}`);
206
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*").includes(`refs/heads/claude/task/clm-${keys.one}`));
169
207
  // Every side of the feature reads the same key: the queue looks for claims
170
208
  // under the configured namespace, and release deletes there too.
171
209
  const other = clone();
172
210
  const listed = await cli(other, "list", "--claimable", "--json");
173
211
  const { tasks } = JSON.parse(listed.stdout);
174
- assert.deepEqual(tasks.map((t) => t.number), [6, 2]);
175
- assert.equal((await cli(other, "claim", "1")).code, 1);
176
- assert.equal((await cli(dir, "claim", "--release", "1", "--comment", "handing it back")).code, 0);
212
+ assert.deepEqual(tasks.map((t) => t.key), [keys.jumper, keys.two, keys.person]);
213
+ assert.equal((await cli(other, "claim", keys.one)).code, 1);
214
+ assert.equal((await cli(dir, "claim", "--release", keys.one, "--comment", "handing it back")).code, 0);
177
215
  assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "");
178
216
  // A missing trailing slash is forgiven; a malformed value fails loudly.
179
217
  const bad = clone();
180
218
  const configPath = join(bad, ".task", "config.json");
181
219
  const config = JSON.parse(readFileSync(configPath, "utf8"));
182
- config.claimPrefix = "spaces are not refs/";
220
+ config.claims = { branchPrefix: "spaces are not refs/" };
183
221
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
184
222
  sh(bad, "git", "commit", "--quiet", "-am", "bad prefix");
185
- const invalid = await cli(bad, "claim", "2");
223
+ const invalid = await cli(bad, "claim", keys.two);
186
224
  assert.equal(invalid.code, 2);
187
225
  assert.match(invalid.stderr, /invalid claims\.branchPrefix/);
226
+ // The retired pre-0.8 `claimPrefix` spelling is not honored (TAS-41): a
227
+ // board carrying only it refuses by name rather than silently defaulting
228
+ // the lock convention out from under its workers.
229
+ const retired = clone();
230
+ const retiredPath = join(retired, ".task", "config.json");
231
+ const retiredConfig = JSON.parse(readFileSync(retiredPath, "utf8"));
232
+ delete retiredConfig.claims;
233
+ retiredConfig.claimPrefix = "claude/task/";
234
+ writeFileSync(retiredPath, `${JSON.stringify(retiredConfig, null, 2)}\n`);
235
+ sh(retired, "git", "commit", "--quiet", "-am", "retired key");
236
+ const refused = await cli(retired, "claim", keys.two);
237
+ assert.equal(refused.code, 2);
238
+ assert.match(refused.stderr, /pre-0\.8 spelling and is no longer read/);
188
239
  });
189
- /**
190
- * A monorepo with two boards for the multi-board selection paths (TAS-27):
191
- * a root board (ROO, one ready ticket) and a nested one under pkg/ (NES,
192
- * one ready ticket). The root config carries the new `claims` block and,
193
- * optionally, the top-level `boards` list — NES deliberately ahead of ROO,
194
- * so priority provably follows list order, not nearness.
195
- */
196
240
  function multiFixture(options = {}) {
197
241
  const home = tempDir();
198
242
  const bare = join(home, "origin.git");
@@ -208,10 +252,10 @@ function multiFixture(options = {}) {
208
252
  config.boards = options.boards;
209
253
  config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount };
210
254
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
211
- root.create({ title: "Root ready", status: "todo" });
255
+ const rootKey = root.create({ title: "Root ready", status: "todo" }).key;
212
256
  mkdirSync(join(seed, "pkg"), { recursive: true });
213
257
  const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" });
214
- nested.create({ title: "Nested ready", status: "todo" });
258
+ const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key;
215
259
  sh(seed, "git", "add", "-A");
216
260
  sh(seed, "git", "commit", "--quiet", "-m", "boards");
217
261
  sh(seed, "git", "push", "--quiet", "-u", "origin", "main");
@@ -223,10 +267,10 @@ function multiFixture(options = {}) {
223
267
  sh(dir, "git", "config", "user.email", "test@example.com");
224
268
  return dir;
225
269
  };
226
- return { bare, clone };
270
+ return { bare, clone, rootKey, nestedKey };
227
271
  }
228
272
  test("claim --next drains the queue in position order and exits 1 when empty", async () => {
229
- const { clone } = fixture();
273
+ const { clone, keys } = fixture();
230
274
  const dir = clone();
231
275
  const order = [];
232
276
  for (;;) {
@@ -237,12 +281,13 @@ test("claim --next drains the queue in position order and exits 1 when empty", a
237
281
  }
238
282
  assert.equal(result.code, 0, result.stderr);
239
283
  const { task, branch } = JSON.parse(result.stdout);
240
- order.push(task.number);
284
+ order.push(task.key);
241
285
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch);
242
286
  }
243
- // The jumper leads (position order), then creation order; blocked,
244
- // needs-human and backlog tickets never surface.
245
- assert.deepEqual(order, [6, 1, 2]);
287
+ // The jumper leads (position order), then creation order; blocked and
288
+ // backlog tickets never surface, while the open ask on `person` doesn't
289
+ // keep it out of the queue.
290
+ assert.deepEqual(order, [keys.jumper, keys.one, keys.two, keys.person]);
246
291
  });
247
292
  test("two concurrent claim --next calls land on different tickets", async () => {
248
293
  const { clone } = fixture();
@@ -258,40 +303,40 @@ test("two concurrent claim --next calls land on different tickets", async () =>
258
303
  assert.notEqual(ids[0], ids[1], `both claimed ${ids[0]}`);
259
304
  });
260
305
  test("--next walks the root boards list in priority order; --board overrides it", async () => {
261
- const { clone } = multiFixture({ boards: ["NES", "ROO"] });
306
+ const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] });
262
307
  const a = clone();
263
308
  // list --claimable reads the same scope, in the same order.
264
309
  const listed = await cli(a, "list", "--claimable", "--json");
265
310
  assert.equal(listed.code, 0, listed.stderr);
266
311
  const { tasks } = JSON.parse(listed.stdout);
267
- assert.deepEqual(tasks.map((t) => t.id), ["NES-1", "ROO-1"]);
312
+ assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`]);
268
313
  // The nested board wins despite the root board being nearest.
269
314
  const next = await cli(a, "claim", "--next", "--json");
270
315
  assert.equal(next.code, 0, next.stderr);
271
316
  const claimed = JSON.parse(next.stdout);
272
- assert.equal(claimed.task.id, "NES-1");
273
- assert.equal(claimed.branch, "task/claim/nes-1");
317
+ assert.equal(claimed.task.id, `NES-${nestedKey}`);
318
+ assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`);
274
319
  // An explicit --board list is the whole scope, in the given order.
275
320
  const b = clone();
276
321
  const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json");
277
322
  assert.equal(overridden.code, 0, overridden.stderr);
278
- assert.equal(JSON.parse(overridden.stdout).task.id, "ROO-1");
323
+ assert.equal(JSON.parse(overridden.stdout).task.id, `ROO-${rootKey}`);
279
324
  const unknown = await cli(b, "claim", "--next", "--board", "NOPE");
280
325
  assert.equal(unknown.code, 2);
281
326
  assert.match(unknown.stderr, /no board with prefix NOPE/);
282
327
  });
283
328
  test("without a boards list, --next sees the nearest board only", async () => {
284
- const { clone } = multiFixture();
329
+ const { clone, rootKey } = multiFixture();
285
330
  const dir = clone();
286
331
  const next = await cli(dir, "claim", "--next", "--json");
287
332
  assert.equal(next.code, 0, next.stderr);
288
- assert.equal(JSON.parse(next.stdout).task.id, "ROO-1");
333
+ assert.equal(JSON.parse(next.stdout).task.id, `ROO-${rootKey}`);
289
334
  // ROO's queue is drained and NES never entered the scope.
290
335
  const empty = await cli(dir, "claim", "--next", "--json");
291
336
  assert.equal(empty.code, 1);
292
337
  });
293
338
  test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
294
- const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 });
339
+ const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 });
295
340
  const a = clone();
296
341
  assert.equal((await cli(a, "claim", "--next", "--json")).code, 0);
297
342
  // At the cap, --next and explicit-id claims both refuse with exit 2 —
@@ -300,40 +345,181 @@ test("maxOpenCount caps every claim repo-wide; --force is the escape", async ()
300
345
  const capped = await cli(b, "claim", "--next");
301
346
  assert.equal(capped.code, 2);
302
347
  assert.match(capped.stderr, /maxOpenCount cap \(1\)/);
303
- const explicit = await cli(b, "claim", "ROO-1");
348
+ const explicit = await cli(b, "claim", `ROO-${rootKey}`);
304
349
  assert.equal(explicit.code, 2);
305
350
  assert.match(explicit.stderr, /maxOpenCount cap \(1\)/);
306
351
  const forced = await cli(b, "claim", "--next", "--force", "--json");
307
352
  assert.equal(forced.code, 0, forced.stderr);
308
- assert.equal(JSON.parse(forced.stdout).task.id, "ROO-1");
353
+ assert.equal(JSON.parse(forced.stdout).task.id, `ROO-${rootKey}`);
309
354
  });
310
355
  test("release requires a reason and leaves it as a comment on the ticket", async () => {
311
- const { clone } = fixture();
356
+ const { clone, keys } = fixture();
312
357
  const dir = clone();
313
- assert.equal((await cli(dir, "claim", "1")).code, 0);
358
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0);
314
359
  // No comment, and an empty one, are both refused before anything happens.
315
- const missing = await cli(dir, "claim", "--release", "1");
360
+ const missing = await cli(dir, "claim", "--release", keys.one);
316
361
  assert.equal(missing.code, 1);
317
362
  assert.match(missing.stderr, /requires --comment/);
318
- const empty = await cli(dir, "claim", "--release", "1", "--comment", "");
363
+ const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "");
319
364
  assert.equal(empty.code, 1);
320
365
  assert.match(empty.stderr, /requires --comment/);
321
- assert.match(sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"), /clm-1/);
322
- const released = await cli(dir, "claim", "--release", "1", "--comment", "the migration kept flaking — needs a fixture, not a retry", "--author", "claude");
366
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(`clm-${keys.one}`));
367
+ const released = await cli(dir, "claim", "--release", keys.one, "--comment", "the migration kept flaking — needs a fixture, not a retry", "--author", "claude");
323
368
  assert.equal(released.code, 0, released.stderr);
324
369
  // The reason survives the branch: an ordinary attributed comment, sitting
325
370
  // uncommitted in the working tree on the default branch.
326
- const shown = await cli(dir, "show", "1", "--json");
371
+ const shown = await cli(dir, "show", keys.one, "--json");
327
372
  const { comments } = JSON.parse(shown.stdout);
328
373
  assert.equal(comments.length, 1);
329
374
  assert.equal(comments[0].author, "claude");
330
375
  assert.match(comments[0].body, /kept flaking/);
331
- assert.match(sh(dir, "git", "status", "--porcelain"), /tickets\/1\/comments/);
376
+ assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`));
332
377
  // Releasing an unclaimed ticket stays a no-op — no second comment.
333
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "again");
378
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again");
334
379
  assert.equal(noop.code, 0);
335
380
  assert.match(noop.stdout, /wasn't claimed/);
336
- const after = await cli(dir, "show", "1", "--json");
381
+ const after = await cli(dir, "show", keys.one, "--json");
337
382
  assert.equal(JSON.parse(after.stdout).comments.length, 1);
338
383
  });
384
+ test("--lock-only claims from a dirty, branch-pinned checkout without touching it", async () => {
385
+ const { clone, keys } = fixture();
386
+ const dir = clone();
387
+ const branch = `task/claim/clm-${keys.one}`;
388
+ // The branch-pinned session: a provisioned branch, uncommitted tracked work.
389
+ sh(dir, "git", "checkout", "--quiet", "-b", "pinned");
390
+ const gitignore = join(dir, ".task", ".gitignore");
391
+ writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`);
392
+ const claimed = await cli(dir, "claim", keys.one, "--lock-only", "--json");
393
+ assert.equal(claimed.code, 0, claimed.stderr);
394
+ const parsed = JSON.parse(claimed.stdout);
395
+ assert.equal(parsed.branch, branch);
396
+ assert.equal(parsed.deliveredBy, "pinned");
397
+ assert.equal(parsed.task.status, "in_progress");
398
+ // The checkout is exactly where it was: same branch, same dirty file, and
399
+ // the local ticket file never flipped — the claim lives on origin only.
400
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned");
401
+ assert.match(sh(dir, "git", "status", "--porcelain", "-uno"), /\.task\/\.gitignore/);
402
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/);
403
+ // On origin the claim is a real flip commit built on the default branch,
404
+ // carrying the delivery branch as a trailer.
405
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`));
406
+ assert.equal(sh(dir, "git", "rev-parse", `origin/${branch}~1`), sh(dir, "git", "rev-parse", "origin/main"));
407
+ assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/);
408
+ const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`);
409
+ assert.match(message, /lock-only/);
410
+ assert.match(message, new RegExp(`delivered_by: pinned`));
411
+ // The lock is the same lock: both claim flavors lose to it from elsewhere.
412
+ const other = clone();
413
+ assert.equal((await cli(other, "claim", keys.one)).code, 1);
414
+ assert.equal((await cli(other, "claim", keys.one, "--lock-only")).code, 1);
415
+ // Release is symmetric: no local branch to delete, the origin one goes, and
416
+ // the pinned checkout still isn't touched.
417
+ const released = await cli(dir, "claim", "--release", keys.one, "--comment", "handing back a lock-only claim");
418
+ assert.equal(released.code, 0, released.stderr);
419
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned");
420
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "");
421
+ assert.equal((await cli(other, "claim", keys.one)).code, 0);
422
+ });
423
+ test("--lock-only validates against origin's default branch, not the checkout", async () => {
424
+ const { clone, keys } = fixture();
425
+ const dir = clone();
426
+ // Not todo on the base tree → exit 2, and nothing minted on origin.
427
+ const parked = await cli(dir, "claim", keys.parked, "--lock-only");
428
+ assert.equal(parked.code, 2);
429
+ assert.match(parked.stderr, /is backlog on origin\/main/);
430
+ // A local promote that hasn't landed on the default branch doesn't count:
431
+ // the flip commit is built on origin/main, where the ticket is still backlog.
432
+ assert.equal((await cli(dir, "promote", keys.parked)).code, 0);
433
+ const promoted = await cli(dir, "claim", keys.parked, "--lock-only");
434
+ assert.equal(promoted.code, 2);
435
+ assert.match(promoted.stderr, /is backlog on origin\/main/);
436
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.parked}`), "");
437
+ // Blocked on the base tree refuses too.
438
+ const blocked = await cli(dir, "claim", keys.blocked, "--lock-only");
439
+ assert.equal(blocked.code, 2);
440
+ assert.match(blocked.stderr, new RegExp(`blocked by CLM-${keys.one}`));
441
+ });
442
+ test("plain claim warns when it moves the checkout off a non-default branch", async () => {
443
+ const { clone, keys } = fixture();
444
+ const dir = clone();
445
+ sh(dir, "git", "checkout", "--quiet", "-b", "feature");
446
+ const claimed = await cli(dir, "claim", keys.one);
447
+ assert.equal(claimed.code, 0, claimed.stderr);
448
+ assert.match(claimed.stdout, /moved you off feature/);
449
+ assert.match(claimed.stdout, /--lock-only/);
450
+ // From the default branch there is nothing to warn about.
451
+ const other = clone();
452
+ const quiet = await cli(other, "claim", keys.two);
453
+ assert.equal(quiet.code, 0, quiet.stderr);
454
+ assert.doesNotMatch(quiet.stdout, /moved you off/);
455
+ });
456
+ test("sweep deletes done/canceled/missing claims and keeps live ones", async () => {
457
+ const { clone, keys } = fixture();
458
+ const a = clone();
459
+ assert.equal((await cli(a, "claim", keys.one)).code, 0);
460
+ assert.equal((await cli(a, "claim", keys.two)).code, 0);
461
+ assert.equal((await cli(a, "claim", keys.person)).code, 0);
462
+ // A second checkout is where the tickets' fates land (a merge, normally):
463
+ // one finishes, person is canceled, two stays live — plus a branch under
464
+ // the namespace whose ticket never existed.
465
+ const b = clone();
466
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0);
467
+ assert.equal((await cli(b, "move", keys.person, "canceled")).code, 0);
468
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-zz9zz");
469
+ // Namespace squatters sweep must NOT touch: a pre-migration numeric claim
470
+ // (its branch may head an open PR carrying unmerged work — the TAS-31 case)
471
+ // and a branch matching no board's prefix. Skipped and reported, never
472
+ // deleted — sweep's jurisdiction is exactly the names `claim` mints.
473
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-31");
474
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/other-zz9zz");
475
+ const swept = await cli(b, "sweep", "--json");
476
+ assert.equal(swept.code, 0, swept.stderr);
477
+ const { sweep } = JSON.parse(swept.stdout);
478
+ assert.deepEqual(sweep.swept.map((s) => [s.branch, s.reason]).sort(), [
479
+ [`task/claim/clm-${keys.one}`, "done"],
480
+ [`task/claim/clm-${keys.person}`, "canceled"],
481
+ ["task/claim/clm-zz9zz", "missing"],
482
+ ].sort());
483
+ assert.deepEqual(sweep.kept, [
484
+ { branch: `task/claim/clm-${keys.two}`, ticket: `CLM-${keys.two}`, status: "todo" },
485
+ ]);
486
+ assert.deepEqual(sweep.skipped.sort(), ["task/claim/clm-31", "task/claim/other-zz9zz"]);
487
+ assert.equal(sweep.failed.length, 0);
488
+ // Origin agrees: the live claim and the out-of-scheme branches survive,
489
+ // and a re-run is a no-op.
490
+ const remote = sh(b, "git", "ls-remote", "--heads", "origin", "task/claim/*");
491
+ assert.deepEqual(remote.split("\n").map((l) => l.split("refs/heads/")[1]).sort(), ["task/claim/clm-31", `task/claim/clm-${keys.two}`, "task/claim/other-zz9zz"].sort());
492
+ const again = await cli(b, "sweep", "--json");
493
+ assert.equal(again.code, 0);
494
+ assert.equal(JSON.parse(again.stdout).sweep.swept.length, 0);
495
+ });
496
+ test("verified deletion: a remote that drops deletions makes release and sweep fail loudly", { skip: process.platform === "win32" }, async () => {
497
+ const { clone, keys } = fixture();
498
+ const path = deleteDroppingPath();
499
+ const dir = clone();
500
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0);
501
+ // Release under the shim: git says the deletion worked, the ref survives —
502
+ // pre-TAS-8b7s9 this reported "released" and left a stale lock behind.
503
+ const released = await cliEnv(dir, { PATH: path }, "claim", "--release", keys.one, "--comment", "should not land");
504
+ assert.notEqual(released.code, 0);
505
+ assert.match(released.stderr, /origin still has/);
506
+ assert.doesNotMatch(released.stdout, /released CLM/);
507
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(`clm-${keys.one}`));
508
+ // No release, no comment: the failure must not read as a done release.
509
+ const shown = await cli(dir, "show", keys.one, "--json");
510
+ assert.equal(JSON.parse(shown.stdout).comments.length, 0);
511
+ // Sweep under the shim reports the failed deletion and exits non-zero.
512
+ const b = clone();
513
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0);
514
+ const blocked = await cliEnv(b, { PATH: path }, "sweep", "--json");
515
+ assert.equal(blocked.code, 1);
516
+ const { sweep } = JSON.parse(blocked.stdout);
517
+ assert.equal(sweep.swept.length, 0);
518
+ assert.equal(sweep.failed.length, 1);
519
+ assert.match(sweep.failed[0].error, /origin still has/);
520
+ // A capable runner heals it: the same sweep with real git succeeds.
521
+ const healed = await cli(b, "sweep");
522
+ assert.equal(healed.code, 0, healed.stderr);
523
+ assert.equal(sh(b, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`), "");
524
+ });
339
525
  //# sourceMappingURL=claim.test.js.map