@clipboard-health/groundcrew 4.37.1 → 4.39.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.
@@ -73,6 +73,12 @@ conda.anaconda.org
73
73
  registry.npmjs.org
74
74
  www.npmjs.com
75
75
 
76
+ # PyPI registry + package CDN (pip / uv). pypi.org serves the Simple index;
77
+ # files.pythonhosted.org serves the wheel/sdist artifacts. uv's managed-Python
78
+ # downloads come from github.com (already allowed above).
79
+ files.pythonhosted.org
80
+ pypi.org
81
+
76
82
  # Google APIs
77
83
  developers.google.com
78
84
  www.googleapis.com
@@ -1 +1 @@
1
- {"version":3,"file":"task.d.ts","sourceRoot":"","sources":["../../src/commands/task.ts"],"names":[],"mappings":"AAoyBA,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB3D"}
1
+ {"version":3,"file":"task.d.ts","sourceRoot":"","sources":["../../src/commands/task.ts"],"names":[],"mappings":"AAwyBA,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB3D"}
@@ -2,6 +2,7 @@ import { createBoard } from "../lib/board.js";
2
2
  import { buildSources, sourcesFromConfig } from "../lib/buildSources.js";
3
3
  import { AGENT_ANY, loadConfig } from "../lib/config.js";
4
4
  import { findPullRequestsForBranch } from "../lib/pullRequests.js";
5
+ import { resolveTaskIdMatches } from "../lib/taskResolution.js";
5
6
  import { naturalIdFromCanonical, } from "../lib/taskSource.js";
6
7
  import { parseSourceFilterArgs, writeOutput } from "../lib/util.js";
7
8
  import { worktrees } from "../lib/worktrees.js";
@@ -387,8 +388,28 @@ function canonicalParts(taskId) {
387
388
  }
388
389
  return { sourceName, naturalId };
389
390
  }
390
- async function taskFromSource(source, naturalId) {
391
- return await source.getTask(naturalId);
391
+ function taskFromResolution({ taskId, resolution, sourceName }) {
392
+ if (resolution.matches.length === 0) {
393
+ if (resolution.rejections.length > 0) {
394
+ throw resolution.rejections[0];
395
+ }
396
+ if (sourceName !== undefined) {
397
+ throw new Error(`Task ${taskId} not found in source "${sourceName}".`);
398
+ }
399
+ throw new Error(`Task ${taskId} not found across configured sources.`);
400
+ }
401
+ if (resolution.matches.length > 1) {
402
+ if (resolution.matchKind === "exact" && sourceName === undefined) {
403
+ throw new Error(`Task id "${taskId}" matched multiple sources: ${resolution.matches.map((task) => task.id).join(", ")}. Re-run with a canonical id or --source <name>.`);
404
+ }
405
+ throw new Error(`Task id "${taskId}" matched multiple tasks: ${resolution.matches.map((task) => task.id).join(", ")}. Re-run with a longer prefix or canonical id.`);
406
+ }
407
+ const [match] = resolution.matches;
408
+ /* v8 ignore next 3 @preserve -- matches.length was checked above; guard exists for noUncheckedIndexedAccess */
409
+ if (match === undefined) {
410
+ throw new Error(`Task ${taskId} not found across configured sources.`);
411
+ }
412
+ return match;
392
413
  }
393
414
  async function resolveTask(sources, taskId, sourceName) {
394
415
  const canonical = canonicalParts(taskId);
@@ -397,47 +418,24 @@ async function resolveTask(sources, taskId, sourceName) {
397
418
  throw new Error(`crew task get: canonical id "${taskId}" already names source "${canonical.sourceName}"`);
398
419
  }
399
420
  const source = findSource(sources, canonical.sourceName);
400
- const task = await taskFromSource(source, canonical.naturalId);
401
- if (task === null) {
402
- throw new Error(`Task ${taskId} not found in source "${source.name}".`);
403
- }
404
- return task;
421
+ return taskFromResolution({
422
+ taskId,
423
+ sourceName: source.name,
424
+ resolution: await resolveTaskIdMatches({ sources: [source], naturalId: canonical.naturalId }),
425
+ });
405
426
  }
406
427
  if (sourceName !== undefined) {
407
428
  const source = findSource(sources, sourceName);
408
- const task = await taskFromSource(source, taskId);
409
- if (task === null) {
410
- throw new Error(`Task ${taskId} not found in source "${source.name}".`);
411
- }
412
- return task;
413
- }
414
- const results = await Promise.allSettled(sources.map(async (source) => await taskFromSource(source, taskId)));
415
- const matches = [];
416
- const rejections = [];
417
- for (const result of results) {
418
- if (result.status === "fulfilled") {
419
- if (result.value !== null) {
420
- matches.push(result.value);
421
- }
422
- continue;
423
- }
424
- rejections.push(result.reason);
425
- }
426
- if (matches.length === 0) {
427
- if (rejections.length > 0) {
428
- throw rejections[0];
429
- }
430
- throw new Error(`Task ${taskId} not found across configured sources.`);
431
- }
432
- if (matches.length > 1) {
433
- throw new Error(`Task id "${taskId}" matched multiple sources: ${matches.map((task) => task.id).join(", ")}. Re-run with a canonical id or --source <name>.`);
434
- }
435
- const [match] = matches;
436
- /* v8 ignore next 3 @preserve -- matches.length was checked above; guard exists for noUncheckedIndexedAccess */
437
- if (match === undefined) {
438
- throw new Error(`Task ${taskId} not found across configured sources.`);
439
- }
440
- return match;
429
+ return taskFromResolution({
430
+ taskId,
431
+ sourceName: source.name,
432
+ resolution: await resolveTaskIdMatches({ sources: [source], naturalId: taskId }),
433
+ });
434
+ }
435
+ return taskFromResolution({
436
+ taskId,
437
+ resolution: await resolveTaskIdMatches({ sources, naturalId: taskId }),
438
+ });
441
439
  }
442
440
  function writeTaskDetails(task) {
443
441
  writeOutput(task.id);
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/linear/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAIL,KAAK,KAAK,IAAI,cAAc,EAG5B,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,EAIL,KAAK,KAAK,IAAI,WAAW,EAE1B,MAAM,YAAY,CAAC;AAEpB,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,kBAAkB,CAAC;AAG1B;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,iGAAiG;IACjG,YAAY,EAAE,MAAM,CAAC;CACtB;AA2DD,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,WAAW,EACxB,UAAU,EAAE,MAAM,EAClB,WAAW,GAAE,iBAA+C,GAC3D,cAAc,CA4BhB;AAED,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,cAAc,GACtB,UAAU,CAqIZ"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/linear/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAIL,KAAK,KAAK,IAAI,cAAc,EAG5B,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,EAIL,KAAK,KAAK,IAAI,WAAW,EAE1B,MAAM,YAAY,CAAC;AAEpB,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,kBAAkB,CAAC;AAG1B;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,iGAAiG;IACjG,YAAY,EAAE,MAAM,CAAC;CACtB;AA8DD,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,WAAW,EACxB,UAAU,EAAE,MAAM,EAClB,WAAW,GAAE,iBAA+C,GAC3D,cAAc,CA4BhB;AAED,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,cAAc,GACtB,UAAU,CAkIZ"}
@@ -23,17 +23,16 @@ function canonicalBlockerStatus(blocker, statusNames) {
23
23
  stateType: blocker.stateType,
24
24
  statusNames,
25
25
  });
26
- if (status !== "other") {
27
- return {
26
+ return status === "other"
27
+ ? {
28
+ status,
29
+ statusReason: blocker.stateType === undefined ? "missing" : "unmapped",
30
+ ...(blocker.status !== undefined && { nativeStatus: blocker.status }),
31
+ }
32
+ : {
28
33
  status,
29
34
  ...(blocker.status !== undefined && { nativeStatus: blocker.status }),
30
35
  };
31
- }
32
- return {
33
- status,
34
- statusReason: blocker.stateType === undefined ? "missing" : "unmapped",
35
- ...(blocker.status !== undefined && { nativeStatus: blocker.status }),
36
- };
37
36
  }
38
37
  function toCanonicalBlocker(blocker, sourceName, statusNames) {
39
38
  const { status, statusReason, nativeStatus } = canonicalBlockerStatus(blocker, statusNames);
@@ -57,6 +56,9 @@ function isLinearNotFoundError(error, naturalId) {
57
56
  error.message.startsWith(`Task ${naturalId.toUpperCase()} `) &&
58
57
  error.message.includes("not found"));
59
58
  }
59
+ function throwLinearLookupError(error) {
60
+ throw error;
61
+ }
60
62
  export function toCanonicalIssue(linearIssue, sourceName, statusNames = DEFAULT_LINEAR_STATUS_NAMES) {
61
63
  const sourceRef = {
62
64
  uuid: linearIssue.uuid,
@@ -127,10 +129,7 @@ export function createLinearTaskSource(config, context) {
127
129
  });
128
130
  }
129
131
  catch (error) {
130
- if (isLinearNotFoundError(error, naturalId)) {
131
- return null;
132
- }
133
- throw error;
132
+ return isLinearNotFoundError(error, naturalId) ? null : throwLinearLookupError(error);
134
133
  }
135
134
  const sourceRef = {
136
135
  uuid: resolved.uuid,
@@ -9,8 +9,9 @@ export interface Board {
9
9
  verify: () => Promise<void>;
10
10
  fetch: () => Promise<BoardState>;
11
11
  /**
12
- * Accepts either canonical (`linear:eng-220`) or natural (`eng-220`) ids.
13
- * Natural ids fan out across sources; ambiguous matches throw.
12
+ * Accepts either canonical (`linear:eng-220`) or natural (`eng-220`) ids,
13
+ * plus unique prefixes of current listed tasks. Natural ids fan out across
14
+ * sources; ambiguous matches throw.
14
15
  */
15
16
  resolveOne: (canonicalOrNaturalId: string) => Promise<Issue | undefined>;
16
17
  /** Routes to the adapter whose `name` matches `issue.source`. Unknown source throws. */
@@ -1 +1 @@
1
- {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,KAAK,EACV,KAAK,cAAc,EACnB,KAAK,kBAAkB,EAEvB,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,KAAK,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC;IACjC;;;OAGG;IACH,UAAU,EAAE,CAAC,oBAAoB,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC;IACzE,wFAAwF;IACxF,cAAc,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD;;;;OAIG;IACH,YAAY,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC5D;;;;OAIG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;CACrD;AAqBD,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,GAAG,KAAK,CAmHjE"}
1
+ {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,KAAK,EACV,KAAK,cAAc,EACnB,KAAK,kBAAkB,EAEvB,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AAGzB,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,KAAK,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC;IACjC;;;;OAIG;IACH,UAAU,EAAE,CAAC,oBAAoB,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC;IACzE,wFAAwF;IACxF,cAAc,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD;;;;OAIG;IACH,YAAY,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC5D;;;;OAIG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;CACrD;AA0CD,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,GAAG,KAAK,CA8FjE"}
package/dist/lib/board.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * back into consumers.
6
6
  */
7
7
  import { AmbiguousTaskError, } from "./taskSource.js";
8
+ import { resolveTaskIdMatches } from "./taskResolution.js";
8
9
  async function callVerify(source) {
9
10
  await source.verify();
10
11
  }
@@ -17,8 +18,21 @@ async function callFetchParentSkips(source) {
17
18
  }
18
19
  return [];
19
20
  }
20
- async function callResolveOne(source, naturalId) {
21
- return (await source.getTask(naturalId)) ?? undefined;
21
+ function uniqueResolvedIssue({ idArgument, resolution, }) {
22
+ if (resolution.matches.length === 0) {
23
+ if (resolution.rejections.length > 0) {
24
+ throw resolution.rejections[0];
25
+ }
26
+ return undefined;
27
+ }
28
+ if (resolution.matches.length === 1) {
29
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- length checked above
30
+ return resolution.matches[0];
31
+ }
32
+ throw new AmbiguousTaskError({
33
+ naturalId: idArgument,
34
+ matches: resolution.matches.map((match) => match.id),
35
+ });
22
36
  }
23
37
  export function createBoard(sources) {
24
38
  const byName = new Map();
@@ -69,7 +83,10 @@ export function createBoard(sources) {
69
83
  if (!source) {
70
84
  throw new Error(`unknown source "${sourceName}" in canonical id "${idArgument}"`);
71
85
  }
72
- return await callResolveOne(source, naturalId);
86
+ return uniqueResolvedIssue({
87
+ idArgument,
88
+ resolution: await resolveTaskIdMatches({ sources: [source], naturalId }),
89
+ });
73
90
  }
74
91
  // Per-source resolveOne errors must not poison sibling resolutions.
75
92
  // A source that rejects on a natural-id lookup is treated as "I don't
@@ -78,31 +95,9 @@ export function createBoard(sources) {
78
95
  // the rejection — so the user sees a real Linear/network error when
79
96
  // there's no fallback, but a stray "not found" from one source doesn't
80
97
  // mask a successful match from another.
81
- const results = await Promise.allSettled(sources.map(async (s) => await callResolveOne(s, idArgument)));
82
- const matches = [];
83
- const rejections = [];
84
- for (const result of results) {
85
- if (result.status === "rejected") {
86
- rejections.push(result.reason);
87
- continue;
88
- }
89
- if (result.value !== undefined) {
90
- matches.push(result.value);
91
- }
92
- }
93
- if (matches.length === 0) {
94
- if (rejections.length > 0) {
95
- throw rejections[0];
96
- }
97
- return undefined;
98
- }
99
- if (matches.length === 1) {
100
- // oxlint-disable-next-line typescript/no-non-null-assertion -- length checked above
101
- return matches[0];
102
- }
103
- throw new AmbiguousTaskError({
104
- naturalId: idArgument,
105
- matches: matches.map((m) => m.id),
98
+ return uniqueResolvedIssue({
99
+ idArgument,
100
+ resolution: await resolveTaskIdMatches({ sources, naturalId: idArgument }),
106
101
  });
107
102
  },
108
103
  async markInProgress(issue) {
@@ -0,0 +1,14 @@
1
+ import { type Task, type TaskSource } from "./taskSource.ts";
2
+ type TaskMatchKind = "exact" | "prefix" | "none";
3
+ export interface TaskResolutionMatches {
4
+ matches: Task[];
5
+ rejections: unknown[];
6
+ matchKind: TaskMatchKind;
7
+ }
8
+ interface CollectExactTaskMatchesArguments {
9
+ sources: readonly TaskSource[];
10
+ naturalId: string;
11
+ }
12
+ export declare function resolveTaskIdMatches(arguments_: CollectExactTaskMatchesArguments): Promise<TaskResolutionMatches>;
13
+ export {};
14
+ //# sourceMappingURL=taskResolution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"taskResolution.d.ts","sourceRoot":"","sources":["../../src/lib/taskResolution.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,IAAI,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAErF,KAAK,aAAa,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,IAAI,EAAE,CAAC;IAChB,UAAU,EAAE,OAAO,EAAE,CAAC;IACtB,SAAS,EAAE,aAAa,CAAC;CAC1B;AAED,UAAU,gCAAgC;IACxC,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,gCAAgC,GAC3C,OAAO,CAAC,qBAAqB,CAAC,CAehC"}
@@ -0,0 +1,53 @@
1
+ import { naturalIdFromCanonical } from "./taskSource.js";
2
+ export async function resolveTaskIdMatches(arguments_) {
3
+ const exact = await collectExactTaskMatches(arguments_);
4
+ if (exact.matches.length > 0) {
5
+ return { ...exact, matchKind: "exact" };
6
+ }
7
+ const prefix = await collectPrefixTaskMatches({
8
+ sources: arguments_.sources,
9
+ naturalIdPrefix: arguments_.naturalId,
10
+ });
11
+ const rejections = [...exact.rejections, ...prefix.rejections];
12
+ if (prefix.matches.length > 0) {
13
+ return { matches: prefix.matches, rejections, matchKind: "prefix" };
14
+ }
15
+ return { matches: [], rejections, matchKind: "none" };
16
+ }
17
+ async function collectExactTaskMatches({ sources, naturalId, }) {
18
+ const results = await Promise.allSettled(sources.map(async (source) => await source.getTask(naturalId)));
19
+ const matches = [];
20
+ const rejections = [];
21
+ for (const result of results) {
22
+ if (result.status === "fulfilled") {
23
+ if (result.value !== null) {
24
+ matches.push(result.value);
25
+ }
26
+ continue;
27
+ }
28
+ rejections.push(result.reason);
29
+ }
30
+ return { matches, rejections };
31
+ }
32
+ async function collectPrefixTaskMatches({ sources, naturalIdPrefix, }) {
33
+ const results = await Promise.allSettled(sources.map(async (source) => {
34
+ const tasks = await source.listTasks();
35
+ return tasks.filter((task) => taskMatchesNaturalIdPrefix({ task, naturalIdPrefix }));
36
+ }));
37
+ const matches = [];
38
+ const rejections = [];
39
+ for (const result of results) {
40
+ if (result.status === "fulfilled") {
41
+ matches.push(...result.value);
42
+ continue;
43
+ }
44
+ rejections.push(result.reason);
45
+ }
46
+ return { matches, rejections };
47
+ }
48
+ function taskMatchesNaturalIdPrefix({ task, naturalIdPrefix, }) {
49
+ if (naturalIdPrefix.length === 0) {
50
+ return false;
51
+ }
52
+ return naturalIdFromCanonical(task.id).toLowerCase().startsWith(naturalIdPrefix.toLowerCase());
53
+ }
@@ -36,7 +36,7 @@ export class TaskSourceOutputError extends Error {
36
36
  export class AmbiguousTaskError extends Error {
37
37
  constructor(arguments_) {
38
38
  const { naturalId, matches } = arguments_;
39
- super(`Task id "${naturalId}" is ambiguous; matched in multiple sources: ${matches.join(", ")}. Re-invoke with one of those canonical ids.`);
39
+ super(`Task id "${naturalId}" is ambiguous; matched multiple tasks: ${matches.join(", ")}. Re-invoke with a longer prefix or one of those canonical ids.`);
40
40
  this.name = "AmbiguousTaskError";
41
41
  }
42
42
  }
package/docs/commands.md CHANGED
@@ -10,7 +10,7 @@ crew task list --source todo --status todo --unblocked
10
10
  crew task list --agent claude-fable --repo ClipboardHealth/api --json
11
11
  ```
12
12
 
13
- `crew task get <task-id>` prints one normalized task. Canonical IDs such as `todo:GC-20260608-001` route directly to the named source. Natural IDs can be resolved with `--source <name>` or, when unique, by searching all configured sources. If more than one source matches, the command fails and asks for a canonical ID or `--source`.
13
+ `crew task get <task-id>` prints one normalized task. Canonical IDs such as `todo:GC-20260608-001` route directly to the named source. Natural IDs can be resolved with `--source <name>` or, when unique, by searching all configured sources. Exact IDs are tried first; if none match, Groundcrew accepts a unique prefix of a current listed task ID. If more than one task matches, the command fails and prints the matching canonical IDs.
14
14
 
15
15
  ```bash
16
16
  crew task get todo:GC-20260608-001
@@ -44,8 +44,9 @@ crew task create "Fix cancellation retry race" \
44
44
  `crew task done <task-id>` marks one task done through its source adapter. Use
45
45
  it for completed work that intentionally does not produce a PR. The command
46
46
  resolves canonical IDs such as `todo:flaky-triage-1` directly, or natural IDs
47
- when they match exactly one configured source. Sources without a done writeback
48
- return an unsupported error.
47
+ when they match exactly one configured source. Exact IDs are tried first; if
48
+ none match, Groundcrew accepts a unique prefix of a current listed task ID.
49
+ Sources without a done writeback return an unsupported error.
49
50
 
50
51
  Groundcrew checks matching local worktrees before marking a task done. Clean
51
52
  worktrees, and tasks with no local worktree, are allowed. A dirty worktree with
package/docs/runners.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  Only applies when `local.runner` resolves to `safehouse`. Groundcrew starts `clearance` on `http://127.0.0.1:19999` and runs the agent through the bundled `safehouse-clearance` wrapper. Groundcrew automatically points clearance at its shipped starter allowlist, so a fresh install does not need a `CLEARANCE_ALLOW_HOSTS_FILES` export.
17
17
 
18
- Groundcrew ships that starter file at `$(npm root -g)/@clipboard-health/groundcrew/clearance-allow-hosts`, covering model APIs, Linear, Notion, Slack, Datadog, GitHub, npm, and common dev tooling.
18
+ Groundcrew ships that starter file at `$(npm root -g)/@clipboard-health/groundcrew/clearance-allow-hosts`, covering model APIs, Linear, Notion, Slack, Datadog, GitHub, npm, PyPI, and common dev tooling.
19
19
 
20
20
  To add ad hoc hosts for one run, use `CLEARANCE_ALLOW_HOSTS`:
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.37.1",
3
+ "version": "4.39.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",