diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index bfb33e71b1..b2e07a2a35 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "genesis-tools",
- "version": "1.0.113",
+ "version": "1.0.114",
"description": "Plugins for GenesisTools CLI development and management",
"owner": {
"name": "genesiscz",
@@ -11,7 +11,7 @@
{
"name": "genesis-tools",
"description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.",
- "version": "1.0.113",
+ "version": "1.0.114",
"author": {
"name": "GenesisTools"
},
diff --git a/docs/benchmarks-du.md b/docs/benchmarks-du.md
index 667fc93b4b..eb8e58092f 100644
--- a/docs/benchmarks-du.md
+++ b/docs/benchmarks-du.md
@@ -1322,3 +1322,88 @@ to the 512 cap, and a scratch directory registers nothing outside itself. `bun s
(with the two other touched suites) exited 0: 101 pass, 0 fail. The foreign check requires `foreign>=1`, `missing=0`
and `leaked=0`. `src/du/native/bench.sh` was not run. The change is the mount list for root `/`, and a bench must not
walk `/`.
+
+## 2026-10-22 18:57 — Cloud-backed content is measured by stat only by default (dataless files, File Provider domains)
+
+Opening a dataless file makes `fileproviderd` download it. Before this change the default scan pruned only
+`~/Library/CloudStorage` and `~/Library/Mobile Documents` by name, so Desktop and Documents synced to iCloud (and a
+scan rooted inside a provider folder) were walked with the normal open + `F_LOG2PHYS_EXT` path for every shared file.
+`clonesize ~/Desktop --freeable` and the same for `~/Documents` each hit a 45 s timeout on this Mac.
+
+What changed in `clonesize.c`:
+
+- Every walk (scan, `--bigfiles`, `--index`) requests `ATTR_CMN_FLAGS` (bit 0x00040000, between MODTIME and FILEID),
+ so each entry layout gains a u32 at offset 52 and every later field moves by 4. The Bun engine's shim
+ (`cs_getattrbulk`) gains it at offset 36.
+- A file is cloud-backed when it has `SF_DATALESS`, or when it sits under a File Provider domain root (the
+ `com.apple.file-provider-domain-id` xattr). Only the places a root can sit pay for a `getxattr`: children of a home
+ folder, of `.../Library/CloudStorage`, `.../Library/Mobile Documents`, and the scan root with its ancestors.
+- Default: a cloud-backed file is counted from the listing (allocated size into `naive`, and in full into the unique
+ totals) and never opened. A dataless folder is counted and never entered. Each walk thread sets
+ `IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES` to off. `--include-cloud` lifts all of it.
+- `--bigfiles` leaves dataless files out (`dataless_skipped`); `--index` flags a dataless folder `IDX_F_CLOUD` and
+ does not enter it; a new `--cloudfiles` mode lists the downloaded cloud files for `tools du downloaded`.
+
+Which totals move: only trees that hold cloud-backed content. There, clone sharing between cloud-backed files is no
+longer deduped (they count at their full allocation, as `du` does), and `files_opened` drops to 0 for them. Every
+total on a tree without cloud-backed content is unchanged. Checked old binary against new, back to back, at the same
+moment (the repo is a live tree, so the T1 numbers of the two bench runs below differ between runs):
+
+| Target | old vs new |
+|---|---|
+| T1 (repo) `--json` | identical `files_listed` 331921, `files_opened` 115978, `naive` 10743083008, `unique` 8786771088, `unique_allocated` 9595559936, `shared` 1147523072 |
+| T2 (node_modules) `--json` | identical, `naive` 1680945152, `unique` 1238311583 |
+| T2 `--index --threads 8` | 784 rows identical once node ids (scheduling order) are removed |
+| T1 `--bigfiles --min-bytes 1000000` | 1176 files, same paths, `dataless_skipped` 0 |
+
+`src/du/native/bench.sh` (system CPU is the metric; the load average was 20 to 88 throughout, so wall time is noise):
+
+| Mode | before (sys s) | after (sys s) |
+|---|---|---|
+| T1 flat | 33.48 | 32.98 |
+| T1 depth2 | 30.44 | 30.31 |
+| T2 flat | 7.91 | 9.30 |
+| T2 depth2 | 7.08 | 6.66 |
+| T1 cache-cold | 28.31 | 27.66 |
+| T1 cache-warm | 17.86 | 17.51 |
+
+Load: before 35.00 at start, 88.66 at end; after 20.09 at start, 69.52 at end. Old against new in one hyperfine run
+(load 60): `--index` T2 2.66 s against 2.39 s system, `--bigfiles` T1 6.95 s against 5.16 s. No regression shows
+above the noise; the flags attribute is one more u32 per entry in the same kernel call.
+
+Cloud folders with the new binary (counts by lstat only: `find -x
\( -flags +dataless -prune -type f -print \)`):
+
+| Folder | wall | opened | dataless files before / after | local KB before / after |
+|---|---|---|---|---|
+| ~/Desktop (iCloud) | 0.01 s | 0 | 889 / 889 | 3840 / 3840 |
+| ~/Documents (iCloud) | 0.10 s | 0 | 8949 / 8949 | 159140 / 159140 |
+| ~/Library/CloudStorage/Dropbox | 0.23 s | 0 | 52753 / 52753 | 0 / 0 |
+| Tresorit Drive mount root | 0.00 s | 0 | 24 dataless folders, none entered / 24 | — |
+
+## 2026-10-22 19:27 — `tools du evict` without a Swift compiler
+
+No scan code changed: the C engine, the Bun engine and their totals are untouched, so no benchmark was rerun.
+`evict` now picks its helper: the compiled `cloudctl` when it exists or `swiftc` can build it, otherwise
+`native/cloudctl.js` through `osascript -l JavaScript`. Developer tools are detected with `xcode-select -p`,
+because `/usr/bin/swiftc` exists on every Mac and opens the "install the tools" dialog when they are missing.
+
+Parity, status of every downloaded ~/Desktop file plus two control paths: 278 rows from each helper, 0 differing.
+The JS helper must pass the keys as an `NSMutableArray`; a JS array bridges to an empty key list, and single-key
+reads leave out the upload error that the batched Swift read returns.
+
+| Check | Swift helper | osascript helper (helper moved aside, fake `xcode-select` exiting 2) |
+|---|---|---|
+| `evict ~/Desktop --all --top 1000`, dry run | 45 eligible (180 KB), 231 refused | 45 eligible (180 KB), 231 refused (194 not uploaded, 37 not iCloud) |
+| `evict --apply` on a probe file created in iCloud Drive | — | evicted: 176 blocks → 0, flags `compressed,dataless` |
+
+## 2026-10-22 21:52 — PR #558 review: the Bun planner counts dataless folders once, and `evict` has a deadline
+
+No hot-loop code changed: the C engine is untouched, and in the Bun engine only the planning walk (a few hundred
+directory listings on the main thread) moved, so no benchmark was rerun. One total moves, in the Bun engine only:
+`cloud_backed.dataless_dirs` goes down to the C engine's count. The planner counted a dataless folder when it listed
+its parent, so a frontier leaf was counted again by the worker that recursed it, and once more on every later
+planning round. It now counts only the dataless folders of interior directories, which no worker lists.
+
+The planning walk also runs with dataless materialization off, and a dataless scan root is never listed. The scan
+then finds nothing, as the C engine's guarded open of that root does. `cloudctl` runs stop after 60 s plus 2 s per
+path, and the call fails with that reason (an `evict` names that some files may already be evicted).
diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json
index 5bb0e9df88..68d79199bf 100644
--- a/plugins/genesis-tools/.claude-plugin/plugin.json
+++ b/plugins/genesis-tools/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "genesis-tools",
- "version": "1.0.113",
+ "version": "1.0.114",
"description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.",
"author": {
"name": "GenesisTools"
diff --git a/plugins/genesis-tools/skills/azure-devops/SKILL.md b/plugins/genesis-tools/skills/azure-devops/SKILL.md
index b6f76a614a..bb7729bb28 100644
--- a/plugins/genesis-tools/skills/azure-devops/SKILL.md
+++ b/plugins/genesis-tools/skills/azure-devops/SKILL.md
@@ -19,7 +19,7 @@ az login --allow-no-subscriptions --use-device-code # if Conditional Access b
tools azure-devops configure "https://dev.azure.com/MyOrg/MyProject/_workitems"
```
-`configure` takes any Azure DevOps URL of the project and writes `.claude/azure/config.json`. The tool finds that file from the current directory up to three parent levels. A missing login or config stops the command with a message that names the missing step. Add `-v` for debug logging. `tools azure-devops --readme` prints the full README.
+`configure` takes any Azure DevOps URL of the project and writes `.claude/azure/config.json`. The tool finds that file from the current directory up to three parent levels. To use a config file from any folder, pass `--config ` (a global option, valid before or after the subcommand) or set `AZURE_DEVOPS_CONFIG=`. The order is `--config`, then `AZURE_DEVOPS_CONFIG`, then the search. An explicit path that is not a readable file stops the command with an error; it never falls back to the search. With an explicit config, the `tasks/` and `wiki/` folders live next to that file instead of in the working directory, and `configure` writes to that path. A missing login or config stops the command with a message that names the missing step. Add `-v` for debug logging. `tools azure-devops --readme` prints the full README.
## CLI Reference
@@ -46,6 +46,7 @@ tools azure-devops comment add --file x.md # Post markdown (--text, --file
tools azure-devops comment edit --file x.md # Replace a comment's text
tools azure-devops comment delete --yes # --yes is required without a terminal (alias: rm)
tools azure-devops workitem-create # Create work item (alias: create)
+tools azure-devops workitem-update # Read multilineFieldsFormat; with --field, patch in one request (alias: update)
tools azure-devops timelog configure # Interactive: setup API key, user, allowed types
tools azure-devops timelog types # List available time types
tools azure-devops timelog list -w # List time logs for work item (--day , --from/--to, --user @me)
@@ -332,6 +333,23 @@ When user says "analyze workitem/task X" or "analyze tasks from query Y":
| "Get task 12345 with screenshots" | `tools azure-devops workitem 12345 --task-folders --images` |
| "Analyze bug with images" | Fetch with `--images` → Read images → Explore agent with visual context |
+### Update fields
+
+`workitem-update` (alias `update`) reads one work item, including `multilineFieldsFormat`, and can update fields in a single JSON Patch. Without `--confirm` it prints the patch and writes nothing. Field names are the caller's; the command hard-codes none.
+
+```bash
+tools azure-devops workitem-update 12345 --json
+tools azure-devops workitem-update 12345 \
+ --field System.Description=@description.html \
+ --markdown System.Description \
+ --image-root ./drafts \
+ --reuse uploaded-images.json \
+ --rev 6 \
+ --confirm --json
+```
+
+`--field` repeats. A value that starts with `@` is a file path. `--markdown` repeats and switches that multiline field to Markdown when it is not already; the server cannot switch it back. `--reuse` is a JSON object `{ "": { "sha256": "", "url": "" } }`. A local `` whose file still has that sha256 keeps the URL and is not uploaded again. The patch always starts with `test /rev` (the live rev, or `--rev` when it still matches). After `--confirm`, the read-back treats these server rewrites as the same text: an emoji stored as `🔗`, a quoted integer attribute stored without quotes (`width="375"` as `width=375`; `width="48%"` keeps the quotes), and an image `src` replaced by its attachment URL. `--json` prints the result. A differing read-back exits 1.
+
## Creating Work Items
The `workitem-create` command (alias `create`) supports multiple modes for creating new work items.
diff --git a/plugins/genesis-tools/skills/browser-router/SKILL.md b/plugins/genesis-tools/skills/browser-router/SKILL.md
index b600720024..3fe893a658 100644
--- a/plugins/genesis-tools/skills/browser-router/SKILL.md
+++ b/plugins/genesis-tools/skills/browser-router/SKILL.md
@@ -58,7 +58,7 @@ A link carries only the session id, the number and one letter. Anything else doe
## Run Claude in a new cmux surface
-`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once.
+`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account; `--account auto` runs `tools claude run auto`, which picks the account with the most headroom when the surface starts. A handoff's run link is minted with `account=auto`. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once.
```bash
tools browser-router route 'http://127.0.0.1:8787/add/:id' --run --arg add --arg '{id}' --arg --qty --arg '{qty}' --notify 'Added' --approval allow
diff --git a/src/ai/commands/usage/poll-daemon.test.ts b/src/ai/commands/usage/poll-daemon.test.ts
index 53afe5e562..949a0a292a 100644
--- a/src/ai/commands/usage/poll-daemon.test.ts
+++ b/src/ai/commands/usage/poll-daemon.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/usage-poll/types";
-import { anthropicRows, notifiableWindows } from "./poll-daemon";
+import { anthropicRows, notifiableWindows, rowsOfAnswer, sessionRowsArgv } from "./poll-daemon";
/**
* The daemon fans one poll round out to three consumers with different rules: threshold
@@ -86,3 +86,41 @@ describe("anthropicRows", () => {
expect(rows.filter((r) => !r.stale)).toEqual([]);
});
});
+
+describe("sessionRowsArgv", () => {
+ test("the child and the hub server get the same cache-safe command, providers included", () => {
+ expect(sessionRowsArgv({ hours: 24, minRows: 10 })).toEqual([
+ "ai",
+ "usage",
+ "sessions",
+ "--json",
+ "--fresh",
+ "--no-cache-write",
+ "--journal-only",
+ "--hours",
+ "24",
+ "--min",
+ "10",
+ ]);
+ expect(sessionRowsArgv({ providers: ["claude", "codex"], limit: 5 })).toEqual([
+ "ai",
+ "usage",
+ "sessions",
+ "--json",
+ "--fresh",
+ "--no-cache-write",
+ "--journal-only",
+ "--provider",
+ "claude",
+ "codex",
+ "--limit",
+ "5",
+ ]);
+ });
+
+ test("rowsOfAnswer reads the rows and refuses an answer without them", () => {
+ expect(rowsOfAnswer(`{"rows":[{"sessionId":"a"}],"fetchedAt":1}`)?.map((row) => row.sessionId)).toEqual(["a"]);
+ expect(rowsOfAnswer(`{"error":"busy"}`)).toBeNull();
+ expect(() => rowsOfAnswer("not json")).toThrow();
+ });
+});
diff --git a/src/ai/commands/usage/poll-daemon.ts b/src/ai/commands/usage/poll-daemon.ts
index eb171c7d49..f3c4ce4e3f 100644
--- a/src/ai/commands/usage/poll-daemon.ts
+++ b/src/ai/commands/usage/poll-daemon.ts
@@ -10,7 +10,7 @@ import { NotificationManager } from "@genesiscz/utils/ai/usage-poll/notification
import { pollAccounts } from "@genesiscz/utils/ai/usage-poll/poll";
import { usagePollStorage } from "@genesiscz/utils/ai/usage-poll/storage";
import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/usage-poll/types";
-import { withTimeout } from "@genesiscz/utils/async";
+import { execTool } from "@genesiscz/utils/cli";
import { recordRunOnExit } from "@genesiscz/utils/cli/run-record";
import { toolCommand } from "@genesiscz/utils/cli/tool-command";
import { SafeJSON } from "@genesiscz/utils/json";
@@ -75,6 +75,34 @@ export function anthropicRows(snapshots: readonly AccountUsageSnapshot[]) {
const prof = profiler.scope("ai-usage");
+/** The `usage sessions` argv that recomputes `query`. `--journal-only`: these rows go into the cache file, which must
+ * never hold a live tmux tab. */
+export function sessionRowsArgv(query: AgentSessionRowsOptions): string[] {
+ const argv = ["ai", "usage", "sessions", "--json", "--fresh", "--no-cache-write", "--journal-only"];
+ if (query.providers && query.providers.length > 0) {
+ argv.push("--provider", ...query.providers);
+ }
+
+ for (const [flag, value] of [
+ ["--hours", query.hours],
+ ["--min", query.minRows],
+ ["--limit", query.limit],
+ ] as const) {
+ if (value !== undefined) {
+ argv.push(flag, String(value));
+ }
+ }
+
+ return argv;
+}
+
+/** The rows of a `usage sessions --json` answer, or null when it holds none. */
+export function rowsOfAnswer(stdout: string): AgentSessionRow[] | null {
+ const parsed: unknown = SafeJSON.parse(stdout, { strict: true });
+ const rows = isObject(parsed) ? parsed.rows : null;
+ return Array.isArray(rows) ? rows : null;
+}
+
/**
* Genesis.app asks `usage sessions --json --hours 24 --min 10` every 35 s, and a fresh answer costs about 1.1 s of
* CPU (1,130 ms median on 2026-10-12, `scripts/benchmarks/agent-sessions/session-listing.ts`): process start, every
@@ -86,12 +114,16 @@ const prof = profiler.scope("ai-usage");
* 60 s timeout SIGTERMed the tick 20 times that day, losing its "completed" line. A walk cut short leaves the
* previous file for the next tick. The budget counts from process start, because the runner's clock starts at
* spawn and on 2026-10-12 the first line of `main` reached it 5-38 s after spawn. Never throws.
+ *
+ * The walk runs in a child, never in this process: its SQLite reads are synchronous, so on a loaded disk they held
+ * the event loop past the budget timer, and the runner SIGTERMed the tick 9 times on 2026-10-21 (twice the SIGTERM
+ * handler could not run either, and it took a SIGKILL). A child is killed at the budget, whatever it is doing.
*/
async function refreshSessionRows(): Promise {
- const budgetMs = TICK_BUDGET_MS - (Date.now() - performance.timeOrigin);
+ const remainingMs = () => TICK_BUDGET_MS - (Date.now() - performance.timeOrigin);
+ const budgetMs = remainingMs();
try {
- const { listAgentSessionRows } = await import("@app/ai/lib/sessions/agent-session-rows");
const { refreshSessionRowsCache } = await import("@app/ai/lib/sessions/rows-cache");
const { callHubServer } = await import("@app/hub/server/client");
// The resident hub server answers with warm caches (directory walk, metadata, rows); this process
@@ -101,20 +133,11 @@ async function refreshSessionRows(): Promise {
return null;
}
- // `--journal-only`: these rows go into the cache file, which must never hold a live tmux tab.
- const argv = ["ai", "usage", "sessions", "--json", "--fresh", "--no-cache-write", "--journal-only"];
- for (const [flag, value] of [
- ["--hours", query.hours],
- ["--min", query.minRows],
- ["--limit", query.limit],
- ] as const) {
- if (value !== undefined) {
- argv.push(flag, String(value));
- }
- }
-
// Short: a stalled server must not eat the budget the local walk needs (a cold walk is ~2 s CPU).
- const answer = await callHubServer({ argv, timeoutMs: Math.max(1000, Math.min(5_000, budgetMs / 4)) });
+ const answer = await callHubServer({
+ argv: sessionRowsArgv(query),
+ timeoutMs: Math.max(1000, Math.min(5_000, budgetMs / 4)),
+ });
if (!answer) {
// This tick builds the rows itself. A server that is not running is started for the next tick;
// the hub window is not needed for that (238 of 644 refreshes on 2026-10-12 found no server).
@@ -128,24 +151,49 @@ async function refreshSessionRows(): Promise {
}
try {
- const parsed: unknown = SafeJSON.parse(answer.stdout, { strict: true });
- const rows = isObject(parsed) ? parsed.rows : null;
- return Array.isArray(rows) ? rows : null;
+ return rowsOfAnswer(answer.stdout);
} catch (err) {
logger.debug({ err }, "[ai-usage] hub server rows unreadable; computing locally");
return null;
}
};
- const outcome = await withTimeout(
- prof.measureAsync(
+ let cut = false;
+ const local = async (query: AgentSessionRowsOptions): Promise => {
+ const timeout = remainingMs();
+ const child = timeout > 0 ? await execTool(sessionRowsArgv(query), { timeout }) : null;
+ if (!child || child.timedOut) {
+ cut = true;
+ throw new Error(`session rows walk passed the tick's budget (${Math.round(budgetMs / 1000)} s left)`);
+ }
+
+ const rows = child.success ? rowsOfAnswer(child.stdout) : null;
+ if (!rows) {
+ throw new Error(`usage sessions exited ${child.exitCode} without rows: ${child.stderr.slice(-300)}`);
+ }
+
+ return rows;
+ };
+
+ try {
+ const outcome = await prof.measureAsync(
"tick.session-rows",
- () => refreshSessionRowsCache(listAgentSessionRows, { remote }),
+ () => refreshSessionRowsCache(local, { remote }),
cpuMeta()
- ),
- Math.max(0, budgetMs),
- new Error(`session rows walk passed the tick's budget (${Math.round(budgetMs / 1000)} s left)`)
- );
- logger.info(outcome, "[ai-usage] session rows cache");
+ );
+ logger.info(outcome, "[ai-usage] session rows cache");
+ } catch (err) {
+ if (!cut) {
+ throw err;
+ }
+
+ // The designed outcome on a loaded machine, not a fault: in all 7 cuts on 2026-10-21 every read was slow
+ // (the Claude projects walk 8 to 25 s for 3,600 files, the history catalog 30 to 40 s), while 1,317
+ // finished walks that day took under 1 s each.
+ logger.info(
+ { budgetMs: Math.round(budgetMs) },
+ "[ai-usage] session rows walk cut at the tick's budget; the previous rows stay"
+ );
+ }
} catch (err) {
logger.warn({ err }, "[ai-usage] session rows cache refresh failed");
}
diff --git a/src/azure-devops/README.md b/src/azure-devops/README.md
index 8c07ef2e64..99425718e9 100644
--- a/src/azure-devops/README.md
+++ b/src/azure-devops/README.md
@@ -400,6 +400,7 @@ tools azure-devops workitem-create --type Bug --title "Error in checkout" --seve
| `dashboard ` | Extract queries from a dashboard |
| `list` (alias `ls`) | List all cached work items |
| `workitem-create` (alias `create`) | Create new work items (interactive or from template) |
+| `workitem-update` (alias `update`) | Read a work item's field formats, or update fields in one JSON Patch (`--field`, `--markdown`, `--confirm`) |
| `iterations` | List the project's sprints (alias `sprints`) |
| `sprint` | List the work items of one sprint |
| `history show`, `history sync` | One item's history; bulk history sync for cached items (`--since`, `--batch`) |
@@ -529,6 +530,13 @@ Configuration is saved to `.claude/azure/config.json` in your project directory.
**Config Search**: The tool searches for `.claude/azure/config.json` starting from the current directory, then up to 3 parent levels. This allows running the tool from subdirectories.
+**Explicit config**: To run from a folder with no config above it, pass the global option `--config ` or set `AZURE_DEVOPS_CONFIG=`. The order is `--config`, then `AZURE_DEVOPS_CONFIG`, then the search. A path that is missing, is a folder, or cannot be read is an error; the tool never falls back to the search. `tasks/` and `wiki/` are then created next to the config file, and `configure` writes the config to that path.
+
+```bash
+tools azure-devops --config /path/to/project/.claude/azure/config.json workitem 12345
+AZURE_DEVOPS_CONFIG=/path/to/project/.claude/azure/config.json tools azure-devops workitem 12345
+```
+
## Output Formats
### AI Format (Default)
diff --git a/src/azure-devops/api.ts b/src/azure-devops/api.ts
index 01da7b6d03..800702f188 100644
--- a/src/azure-devops/api.ts
+++ b/src/azure-devops/api.ts
@@ -17,8 +17,11 @@ import type {
IterationClassificationNode,
QueryNode,
SavedQueryApi,
+ TeamCapacitiesResponse,
+ TeamDaysOffResponse,
TeamIteration,
TeamIterationsResponse,
+ TeamMemberCapacityApi,
TeamMembersResponse,
TeamsListResponse,
WikiListResponse,
@@ -49,10 +52,12 @@ import type {
WiqlResponse,
WorkItem,
WorkItemFull,
+ WorkItemLive,
WorkItemType,
WorkItemTypeDefinition,
WorkItemUpdate,
} from "@app/azure-devops/types";
+import { wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder";
import { concurrentMap } from "@genesiscz/utils/async";
import { SafeJSON } from "@genesiscz/utils/json";
import { logger } from "@genesiscz/utils/logger";
@@ -292,31 +297,46 @@ export class Api {
private async send(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
url: string,
- options: { body?: unknown; contentType?: string; description?: string } = {}
+ options: { body?: unknown; rawBody?: Uint8Array; contentType?: string; description?: string } = {}
): Promise {
- const { body, contentType = "application/json", description } = options;
+ const { body, rawBody, contentType = "application/json", description } = options;
const shortUrl = url.replace(this.config.org, "").slice(0, 80);
logger.debug(`[api] ${method} ${shortUrl}${description ? ` (${description})` : ""}`);
+
if (body !== undefined) {
const bodyStr = SafeJSON.stringify(body);
const truncated = bodyStr.length > 500 ? `${bodyStr.slice(0, 500)}... (${bodyStr.length} chars)` : bodyStr;
logger.debug(`[api] ${method} body: ${truncated}`);
+ } else if (rawBody !== undefined) {
+ logger.debug(`[api] ${method} body: ${rawBody.byteLength} bytes`);
}
+
const startTime = Date.now();
const token = await this.getAccessToken();
const headers: Record = {
Authorization: `Bearer ${token}`,
};
- if (body !== undefined) {
+
+ if (body !== undefined || rawBody !== undefined) {
headers["Content-Type"] = contentType;
}
+ let payload: BodyInit | undefined;
+
+ if (rawBody !== undefined) {
+ const copy = new ArrayBuffer(rawBody.byteLength);
+ new Uint8Array(copy).set(rawBody);
+ payload = new Blob([copy]);
+ } else if (body !== undefined) {
+ payload = SafeJSON.stringify(body);
+ }
+
const response = await fetch(url, {
method,
headers,
- body: body !== undefined ? SafeJSON.stringify(body) : undefined,
+ body: payload,
});
const elapsed = Date.now() - startTime;
@@ -1076,6 +1096,37 @@ export class Api {
};
}
+ /** One work item, including `multilineFieldsFormat`, which the batch read does not ask for. */
+ async getWorkItemLive(id: number): Promise {
+ const url = Api.witUrl(this.config, ["workitems", String(id)], { $expand: "relations" });
+ const item = await this.get(url, `work item #${id}`);
+
+ return {
+ id: item.id,
+ rev: item.rev,
+ fields: item.fields ?? {},
+ multilineFieldsFormat: item.multilineFieldsFormat ?? {},
+ relations: item.relations,
+ };
+ }
+
+ /** Upload bytes as a work-item attachment. The returned URL is what an `` stores. */
+ async uploadAttachment(fileName: string, bytes: Uint8Array): Promise {
+ const url = Api.witUrl(this.config, "attachments", { fileName });
+ const response = await this.send("POST", url, {
+ rawBody: bytes,
+ contentType: "application/octet-stream",
+ description: `attachment ${fileName}`,
+ });
+ const created = SafeJSON.parse(await response.text()) as { url?: string };
+
+ if (!created?.url) {
+ throw new Error(`attachment upload of ${fileName} returned no url`);
+ }
+
+ return created.url;
+ }
+
/**
* Get the definition of a work item type including fields, states, and transitions
*/
@@ -1178,8 +1229,16 @@ export class Api {
* Pass `team` to execute in a team context, which is required by macros such
* as `@CurrentIteration`.
*/
- async runWiql(wiql: string, options?: { top?: number; team?: string }): Promise {
- const queryParams = { $top: options?.top ? String(options.top) : undefined };
+ async runWiql(
+ wiql: string,
+ options?: { top?: number; team?: string; timePrecision?: boolean }
+ ): Promise {
+ // A clock in a date literal is rejected unless the request opts into time precision.
+ // Date-only queries keep the default, so a bare day still means that whole day.
+ const queryParams = {
+ $top: options?.top ? String(options.top) : undefined,
+ timePrecision: wiqlTimePrecisionParam(wiql, options?.timePrecision),
+ };
const url = options?.team
? Api.teamApiUrl(this.config, options.team, ["wit", "wiql"], queryParams)
: Api.witUrl(this.config, "wiql", queryParams);
@@ -1198,6 +1257,42 @@ export class Api {
return data.value ?? [];
}
+ /** Every team of the project, up to 500. */
+ async getTeams(): Promise {
+ const url = Api.orgUrl(this.config, ["projects", this.config.projectId, "teams"], { $top: "500" });
+ const data = await this.get(url, "teams");
+
+ return data.value ?? [];
+ }
+
+ /** Capacity of every member of `team` in one iteration, per activity, with the member's days off. */
+ async getTeamCapacities(team: string, iterationId: string): Promise {
+ const url = Api.teamApiUrl(this.config, team, [
+ "work",
+ "teamsettings",
+ "iterations",
+ iterationId,
+ "capacities",
+ ]);
+ const data = await this.get(url, `capacity of team "${team}"`);
+
+ return data.teamMembers ?? data.value ?? [];
+ }
+
+ /** The days off the whole team shares in one iteration. */
+ async getTeamDaysOff(team: string, iterationId: string): Promise> {
+ const url = Api.teamApiUrl(this.config, team, [
+ "work",
+ "teamsettings",
+ "iterations",
+ iterationId,
+ "teamdaysoff",
+ ]);
+ const data = await this.get(url, `team days off of "${team}"`);
+
+ return data.daysOff ?? [];
+ }
+
/**
* How far `classificationnodes` is asked to walk. Deep enough for
* `Project > Year > Release > Sprint` style hierarchies with headroom;
diff --git a/src/azure-devops/api.types.ts b/src/azure-devops/api.types.ts
index 6cdfed4e38..a1531c401d 100644
--- a/src/azure-devops/api.types.ts
+++ b/src/azure-devops/api.types.ts
@@ -119,6 +119,23 @@ export interface TeamMembersResponse {
value: Array<{ identity: IdentityRef }>;
}
+/** One team member as `work/teamsettings/iterations/{id}/capacities` returns it (7.0+ `teamMembers`, older `value`). */
+export interface TeamMemberCapacityApi {
+ teamMember: { displayName: string; uniqueName?: string; id?: string };
+ activities?: Array<{ name?: string | null; capacityPerDay?: number }>;
+ daysOff?: Array<{ start: string; end: string }>;
+}
+
+export interface TeamCapacitiesResponse {
+ teamMembers?: TeamMemberCapacityApi[];
+ value?: TeamMemberCapacityApi[];
+}
+
+/** Raw response from `work/teamsettings/iterations/{id}/teamdaysoff`. */
+export interface TeamDaysOffResponse {
+ daysOff?: Array<{ start: string; end: string }>;
+}
+
/** Raw response from projects list */
export interface ProjectsListResponse {
value: Array<{ id: string; name: string }>;
diff --git a/src/azure-devops/commands/config-option.ts b/src/azure-devops/commands/config-option.ts
new file mode 100644
index 0000000000..c55046bdde
--- /dev/null
+++ b/src/azure-devops/commands/config-option.ts
@@ -0,0 +1,15 @@
+import { setConfigFlag } from "@app/azure-devops/config";
+import { CONFIG_ENV_VAR, CONFIG_FLAG } from "@app/azure-devops/lib/config-path";
+import type { Command } from "commander";
+
+/** The global --config option. Its value is kept for the run, before the action of any command. */
+export function registerConfigOption(program: Command): void {
+ program
+ .option(
+ `${CONFIG_FLAG} `,
+ `Path of the Azure DevOps config.json (env: ${CONFIG_ENV_VAR}). Default: search for .claude/azure/config.json from the working directory.`
+ )
+ .hook("preAction", (command) => {
+ setConfigFlag(command.opts<{ config?: string }>().config);
+ });
+}
diff --git a/src/azure-devops/commands/configure.ts b/src/azure-devops/commands/configure.ts
index a7b8ae640e..4eed5816c6 100644
--- a/src/azure-devops/commands/configure.ts
+++ b/src/azure-devops/commands/configure.ts
@@ -5,10 +5,11 @@
* and project configuration from any Azure DevOps URL.
*/
+import { basename } from "node:path";
import { exitWithAuthGuide } from "@app/azure-devops/cli.utils";
import { azDevopsDefaults, buildAdoConfig, saveAdoConfig } from "@app/azure-devops/lib/ado-configure";
import { azNotOnPathMessage, isAzOnPath } from "@app/azure-devops/lib/az-cli.utils";
-import { getLocalConfigDir } from "@app/azure-devops/utils";
+import { getExplicitConfigPath, getLocalConfigDir } from "@app/azure-devops/utils";
import { toolCommand } from "@genesiscz/utils/cli/tool-command";
import { SafeJSON } from "@genesiscz/utils/json";
import { logger, out } from "@genesiscz/utils/logger";
@@ -62,7 +63,8 @@ async function handleConfigure(url: string): Promise {
// Report what was SAVED, not what the URL carried. A same-project reconfigure
// keeps the team and the timelog block, so printing newConfig said
// "Team: (none)" while the file on disk still had one (PR #333 review t7).
- const saved = saveAdoConfig(newConfig, configDir);
+ const explicitConfig = getExplicitConfigPath();
+ const saved = saveAdoConfig(newConfig, configDir, explicitConfig ? basename(explicitConfig) : undefined);
if (saved.config.team) {
const kept = newConfig.team ? "" : " (kept from the existing config)";
diff --git a/src/azure-devops/commands/history-activity.ts b/src/azure-devops/commands/history-activity.ts
index 57bbf37d86..c9df8313fc 100644
--- a/src/azure-devops/commands/history-activity.ts
+++ b/src/azure-devops/commands/history-activity.ts
@@ -18,9 +18,10 @@ import {
} from "@app/azure-devops/cache";
import { buildWorkItemHistory, userMatches } from "@app/azure-devops/history";
import {
+ formatWindowBound,
groupEventsByDay,
+ historyCoversChange,
isInvertedWindow,
- localDay,
parseDayBoundary,
realDayCount,
resolveUpdateDate,
@@ -226,8 +227,13 @@ async function scanCachedActivity(
// ============= Discovery =============
-/** Discover work items changed by user but not in cache, sync their history + comments */
-async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDate?: Date): Promise {
+/** Discover work items the user changed in the window and sync any history the cache does not already cover. */
+async function discoverAndSync(
+ api: Api,
+ userName: string,
+ fromDate?: Date,
+ toDate?: Date
+): Promise<{ syncedIds: number[]; changedCount: number }> {
// Find existing cached IDs
const cacheFiles = await storage.listCacheFiles(false);
const cachedIds = new Set();
@@ -243,10 +249,11 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa
const userValue = isMeMacro ? "@Me" : `'${escapeWiqlValue(userName)}'`;
let wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.ChangedBy] = ${userValue}`;
- // The bounds carry their time, for the reason `findMentions` names: a bare `YYYY-MM-DD` means
- // MIDNIGHT to WIQL, so `<= ''` dropped everything changed on the --to day. The day part
- // alone was also the wrong day, because these bounds are local moments and `toISOString()`
- // reports them in UTC.
+ // The bounds carry their time. A bare `YYYY-MM-DD` means midnight to WIQL, so `<= ''`
+ // dropped everything changed on the --to day, and the day part alone was also the wrong day:
+ // these bounds are local moments and `toISOString()` reports them in UTC. The clock is legal
+ // only with time precision, which `runWiql` turns on for a literal that contains one. Date
+ // precision rejects it: "You cannot supply a time with the date".
if (fromDate) {
wiql += ` AND [System.ChangedDate] >= '${fromDate.toISOString()}'`;
}
@@ -257,22 +264,31 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa
const response = await api.runWiql(wiql, { top: 500 });
const serverIds = response.workItems.map((wi) => wi.id);
+ const changedDates: Map> = serverIds.length > 0
+ ? await api.getWorkItemFields(serverIds, ["System.ChangedDate"])
+ : new Map();
- // Find items that need history sync (not cached OR no history section)
+ // A history section only means we synced this item once. WIQL already named it as changed
+ // inside the window, so skip it only when the cached updates reach its current ChangedDate.
const needSync: number[] = [];
for (const id of serverIds) {
+ const changed = changedDates.get(id)?.["System.ChangedDate"];
+ const changedDate = typeof changed === "string" ? changed : undefined;
+
if (!cachedIds.has(id)) {
needSync.push(id);
continue;
}
+
const cached = await loadWorkItemCache(id);
- if (!cached?.history) {
+
+ if (!historyCoversChange(cached?.history?.updates, changedDate)) {
needSync.push(id);
}
}
if (needSync.length === 0) {
- return [];
+ return { syncedIds: [], changedCount: serverIds.length };
}
// Sync history for discovered items
@@ -299,7 +315,7 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa
// comments" and `isCommentsFresh` suppressed the retry for seven days. `batchGetComments` logs
// each failure; leaving the section unwritten is what lets the next run try again.
- return needSync;
+ return { syncedIds: needSync, changedCount: serverIds.length };
}
// ============= Fetch Missing Comments =============
@@ -488,7 +504,10 @@ export async function handleHistoryActivity(options: ActivityOptions): Promise 0
- ? `Discovered and synced ${newIds.length} new work items`
- : "No new work items to discover"
+ discovered.syncedIds.length > 0
+ ? `Synced history for ${discovered.syncedIds.length} work items`
+ : discovered.changedCount > 0
+ ? `${discovered.changedCount} changed work items already cached`
+ : "No work items changed in this window"
);
}
diff --git a/src/azure-devops/commands/index.ts b/src/azure-devops/commands/index.ts
index 4288e8a805..53e0f60059 100644
--- a/src/azure-devops/commands/index.ts
+++ b/src/azure-devops/commands/index.ts
@@ -10,3 +10,4 @@ export { registerWikiCommand } from "./wiki";
export { registerWorkitemCommand } from "./workitem";
export { registerWorkitemCacheCommand } from "./workitem-cache";
export { registerWorkitemCreateCommand } from "./workitem-create";
+export { registerWorkItemUpdateCommand } from "./workitem-update";
diff --git a/src/azure-devops/commands/workitem-update.ts b/src/azure-devops/commands/workitem-update.ts
new file mode 100644
index 0000000000..1d9ef5a567
--- /dev/null
+++ b/src/azure-devops/commands/workitem-update.ts
@@ -0,0 +1,178 @@
+import { existsSync, readFileSync } from "node:fs";
+import { isAbsolute, join } from "node:path";
+
+import { Api } from "@app/azure-devops/api";
+import { formatJSON } from "@app/azure-devops/cache";
+import { handleCliError } from "@app/azure-devops/cli.utils";
+import { resolveWorkItemId } from "@app/azure-devops/lib/comments";
+import {
+ executeWorkItemUpdate,
+ localImageSources,
+ parseFieldSpec,
+ parseReuseFile,
+ sha256Hex,
+ type WorkItemUpdateImage,
+ type WorkItemUpdateResult,
+} from "@app/azure-devops/lib/work-item-update";
+import { requireConfig } from "@app/azure-devops/utils";
+import { out } from "@genesiscz/utils/logger";
+import type { Command } from "commander";
+
+function collect(value: string, previous: string[]): string[] {
+ return [...previous, value];
+}
+
+function resolveImages(values: string[], imageRoot: string): WorkItemUpdateImage[] {
+ const sources = [...new Set(values.flatMap((value) => localImageSources(value)))];
+ const images: WorkItemUpdateImage[] = [];
+
+ for (const src of sources) {
+ const relative = decodeURI(src);
+ const path = isAbsolute(relative) ? relative : join(imageRoot, relative);
+
+ if (!existsSync(path)) {
+ throw new Error(`local image not found: ${path}`);
+ }
+
+ const bytes = new Uint8Array(readFileSync(path));
+
+ images.push({ src, name: src.slice(src.lastIndexOf("/") + 1), sha256: sha256Hex(bytes), bytes });
+ }
+
+ return images;
+}
+
+function printResult(result: WorkItemUpdateResult, asJson: boolean): void {
+ if (!result.ok) {
+ if (asJson) {
+ out.println(formatJSON(result));
+ } else {
+ out.error(result.error);
+
+ for (const mismatch of result.mismatches ?? []) {
+ out.error(`${mismatch.field}: sent and stored differ`);
+ }
+ }
+
+ process.exitCode = 1;
+ return;
+ }
+
+ if (asJson) {
+ out.println(formatJSON(result));
+ return;
+ }
+
+ if (result.dryRun) {
+ out.println(`dry run, rev ${result.plan.rev}, nothing sent`);
+ out.println(formatJSON(result.plan.operations));
+
+ for (const upload of result.plan.uploads) {
+ out.println(`would upload ${upload.src}`);
+ }
+
+ return;
+ }
+
+ out.println(`updated work item to rev ${result.rev}`);
+}
+
+export function registerWorkItemUpdateCommand(program: Command): void {
+ program
+ .command("workitem-update")
+ .alias("update")
+ .description(
+ "Read a work item, including multilineFieldsFormat, and update fields in one JSON Patch. Without --confirm, print the patch and send nothing."
+ )
+ .argument("", "Work item id or URL")
+ .option(
+ "--field ",
+ "Set this field. Repeat for each field. A value that starts with @ is a file path (Name=@./text.html).",
+ collect,
+ []
+ )
+ .option(
+ "--markdown ",
+ "Switch this multiline field to Markdown when it is not already. Repeat for each field. The name is the caller's; Markdown cannot be switched back.",
+ collect,
+ []
+ )
+ .option(
+ "--rev ",
+ "Refuse the update unless the work item is still at this rev. The patch tests /rev either way."
+ )
+ .option("--image-root ", "Directory that relative paths are resolved against.", process.cwd())
+ .option(
+ "--reuse ",
+ 'JSON { "": { "sha256": "", "url": "" } }. A local image with the same sha256 is not uploaded again.'
+ )
+ .option("--confirm", "Send the patch. Without it, the command only prints what it would send.")
+ .option("--json", "Print the live work item, the dry-run plan, or the verify result as JSON.")
+ .action(
+ async (
+ workitem: string,
+ options: {
+ field: string[];
+ markdown: string[];
+ rev?: string;
+ imageRoot: string;
+ reuse?: string;
+ confirm?: boolean;
+ json?: boolean;
+ }
+ ) => {
+ try {
+ const config = requireConfig();
+ const api = new Api(config);
+ const id = resolveWorkItemId(workitem, config.org);
+ const asJson = options.json === true;
+
+ if (options.field.length === 0 && options.markdown.length === 0) {
+ const item = await api.getWorkItemLive(id);
+ out.println(asJson ? formatJSON(item) : `work item ${item.id} rev ${item.rev}`);
+
+ if (!asJson) {
+ out.println(formatJSON(item.multilineFieldsFormat));
+ }
+
+ return;
+ }
+
+ const rev = options.rev === undefined ? undefined : Number(options.rev);
+
+ if (rev !== undefined && !Number.isInteger(rev)) {
+ throw new Error(`--rev must be an integer, got '${options.rev}'`);
+ }
+
+ const fields = options.field.map((spec) =>
+ parseFieldSpec(spec, (path) => readFileSync(path, "utf8"))
+ );
+ const reuse = options.reuse ? parseReuseFile(readFileSync(options.reuse, "utf8")) : new Map();
+ const images = resolveImages(
+ fields.map((field) => field.value),
+ options.imageRoot
+ );
+ const result = await executeWorkItemUpdate(
+ {
+ read: (itemId) => api.getWorkItemLive(itemId),
+ upload: (fileName, bytes) => api.uploadAttachment(fileName, bytes),
+ update: (itemId, operations) =>
+ api.updateWorkItem(itemId, operations).then(() => undefined),
+ },
+ {
+ id,
+ rev,
+ fields,
+ markdownFields: options.markdown,
+ images,
+ reuse,
+ confirm: options.confirm === true,
+ }
+ );
+ printResult(result, asJson);
+ } catch (error) {
+ handleCliError(error);
+ }
+ }
+ );
+}
diff --git a/src/azure-devops/config.ts b/src/azure-devops/config.ts
index e75ec7a0a7..1352980526 100644
--- a/src/azure-devops/config.ts
+++ b/src/azure-devops/config.ts
@@ -2,8 +2,17 @@
* Azure DevOps CLI - Configuration loading and management
*/
-import { existsSync, readFileSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
+import { readFileSync } from "node:fs";
+import {
+ azureDataDir,
+ CONFIG_ENV_VAR,
+ CONFIG_FLAG,
+ ConfigPathError,
+ type ConfigPathInput,
+ configPathSourceLabel,
+ explicitConfigPath,
+ resolveConfigPath,
+} from "@app/azure-devops/lib/config-path";
import type { AzureConfig, AzureConfigWithTimeLog, TimeLogUser } from "@app/azure-devops/types";
import { toolCommand } from "@genesiscz/utils/cli/tool-command";
import { env } from "@genesiscz/utils/env";
@@ -15,37 +24,41 @@ export function getRelativeTime(date: Date): string {
return formatRelativeTime(date, { rounding: "round" });
}
-/**
- * Search for config file starting from cwd, up to 3 parent levels
- */
-export function findConfigPath(): string | null {
- const configName = ".claude/azure/config.json";
- let currentDir = resolve(env.paths.getClarityProjectCwd());
+let configFlag: string | undefined;
- for (let i = 0; i < 4; i++) {
- // current + 3 levels up
- const configPath = join(currentDir, configName);
-
- if (existsSync(configPath)) {
- return configPath;
- }
+/** Remember the value of the global --config option for the rest of the run. */
+export function setConfigFlag(value: string | undefined): void {
+ configFlag = value;
+}
- const parentDir = dirname(currentDir);
+function activeConfigInput(): ConfigPathInput {
+ return {
+ flag: configFlag,
+ envValue: env.paths.getAzureDevopsConfig(),
+ searchFrom: env.paths.getClarityProjectCwd(),
+ cwd: process.cwd(),
+ };
+}
- if (parentDir === currentDir) {
- break; // reached root
- }
- currentDir = parentDir;
- }
+/**
+ * The config file: --config, then AZURE_DEVOPS_CONFIG, then a search from cwd up to 3 parent levels.
+ * Throws a ConfigPathError when an explicit path is not a readable file.
+ */
+export function findConfigPath(): string | null {
+ return resolveConfigPath(activeConfigInput())?.path ?? null;
+}
- return null;
+/** The path --config or AZURE_DEVOPS_CONFIG names, whether or not the file exists yet. */
+export function getExplicitConfigPath(): string | null {
+ return explicitConfigPath(activeConfigInput())?.path ?? null;
}
/**
- * Get the config directory for the current project (in cwd)
+ * Get the config directory: the folder of an explicit config, otherwise `.claude/azure` in cwd.
+ * The tasks and wiki folders live in it.
*/
export function getLocalConfigDir(): string {
- return join(process.cwd(), ".claude/azure");
+ return azureDataDir(activeConfigInput());
}
/**
@@ -72,16 +85,37 @@ export function extractOrgName(orgUrl: string): string | null {
* Load config from file or return null if not found.
*/
export function loadConfig(): AzureConfig | null {
- const configPath = findConfigPath();
+ const found = resolveConfigPath(activeConfigInput());
- if (!configPath) {
+ if (!found) {
return null;
}
try {
- return SafeJSON.parse(readFileSync(configPath, "utf-8"));
- } catch {
- return null;
+ return SafeJSON.parse(readFileSync(found.path, "utf-8"));
+ } catch (error) {
+ if (found.source === "search") {
+ return null;
+ }
+
+ const reason = error instanceof Error ? error.message : String(error);
+
+ throw new ConfigPathError(
+ `Azure DevOps config cannot be parsed: ${found.path} (from ${configPathSourceLabel(found.source)}): ${reason}`
+ );
+ }
+}
+
+function loadConfigOrExit(): AzureConfig | null {
+ try {
+ return loadConfig();
+ } catch (error) {
+ if (error instanceof ConfigPathError) {
+ out.error(`\n❌ ${error.message}\n`);
+ process.exit(1);
+ }
+
+ throw error;
}
}
@@ -89,7 +123,7 @@ export function loadConfig(): AzureConfig | null {
* Require config or exit with helpful error
*/
export function requireConfig(): AzureConfig {
- const config = loadConfig();
+ const config = loadConfigOrExit();
if (!config) {
out.error(`
@@ -101,6 +135,7 @@ Run configure with any Azure DevOps URL from your project:
${toolCommand("azure-devops configure")} "https://myorg.visualstudio.com/MyProject/_queries/query/..."
This will create .claude/azure/config.json in the current directory.
+To use a config file elsewhere, pass ${CONFIG_FLAG} or set ${CONFIG_ENV_VAR}.
`);
process.exit(1);
}
@@ -112,7 +147,7 @@ This will create .claude/azure/config.json in the current directory.
* Load config with TimeLog settings or exit with helpful error
*/
export function requireTimeLogConfig(): AzureConfigWithTimeLog {
- const config = loadConfig() as AzureConfigWithTimeLog | null;
+ const config = loadConfigOrExit() as AzureConfigWithTimeLog | null;
if (!config) {
out.error(`
diff --git a/src/azure-devops/index.ts b/src/azure-devops/index.ts
index 000e5505f3..4e54f1afff 100644
--- a/src/azure-devops/index.ts
+++ b/src/azure-devops/index.ts
@@ -12,6 +12,7 @@
* tools azure-devops dashboard [options]
* tools azure-devops list
* tools azure-devops workitem-create [options]
+ * tools azure-devops workitem-update|update [--field Name=value] [--markdown Field] [--confirm]
* tools azure-devops timelog [options]
* tools azure-devops history [options]
* tools azure-devops iterations [options]
@@ -39,6 +40,7 @@ handleReadmeFlag(import.meta.url);
// Import command registration functions
import { registerAncestorsCommand } from "@app/azure-devops/commands/ancestors";
import { registerCommentCommand } from "@app/azure-devops/commands/comment";
+import { registerConfigOption } from "@app/azure-devops/commands/config-option";
import { registerConfigureCommand } from "@app/azure-devops/commands/configure";
import { registerDashboardCommand } from "@app/azure-devops/commands/dashboard";
import { registerHistoryCommand } from "@app/azure-devops/commands/history";
@@ -50,6 +52,7 @@ import { registerWikiCommand } from "@app/azure-devops/commands/wiki";
import { handleWorkItem, registerWorkitemCommand } from "@app/azure-devops/commands/workitem";
import { registerWorkitemCacheCommand } from "@app/azure-devops/commands/workitem-cache";
import { registerWorkitemCreateCommand } from "@app/azure-devops/commands/workitem-create";
+import { registerWorkItemUpdateCommand } from "@app/azure-devops/commands/workitem-update";
import { runTool } from "@genesiscz/utils/cli";
// Wire up cross-command dependencies
@@ -71,6 +74,8 @@ program
process.exit(0);
});
+registerConfigOption(program);
+
// Register all commands
registerConfigureCommand(program);
registerQueryCommand(program);
@@ -78,6 +83,7 @@ registerWorkitemCommand(program);
registerAncestorsCommand(program);
registerTreeCommand(program);
registerWorkitemCreateCommand(program);
+registerWorkItemUpdateCommand(program);
registerWorkitemCacheCommand(program);
registerDashboardCommand(program);
registerTimelogCommand(program);
@@ -102,6 +108,7 @@ Commands:
dashboard Fetch dashboard and list its queries
list List cached work items (alias: ls)
workitem-create Create a new work item, interactive or from template (alias: create)
+ workitem-update Read a work item's field formats, or update fields in one JSON Patch (alias: update)
timelog Manage time log entries (add, list, delete, types, import, configure,
prepare-import, export-month)
history Work item history commands (show, search, sync, activity, mentions)
@@ -111,6 +118,7 @@ Commands:
comment Work item comments: list, add, edit, delete
Global Options:
+ --config Config file to use (or set AZURE_DEVOPS_CONFIG); wins over the search from the working directory
--team Optional team; narrows team-scoped lists (overrides config.team)
-v, --verbose Enable verbose debug logging
--readme Print this tool's README and exit
@@ -160,6 +168,15 @@ Workitem-Create Options:
--assignee Assignee email
--parent Parent work item ID
+Workitem-Update Options:
+ --field Set a field; repeat per field (Name=@file reads the value from a file)
+ --markdown Switch a multiline field to Markdown; repeat per field
+ --rev Refuse the update unless the work item is still at this rev
+ --image-root Directory that relative paths resolve against
+ --reuse JSON of already uploaded images; an image with the same sha256 is not uploaded again
+ --confirm Send the patch (without it, print the patch and send nothing)
+ --json Print the live work item, the dry-run plan, or the verify result as JSON
+
Timelog Subcommands:
timelog add Add a time log entry (--date , default today)
timelog list List time logs (--workitem, --day, --from/--to, --user)
@@ -258,10 +275,10 @@ History Commands:
${toolCommand("azure-devops history mentions")} Comments that named a user in a date window (--user, --from, --to)
Storage:
- Config: .claude/azure/config.json (per-project, searched up to 3 levels)
+ Config: --config , then $AZURE_DEVOPS_CONFIG, then .claude/azure/config.json (per-project, searched up to 3 levels)
Cache: ~/.genesis-tools/azure-devops/cache/ (global; queries 180 days, work items 365 days
with a 5-minute freshness window, history and comments 7 days)
- Tasks: .claude/azure/tasks/ (per-project, in cwd)
+ Tasks: .claude/azure/tasks/ (per-project, in cwd; next to the config file when --config or AZURE_DEVOPS_CONFIG is set)
Documentation: https://learn.microsoft.com/en-us/azure/devops/cli/?view=azure-devops
`);
diff --git a/src/azure-devops/lib/activity-days.test.ts b/src/azure-devops/lib/activity-days.test.ts
index a1453b45f5..3815ab1e2c 100644
--- a/src/azure-devops/lib/activity-days.test.ts
+++ b/src/azure-devops/lib/activity-days.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import {
+ formatWindowBound,
groupEventsByDay,
+ historyCoversChange,
isInvertedWindow,
isSentinelDate,
localDay,
@@ -25,6 +27,38 @@ function update(overrides: Partial): WorkItemUpdate {
};
}
+describe("historyCoversChange", () => {
+ test("an empty cache does not cover a change", () => {
+ expect(historyCoversChange(undefined, "2026-10-15T02:12:15.000Z")).toBe(false);
+ expect(historyCoversChange([], "2026-10-15T02:12:15.000Z")).toBe(false);
+ });
+
+ test("a missing ChangedDate is not proof the cache is current", () => {
+ expect(historyCoversChange([update({})], undefined)).toBe(false);
+ });
+
+ test("covers the change when the newest update is that ChangedDate", () => {
+ const updates = [
+ update({
+ revisedDate: "9999-01-01T00:00:00Z",
+ fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-15T02:12:15.000Z" } },
+ }),
+ ];
+
+ expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(true);
+ });
+
+ test("does not cover a later edit than the newest cached update", () => {
+ const updates = [
+ update({
+ fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-14T08:00:00.000Z" } },
+ }),
+ ];
+
+ expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(false);
+ });
+});
+
describe("resolveUpdateDate", () => {
test("uses revisedDate only when the update carries no changed date", () => {
expect(resolveUpdateDate(update({}))).toBe("2026-03-04T09:10:11Z");
@@ -265,6 +299,23 @@ describe("localDay", () => {
expect(localDay(parseDayBoundary("2026-01-02"))).toBe("2026-01-02");
});
+ test("a clock stays on the status line, in local time", () => {
+ withTz("Europe/Prague", () => {
+ expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-15 04:12");
+ });
+
+ withTz("America/Chicago", () => {
+ expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-14 21:12");
+ });
+ });
+
+ test("midnight and end of day stay a calendar day", () => {
+ withTz("Europe/Prague", () => {
+ expect(formatWindowBound(parseDayBoundary("2026-10-15"))).toBe("2026-10-15");
+ expect(formatWindowBound(parseDayBoundary("2026-10-15", { endOfDay: true }))).toBe("2026-10-15");
+ });
+ });
+
test("covers a day either side of a UTC boundary, which is where the old form broke", () => {
withTz("Europe/Prague", () => {
expect(localDay(parseDayBoundary("2026-12-31"))).toBe("2026-12-31");
diff --git a/src/azure-devops/lib/activity-days.ts b/src/azure-devops/lib/activity-days.ts
index 4e53e718b3..7ab5e21d03 100644
--- a/src/azure-devops/lib/activity-days.ts
+++ b/src/azure-devops/lib/activity-days.ts
@@ -38,6 +38,31 @@ export function resolveUpdateDate(update: WorkItemUpdate): string {
return "";
}
+/**
+ * Whether these updates already contain the work item's current ChangedDate.
+ * A cached history section is not proof of that: discovery used to skip any item that had one,
+ * so a later edit in the requested window never reached the scan.
+ */
+export function historyCoversChange(updates: WorkItemUpdate[] | undefined, changedDate: string | undefined): boolean {
+ const changedAt = changedDate ? new Date(changedDate).getTime() : Number.NaN;
+
+ if (!updates || updates.length === 0 || Number.isNaN(changedAt)) {
+ return false;
+ }
+
+ let latest = Number.NEGATIVE_INFINITY;
+
+ for (const update of updates) {
+ const at = new Date(resolveUpdateDate(update)).getTime();
+
+ if (!Number.isNaN(at) && at > latest) {
+ latest = at;
+ }
+ }
+
+ return Number.isFinite(latest) && latest >= changedAt;
+}
+
/**
* One end of a `--from` / `--to` window, as a moment.
*
@@ -131,6 +156,31 @@ export function localDay(date: Date): string {
return `${date.getFullYear()}-${month}-${day}`;
}
+/**
+ * A window bound for the status line. Midnight and end-of-day stay a calendar day.
+ * A bound that carries a clock shows that clock in local time, so an hour ago
+ * does not read as the whole day.
+ */
+export function formatWindowBound(date: Date): string {
+ const day = localDay(date);
+ const startOfDay =
+ date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0;
+ const endOfDay =
+ date.getHours() === 23 &&
+ date.getMinutes() === 59 &&
+ date.getSeconds() === 59 &&
+ date.getMilliseconds() === 999;
+
+ if (startOfDay || endOfDay) {
+ return day;
+ }
+
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+
+ return `${day} ${hours}:${minutes}`;
+}
+
export interface DatedEvent {
date: string;
}
diff --git a/src/azure-devops/lib/ado-configure.ts b/src/azure-devops/lib/ado-configure.ts
index 0219d3fc73..8247063a4e 100644
--- a/src/azure-devops/lib/ado-configure.ts
+++ b/src/azure-devops/lib/ado-configure.ts
@@ -69,12 +69,12 @@ export interface SavedAdoConfig {
config: AzureConfigWithTimeLog;
}
-export function saveAdoConfig(config: AzureConfig, configDir: string): SavedAdoConfig {
+export function saveAdoConfig(config: AzureConfig, configDir: string, fileName = "config.json"): SavedAdoConfig {
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
- const configPath = join(configDir, "config.json");
+ const configPath = join(configDir, fileName);
const existing = readExistingConfig(configPath);
// Supplemental settings are only meaningful within one project. `team` names
diff --git a/src/azure-devops/lib/config-path.test.ts b/src/azure-devops/lib/config-path.test.ts
new file mode 100644
index 0000000000..24f01d2131
--- /dev/null
+++ b/src/azure-devops/lib/config-path.test.ts
@@ -0,0 +1,259 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
+import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { registerConfigOption } from "@app/azure-devops/commands/config-option";
+import {
+ findConfigPath,
+ getExplicitConfigPath,
+ getLocalConfigDir,
+ loadConfig,
+ requireConfig,
+ setConfigFlag,
+} from "@app/azure-devops/config";
+import {
+ azureDataDir,
+ ConfigPathError,
+ explicitConfigPath,
+ resolveConfigPath,
+ searchConfigPath,
+} from "@app/azure-devops/lib/config-path";
+import { getTasksDir } from "@app/azure-devops/task-files";
+import { env } from "@genesiscz/utils/env";
+import { SafeJSON } from "@genesiscz/utils/json";
+import { out } from "@genesiscz/utils/logger";
+import { Command } from "commander";
+
+const CONFIG_JSON = SafeJSON.stringify({ org: "https://dev.azure.com/contoso", project: "Widgets" });
+
+function tempDir(): string {
+ return mkdtempSync(join(tmpdir(), "ado-config-path-"));
+}
+
+function writeConfig(root: string, name = "config.json"): string {
+ const dir = join(root, ".claude/azure");
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, name);
+ writeFileSync(path, CONFIG_JSON);
+
+ return path;
+}
+
+describe("resolveConfigPath", () => {
+ test("--config wins over the environment variable and over a config the search would find", () => {
+ const root = tempDir();
+ const searched = writeConfig(join(root, "project"));
+ const flagged = writeConfig(join(root, "flagged"));
+ const fromEnv = writeConfig(join(root, "env"));
+
+ expect(
+ resolveConfigPath({ flag: flagged, envValue: fromEnv, searchFrom: dirname(dirname(searched)), cwd: root })
+ ).toEqual({ path: flagged, source: "flag" });
+ });
+
+ test("the environment variable wins over the search", () => {
+ const root = tempDir();
+ const searched = writeConfig(join(root, "project"));
+ const fromEnv = writeConfig(join(root, "env"));
+
+ expect(resolveConfigPath({ envValue: fromEnv, searchFrom: dirname(dirname(searched)), cwd: root })).toEqual({
+ path: fromEnv,
+ source: "env",
+ });
+ });
+
+ test("a blank flag or variable counts as not set, so the search runs", () => {
+ const root = tempDir();
+ const searched = writeConfig(root);
+
+ expect(resolveConfigPath({ flag: " ", envValue: "", searchFrom: root, cwd: root })).toEqual({
+ path: searched,
+ source: "search",
+ });
+ });
+
+ test("a relative explicit path resolves against the working directory", () => {
+ const root = tempDir();
+ const path = writeConfig(root, "other.json");
+
+ expect(resolveConfigPath({ flag: ".claude/azure/other.json", searchFrom: tempDir(), cwd: root })).toEqual({
+ path,
+ source: "flag",
+ });
+ });
+
+ test("a missing explicit path is an error that names its source, even when the search would find a config", () => {
+ const root = tempDir();
+ writeConfig(root);
+ const missing = join(root, "nowhere", "config.json");
+
+ expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(ConfigPathError);
+ expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(/--config/);
+ expect(() => resolveConfigPath({ envValue: missing, searchFrom: root, cwd: root })).toThrow(
+ /AZURE_DEVOPS_CONFIG/
+ );
+ expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(missing);
+ });
+
+ test("a folder is an error, not a config", () => {
+ const root = tempDir();
+
+ expect(() => resolveConfigPath({ flag: root, searchFrom: root, cwd: root })).toThrow(/not a file/);
+ });
+
+ test.skipIf(process.getuid?.() === 0)("an unreadable file is an error", () => {
+ const root = tempDir();
+ const path = writeConfig(root);
+ chmodSync(path, 0o000);
+
+ try {
+ expect(() => resolveConfigPath({ flag: path, searchFrom: root, cwd: root })).toThrow(/cannot be read/);
+ } finally {
+ chmodSync(path, 0o600);
+ }
+ });
+
+ test("the search still works when neither is given: this folder and three levels up, not four", () => {
+ const root = tempDir();
+ const config = writeConfig(root);
+ const level3 = join(root, "a", "b", "c");
+ const level4 = join(level3, "d");
+ mkdirSync(level4, { recursive: true });
+
+ expect(resolveConfigPath({ searchFrom: root, cwd: root })).toEqual({ path: config, source: "search" });
+ expect(searchConfigPath(level3)).toBe(config);
+ expect(searchConfigPath(level4)).toBeNull();
+ expect(resolveConfigPath({ searchFrom: tempDir(), cwd: root })).toBeNull();
+ });
+});
+
+describe("azureDataDir", () => {
+ test("is the folder of an explicit config, otherwise .claude/azure in the working directory", () => {
+ expect(azureDataDir({ flag: "/srv/ado/config.json", cwd: "/work" })).toBe("/srv/ado");
+ expect(azureDataDir({ envValue: "/srv/env/config.json", cwd: "/work" })).toBe("/srv/env");
+ expect(azureDataDir({ cwd: "/work" })).toBe(join("/work", ".claude/azure"));
+ expect(explicitConfigPath({ cwd: "/work" })).toBeNull();
+ });
+});
+
+describe("the active config", () => {
+ let savedConfig: string | undefined;
+ let savedCwd: string | undefined;
+
+ beforeEach(() => {
+ savedConfig = process.env.AZURE_DEVOPS_CONFIG;
+ savedCwd = process.env.CLARITY_PROJECT_CWD;
+ env.testing.unset("AZURE_DEVOPS_CONFIG");
+ setConfigFlag(undefined);
+ });
+
+ afterEach(() => {
+ setConfigFlag(undefined);
+
+ for (const [key, value] of [
+ ["AZURE_DEVOPS_CONFIG", savedConfig],
+ ["CLARITY_PROJECT_CWD", savedCwd],
+ ] as const) {
+ if (value === undefined) {
+ env.testing.unset(key);
+ } else {
+ env.testing.set(key, value);
+ }
+ }
+ });
+
+ test("the flag, then the variable, then the search choose the file", () => {
+ const root = tempDir();
+ const searched = writeConfig(join(root, "project"));
+ const fromEnv = writeConfig(join(root, "env"));
+ const flagged = writeConfig(join(root, "flagged"));
+ env.testing.set("CLARITY_PROJECT_CWD", join(root, "project"));
+
+ expect(findConfigPath()).toBe(searched);
+
+ env.testing.set("AZURE_DEVOPS_CONFIG", fromEnv);
+ expect(findConfigPath()).toBe(fromEnv);
+
+ setConfigFlag(flagged);
+ expect(findConfigPath()).toBe(flagged);
+ expect(loadConfig()?.project).toBe("Widgets");
+ });
+
+ test("tasks and the config folder follow an explicit config", () => {
+ const root = tempDir();
+ const flagged = writeConfig(join(root, "flagged"));
+
+ expect(getLocalConfigDir()).toBe(join(process.cwd(), ".claude/azure"));
+ expect(getExplicitConfigPath()).toBeNull();
+
+ setConfigFlag(flagged);
+
+ expect(getLocalConfigDir()).toBe(dirname(flagged));
+ expect(getTasksDir()).toBe(join(dirname(flagged), "tasks"));
+ expect(getTasksDir("hotfixes")).toBe(join(dirname(flagged), "tasks", "hotfixes"));
+ expect(getExplicitConfigPath()).toBe(flagged);
+ });
+
+ test("an explicit file that does not parse is an error, not a missing config", () => {
+ const root = tempDir();
+ const path = join(root, "broken.json");
+ writeFileSync(path, "{ not json");
+ setConfigFlag(path);
+
+ expect(() => loadConfig()).toThrow(/cannot be parsed/);
+ });
+
+ test("requireConfig stops with a clear message when the explicit path is missing", () => {
+ const root = tempDir();
+ env.testing.set("CLARITY_PROJECT_CWD", root);
+ writeConfig(root);
+ setConfigFlag(join(root, "gone.json"));
+ const printed = spyOn(out, "error").mockImplementation(() => undefined);
+ const exited = spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
+ throw new Error(`exit ${code}`);
+ });
+
+ try {
+ expect(() => requireConfig()).toThrow("exit 1");
+ expect(String(printed.mock.calls[0]?.[0])).toContain("gone.json");
+ } finally {
+ printed.mockRestore();
+ exited.mockRestore();
+ }
+ });
+});
+
+describe("registerConfigOption", () => {
+ afterEach(() => {
+ setConfigFlag(undefined);
+ });
+
+ test("keeps --config before and after the subcommand, and clears it on a run without it", async () => {
+ const program = new Command().exitOverride();
+ registerConfigOption(program);
+ program.command("sub").action(() => undefined);
+
+ await program.parseAsync(["--config", "/srv/one/config.json", "sub"], { from: "user" });
+ expect(getExplicitConfigPath()).toBe("/srv/one/config.json");
+
+ const again = new Command().exitOverride();
+ registerConfigOption(again);
+ again.command("sub").action(() => undefined);
+ await again.parseAsync(["sub", "--config", "/srv/two/config.json"], { from: "user" });
+ expect(getExplicitConfigPath()).toBe("/srv/two/config.json");
+
+ const plain = new Command().exitOverride();
+ registerConfigOption(plain);
+ plain.command("sub").action(() => undefined);
+ await plain.parseAsync(["sub"], { from: "user" });
+ expect(getExplicitConfigPath()).toBeNull();
+ });
+
+ test("--help describes the option and the variable", () => {
+ const program = new Command();
+ registerConfigOption(program);
+ const option = program.options.find((item) => item.long === "--config");
+
+ expect(option?.description).toContain("AZURE_DEVOPS_CONFIG");
+ });
+});
diff --git a/src/azure-devops/lib/config-path.ts b/src/azure-devops/lib/config-path.ts
new file mode 100644
index 0000000000..1472ab69f8
--- /dev/null
+++ b/src/azure-devops/lib/config-path.ts
@@ -0,0 +1,148 @@
+import { accessSync, constants, existsSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+
+export const AZURE_DIR = ".claude/azure";
+export const CONFIG_FLAG = "--config";
+export const CONFIG_ENV_VAR = "AZURE_DEVOPS_CONFIG";
+
+const CONFIG_FILE = `${AZURE_DIR}/config.json`;
+const SEARCH_LEVELS = 4;
+
+export type ConfigPathSource = "flag" | "env" | "search";
+
+export interface ConfigPathHit {
+ path: string;
+ source: ConfigPathSource;
+}
+
+export interface ConfigPathInput {
+ /** The value of --config. */
+ flag?: string;
+ /** The value of AZURE_DEVOPS_CONFIG. */
+ envValue?: string;
+ /** Where the upward search starts. */
+ searchFrom: string;
+ /** What a relative explicit path is resolved against. */
+ cwd: string;
+}
+
+const SOURCE_LABEL: Record = {
+ flag: CONFIG_FLAG,
+ env: CONFIG_ENV_VAR,
+ search: "the upward search",
+};
+
+export class ConfigPathError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigPathError";
+ }
+}
+
+export function configPathSourceLabel(source: ConfigPathSource): string {
+ return SOURCE_LABEL[source];
+}
+
+/** The path --config names, then the one AZURE_DEVOPS_CONFIG names. The file is not checked. */
+export function explicitConfigPath(input: Pick): ConfigPathHit | null {
+ const flag = input.flag?.trim();
+
+ if (flag) {
+ return { path: resolve(input.cwd, flag), source: "flag" };
+ }
+
+ const fromEnv = input.envValue?.trim();
+
+ if (fromEnv) {
+ return { path: resolve(input.cwd, fromEnv), source: "env" };
+ }
+
+ return null;
+}
+
+/** `.claude/azure/config.json` in startDir or in one of the three folders above it. */
+export function searchConfigPath(startDir: string): string | null {
+ let currentDir = resolve(startDir);
+
+ for (let level = 0; level < SEARCH_LEVELS; level++) {
+ const configPath = join(currentDir, CONFIG_FILE);
+
+ if (existsSync(configPath)) {
+ return configPath;
+ }
+
+ const parentDir = dirname(currentDir);
+
+ if (parentDir === currentDir) {
+ break;
+ }
+
+ currentDir = parentDir;
+ }
+
+ return null;
+}
+
+/**
+ * --config wins, then AZURE_DEVOPS_CONFIG, then the upward search. An explicit path that is not a readable
+ * file throws a ConfigPathError: it never falls back to the search.
+ */
+export function resolveConfigPath(input: ConfigPathInput): ConfigPathHit | null {
+ const explicit = explicitConfigPath(input);
+
+ if (explicit) {
+ assertReadableFile(explicit);
+
+ return explicit;
+ }
+
+ const found = searchConfigPath(input.searchFrom);
+
+ return found ? { path: found, source: "search" } : null;
+}
+
+/** The folder that holds tasks/ and wiki/: next to an explicit config, otherwise `.claude/azure` in cwd. */
+export function azureDataDir(input: Pick): string {
+ const explicit = explicitConfigPath(input);
+
+ return explicit ? dirname(explicit.path) : join(input.cwd, AZURE_DIR);
+}
+
+function errorCode(error: unknown): string | undefined {
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
+ return error.code;
+ }
+
+ return undefined;
+}
+
+function assertReadableFile(hit: ConfigPathHit): void {
+ const origin = `${hit.path} (from ${configPathSourceLabel(hit.source)})`;
+ let isFile: boolean;
+
+ try {
+ isFile = statSync(hit.path).isFile();
+ } catch (error) {
+ const code = errorCode(error);
+
+ if (code === "ENOENT" || code === "ENOTDIR") {
+ throw new ConfigPathError(
+ `Azure DevOps config not found: ${origin}. Pass the path of an existing config.json, or leave the option out to search from the working directory.`
+ );
+ }
+
+ throw new ConfigPathError(`Azure DevOps config cannot be read: ${origin}${code ? ` (${code})` : ""}.`);
+ }
+
+ if (!isFile) {
+ throw new ConfigPathError(`Azure DevOps config is not a file: ${origin}. Pass the config.json file itself.`);
+ }
+
+ try {
+ accessSync(hit.path, constants.R_OK);
+ } catch (error) {
+ const code = errorCode(error);
+
+ throw new ConfigPathError(`Azure DevOps config cannot be read: ${origin}${code ? ` (${code})` : ""}.`);
+ }
+}
diff --git a/src/azure-devops/lib/work-item-update.test.ts b/src/azure-devops/lib/work-item-update.test.ts
new file mode 100644
index 0000000000..85cd4a7a5c
--- /dev/null
+++ b/src/azure-devops/lib/work-item-update.test.ts
@@ -0,0 +1,348 @@
+import { describe, expect, test } from "bun:test";
+import { registerWorkItemUpdateCommand } from "@app/azure-devops/commands/workitem-update";
+import {
+ executeWorkItemUpdate,
+ imageReuseMap,
+ parseFieldSpec,
+ parseReuseFile,
+ sameStoredText,
+ sha256Hex,
+ type WorkItemUpdateClient,
+ type WorkItemUpdateImage,
+ workItemPatch,
+} from "@app/azure-devops/lib/work-item-update";
+import type { JsonPatchOperation, WorkItemLive } from "@app/azure-devops/types";
+import { Command } from "commander";
+
+const ATTACHMENT = "https://example.invalid/org/_apis/wit/attachments/abc?fileName=a.png";
+
+function live(overrides: Partial = {}): WorkItemLive {
+ return {
+ id: 100,
+ rev: 6,
+ fields: { "System.Description": "old" },
+ multilineFieldsFormat: { "System.Description": "html" },
+ ...overrides,
+ };
+}
+
+function image(src: string, bytes: Uint8Array, sha256 = sha256Hex(bytes)): WorkItemUpdateImage {
+ return { src, name: src.slice(src.lastIndexOf("/") + 1), sha256, bytes };
+}
+
+function client(
+ item: WorkItemLive,
+ uploadUrl = ATTACHMENT
+): WorkItemUpdateClient & { uploads: string[]; operations: JsonPatchOperation[][] } {
+ const state = { ...item, fields: { ...item.fields }, multilineFieldsFormat: { ...item.multilineFieldsFormat } };
+ const uploads: string[] = [];
+ const operations: JsonPatchOperation[][] = [];
+
+ return {
+ uploads,
+ operations,
+ async read() {
+ return {
+ id: state.id,
+ rev: state.rev,
+ fields: { ...state.fields },
+ multilineFieldsFormat: { ...state.multilineFieldsFormat },
+ };
+ },
+ async upload(fileName) {
+ uploads.push(fileName);
+ return uploadUrl;
+ },
+ async update(_id, ops) {
+ operations.push(ops);
+ state.rev += 1;
+
+ for (const op of ops) {
+ if (op.op === "add" && op.path.startsWith("/fields/") && typeof op.value === "string") {
+ state.fields[op.path.slice("/fields/".length)] = op.value;
+ }
+
+ if (op.op === "add" && op.path.startsWith("/multilineFieldsFormat/") && typeof op.value === "string") {
+ state.multilineFieldsFormat[op.path.slice("/multilineFieldsFormat/".length)] = op.value;
+ }
+ }
+ },
+ };
+}
+
+describe("image reuse", () => {
+ const bytes = new TextEncoder().encode("png-bytes");
+ const sha256 = sha256Hex(bytes);
+
+ test("reuses the attachment URL when the file sha256 is unchanged, and uploads when it changed", () => {
+ const uploaded = new Map([["a.png", { sha256, url: ATTACHMENT }]]);
+ const same = image("shots/a.png", bytes, sha256);
+ const changed = image("shots/a.png", bytes, "f".repeat(64));
+
+ expect(imageReuseMap([same.src], new Map([[same.src, same.sha256]]), uploaded).get(same.src)).toBe(ATTACHMENT);
+ expect(imageReuseMap([changed.src], new Map([[changed.src, changed.sha256]]), uploaded).size).toBe(0);
+ });
+
+ test("a confirmed update does not upload a file whose sha256 is already uploaded", async () => {
+ const shot = image("shots/a.png", bytes);
+ const ado = client(live());
+ const result = await executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [{ name: "System.Description", value: `` }],
+ markdownFields: [],
+ images: [shot],
+ reuse: new Map([[shot.name, { sha256: shot.sha256, url: ATTACHMENT }]]),
+ confirm: true,
+ });
+
+ expect(ado.uploads).toEqual([]);
+ expect(result.ok).toBe(true);
+ expect(
+ ado.operations[0]?.some(
+ (op) => op.path === "/fields/System.Description" && op.value === ``
+ )
+ ).toBe(true);
+ });
+});
+
+describe("the rev test and the format switch", () => {
+ test("the patch tests /rev and switches only the named fields that are not already Markdown", () => {
+ const operations = workItemPatch({
+ rev: 6,
+ fields: [
+ { name: "System.Description", value: "next" },
+ { name: "System.History", value: "note" },
+ ],
+ markdownFields: ["System.Description", "System.History"],
+ liveFormats: { "System.History": "markdown" },
+ });
+
+ expect(operations[0]).toEqual({ op: "test", path: "/rev", value: 6 });
+ expect(operations).toContainEqual({
+ op: "add",
+ path: "/multilineFieldsFormat/System.Description",
+ value: "Markdown",
+ });
+ expect(operations.some((op) => op.path === "/multilineFieldsFormat/System.History")).toBe(false);
+ expect(operations.filter((op) => op.path.startsWith("/fields/"))).toHaveLength(2);
+ });
+
+ test("a stale --rev sends nothing", async () => {
+ const ado = client(live());
+ const result = await executeWorkItemUpdate(ado, {
+ id: 100,
+ rev: 5,
+ fields: [{ name: "System.Description", value: "next" }],
+ markdownFields: ["System.Description"],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+
+ expect(result).toEqual({ ok: false, error: "work item 100 is at rev 6, not 5" });
+ expect(ado.operations).toEqual([]);
+ expect(ado.uploads).toEqual([]);
+ });
+
+ test("confirm sends the rev test and the format switch, then accepts the read-back", async () => {
+ const ado = client(live());
+ const result = await executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [{ name: "System.Description", value: "next" }],
+ markdownFields: ["System.Description"],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+
+ expect(result.ok).toBe(true);
+
+ if (result.ok && !result.dryRun) {
+ expect(result.rev).toBe(7);
+ }
+
+ expect(ado.operations[0]?.[0]).toEqual({ op: "test", path: "/rev", value: 6 });
+ expect(ado.operations[0]).toContainEqual({
+ op: "add",
+ path: "/multilineFieldsFormat/System.Description",
+ value: "Markdown",
+ });
+ });
+});
+
+describe("read-back rewrites", () => {
+ const same = (stored: string, sent: string, images?: ReadonlyMap) =>
+ sameStoredText({ stored, sent, format: "markdown", images });
+
+ test("an emoji stored as a character reference matches the emoji that was sent", () => {
+ expect(same("see 🔗 this", "see 🔗 this")).toBe(true);
+ expect(same("see 🔗 this", "see 🔗 this")).toBe(true);
+ });
+
+ test("a quoted integer attribute may lose its quotes, and a percent value keeps them", () => {
+ expect(same('', "")).toBe(true);
+ expect(same('', '')).toBe(true);
+ expect(same('', "")).toBe(false);
+ });
+
+ test("a local image src matches the attachment URL it was replaced with", () => {
+ const images = new Map([["shots/a.png", ATTACHMENT]]);
+
+ expect(same(``, '', images)).toBe(true);
+ expect(same(``, '')).toBe(false);
+ });
+});
+
+describe("command input", () => {
+ test("a @ value is read from the file, and --reuse is a name to sha256 and url", () => {
+ expect(parseFieldSpec("System.Description=@draft.html", (path) => `from ${path}`)).toEqual({
+ name: "System.Description",
+ value: "from draft.html",
+ });
+ expect(
+ parseReuseFile(`{ "a.png": { "sha256": "${"a".repeat(64)}", "url": "${ATTACHMENT}" } }`).get("a.png")
+ ).toEqual({
+ sha256: "a".repeat(64),
+ url: ATTACHMENT,
+ });
+ });
+
+ test("--help names every flag", () => {
+ const program = new Command();
+ registerWorkItemUpdateCommand(program);
+ const command = program.commands.find((item) => item.name() === "workitem-update");
+ const flags = command?.options.map((option) => option.long) ?? [];
+
+ expect(flags).toEqual(
+ expect.arrayContaining(["--field", "--markdown", "--rev", "--image-root", "--reuse", "--confirm", "--json"])
+ );
+ expect(command?.description().length).toBeGreaterThan(0);
+ expect(command?.aliases()).toEqual(["update"]);
+
+ for (const option of command?.options ?? []) {
+ expect(option.description.length, option.long).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("the read-back of typed fields", () => {
+ function typedClient(stored: Record): WorkItemUpdateClient {
+ let rev = 6;
+ return {
+ async read() {
+ return { id: 100, rev, fields: rev > 6 ? stored : {}, multilineFieldsFormat: {} };
+ },
+ async upload() {
+ return ATTACHMENT;
+ },
+ async update() {
+ rev += 1;
+ },
+ };
+ }
+
+ const update = (ado: WorkItemUpdateClient, name: string, value: string) =>
+ executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [{ name, value }],
+ markdownFields: [],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+
+ test("a number, a boolean and an identity the server stores in its own type match what was sent", async () => {
+ const identity = { displayName: "Alice Example", uniqueName: "alice@example.com" };
+ const ado = typedClient({
+ "Microsoft.VSTS.Scheduling.RemainingWork": 5,
+ "Custom.Blocked": false,
+ "System.AssignedTo": identity,
+ });
+
+ expect((await update(ado, "Microsoft.VSTS.Scheduling.RemainingWork", "5")).ok).toBe(true);
+ expect((await update(ado, "Custom.Blocked", "False")).ok).toBe(true);
+ expect((await update(ado, "System.AssignedTo", "alice@example.com")).ok).toBe(true);
+ expect((await update(ado, "System.AssignedTo", "Alice Example ")).ok).toBe(true);
+ });
+
+ test("a different stored number is still a mismatch, reported as text", async () => {
+ const result = await update(
+ typedClient({ "Microsoft.VSTS.Scheduling.RemainingWork": 4 }),
+ "Microsoft.VSTS.Scheduling.RemainingWork",
+ "5"
+ );
+
+ expect(result).toMatchObject({
+ ok: false,
+ mismatches: [{ field: "Microsoft.VSTS.Scheduling.RemainingWork", sent: "5", stored: "4" }],
+ });
+ });
+});
+
+describe("what the read-back may forgive", () => {
+ test("a plain field matches exactly: a semicolon or a space the server dropped is a mismatch", async () => {
+ const ado = client(live({ fields: { "System.Title": "old" }, multilineFieldsFormat: {} }));
+ ado.update = async () => {};
+ const read = ado.read;
+ ado.read = async (id) => ({ ...(await read(id)), rev: 7, fields: { "System.Title": "ab" } });
+ const result = await executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [{ name: "System.Title", value: "a;b" }],
+ markdownFields: [],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+
+ expect(result).toMatchObject({ ok: false, mismatches: [{ field: "System.Title", sent: "a;b", stored: "ab" }] });
+ expect(sameStoredText({ stored: "a b", sent: "a b", format: "plain" })).toBe(false);
+ });
+
+ test("Markdown keeps semicolons, paragraph breaks and code indentation", () => {
+ const md = (stored: string, sent: string) => sameStoredText({ stored, sent, format: "markdown" });
+
+ expect(md("ab", "a;b")).toBe(false);
+ expect(md("one two", "one\n\ntwo")).toBe(false);
+ expect(md("```\ncode\n```", "```\n code\n```")).toBe(false);
+ expect(md("one\r\n\r\ntwo \n", "one\n\ntwo")).toBe(true);
+ });
+
+ test("HTML forgives insignificant space and the semicolon the server adds to a style", () => {
+ const html = (stored: string, sent: string) => sameStoredText({ stored, sent, format: "html" });
+
+ expect(html('
\n a\n
', '
a
')).toBe(
+ true
+ );
+ expect(html("
a;b
", "
ab
")).toBe(false);
+ });
+
+ test("--markdown without --field still checks that the field became Markdown", async () => {
+ const ado = client(live({ fields: { "System.Description": "body", "System.Title": "t" } }));
+ ado.update = async () => {};
+ const read = ado.read;
+ ado.read = async (id) => ({ ...(await read(id)), rev: 7 });
+ const formatOnly = await executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [],
+ markdownFields: ["System.Description"],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+ const otherField = await executeWorkItemUpdate(ado, {
+ id: 100,
+ fields: [{ name: "System.Title", value: "t" }],
+ markdownFields: ["System.Description"],
+ images: [],
+ reuse: new Map(),
+ confirm: true,
+ });
+
+ for (const result of [formatOnly, otherField]) {
+ expect(result).toMatchObject({
+ ok: false,
+ mismatches: [{ field: "System.Description", sent: "Markdown", stored: "html" }],
+ });
+ }
+ });
+});
diff --git a/src/azure-devops/lib/work-item-update.ts b/src/azure-devops/lib/work-item-update.ts
new file mode 100644
index 0000000000..58c0b4c9ac
--- /dev/null
+++ b/src/azure-devops/lib/work-item-update.ts
@@ -0,0 +1,396 @@
+import { createHash } from "node:crypto";
+import { basename } from "node:path";
+
+import type { JsonPatchOperation, WorkItemLive } from "@app/azure-devops/types";
+import { SafeJSON } from "@genesiscz/utils/json";
+
+/** An image already stored on the work item. Keyed by the file's base name. */
+export interface UploadedImage {
+ sha256: string;
+ url: string;
+}
+
+export interface WorkItemUpdateImage {
+ src: string;
+ name: string;
+ sha256: string;
+ bytes: Uint8Array;
+}
+
+export interface WorkItemUpdateClient {
+ read(id: number): Promise;
+ upload(fileName: string, bytes: Uint8Array): Promise;
+ update(id: number, operations: JsonPatchOperation[]): Promise;
+}
+
+export interface WorkItemUpdateInput {
+ id: number;
+ /** When set, the update is refused unless the work item is still at this rev. */
+ rev?: number;
+ fields: { name: string; value: string }[];
+ /** Multiline fields the caller wants stored as Markdown. Names come from the caller. */
+ markdownFields: string[];
+ images: WorkItemUpdateImage[];
+ reuse: ReadonlyMap;
+ confirm: boolean;
+}
+
+export interface WorkItemUpdatePlan {
+ rev: number;
+ operations: JsonPatchOperation[];
+ uploads: { src: string; name: string; sha256: string }[];
+ reused: { src: string; url: string }[];
+}
+
+export type WorkItemUpdateResult =
+ | { ok: true; dryRun: true; plan: WorkItemUpdatePlan }
+ | { ok: true; dryRun: false; rev: number; images: Record; plan: WorkItemUpdatePlan }
+ | { ok: false; error: string; mismatches?: { field: string; sent: string; stored: string }[] };
+
+/** Local `` values. `http`, `https` and `data` sources are already remote. */
+export function localImageSources(html: string): string[] {
+ const sources = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map((match) => match[1] ?? "");
+
+ return [...new Set(sources.filter((src) => src !== "" && !/^(https?:|data:)/.test(src)))];
+}
+
+export function replaceImageSources(html: string, urls: ReadonlyMap): string {
+ let out = html;
+
+ for (const [src, url] of urls) {
+ out = out.replaceAll(`src="${src}"`, `src="${url}"`);
+ }
+
+ return out;
+}
+
+export function sha256Hex(bytes: Uint8Array): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+/** Sources whose file still matches a previous upload, mapped to that attachment URL. */
+export function imageReuseMap(
+ sources: readonly string[],
+ files: ReadonlyMap,
+ uploaded: ReadonlyMap
+): Map {
+ const urls = new Map();
+
+ for (const src of sources) {
+ const known = uploaded.get(basename(src));
+ const sha256 = files.get(src);
+
+ if (known && sha256 && known.sha256 === sha256 && known.url !== "") {
+ urls.set(src, known.url);
+ }
+ }
+
+ return urls;
+}
+
+/**
+ * One patch: refuse the write when the rev moved, switch the named multiline fields to Markdown,
+ * then set the fields. A field that is already Markdown is left alone.
+ */
+export function workItemPatch(input: {
+ rev: number;
+ fields: { name: string; value: string }[];
+ markdownFields: readonly string[];
+ liveFormats: Readonly>;
+}): JsonPatchOperation[] {
+ const operations: JsonPatchOperation[] = [{ op: "test", path: "/rev", value: input.rev }];
+
+ for (const field of input.markdownFields) {
+ if ((input.liveFormats[field] ?? "").toLowerCase() === "markdown") {
+ continue;
+ }
+
+ operations.push({ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" });
+ }
+
+ for (const field of input.fields) {
+ operations.push({ op: "add", path: `/fields/${field.name}`, value: field.value });
+ }
+
+ return operations;
+}
+
+/** How a read-back is compared: Markdown keeps its lines, HTML ignores insignificant space, plain text is exact. */
+export type StoredTextFormat = "markdown" | "html" | "plain";
+
+const HTML_TAG = /<[a-zA-Z!/][^>]*>/;
+
+/**
+ * True when the stored text is the text that was sent, up to the rewrites the server applies to rich text:
+ * an emoji becomes a numeric (or hex) character reference, a quoted integer attribute loses its quotes
+ * (`width="375"` stored as `width=375`; `width="48%"` keeps the quotes), a declaration inside a tag gains a
+ * closing semicolon, and a local image src is the attachment URL from `images`. Comments are ignored too.
+ * Markdown keeps its line breaks and indentation, which carry meaning; HTML ignores insignificant space.
+ * A plain field (a title, a tag list) must match exactly.
+ */
+export function sameStoredText(input: {
+ stored: string;
+ sent: string;
+ format: StoredTextFormat;
+ images?: ReadonlyMap;
+}): boolean {
+ if (input.format === "plain") {
+ return input.stored === input.sent;
+ }
+
+ const images = input.images ?? new Map();
+ return (
+ normalizeAdoText(replaceImageSources(input.stored, images), input.format) ===
+ normalizeAdoText(replaceImageSources(input.sent, images), input.format)
+ );
+}
+
+function normalizeAdoText(value: string, format: "markdown" | "html"): string {
+ const text = value
+ .replace(//g, "")
+ .replace(/(\d+);/g, (_match, code: string) => String.fromCodePoint(Number(code)))
+ .replace(/([0-9a-fA-F]+);/g, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/&/g, "&")
+ .replace(/(\s[\w:-]+)="(\d+)"/g, "$1=$2")
+ .replace(/<[^>]+>/g, (tag) => tag.replace(/;(?=\s*["'>])/g, ""));
+
+ if (format === "html") {
+ return text
+ .replace(/\s*(<[^>]+>)\s*/g, "$1")
+ .replace(/\s+/g, " ")
+ .trim();
+ }
+
+ return text
+ .replace(/\r\n?/g, "\n")
+ .replace(/[ \t]+$/gm, "")
+ .trim();
+}
+
+/** Markdown when the field is (or is being switched to) Markdown, HTML when the field or either value is HTML. */
+function storedTextFormat(input: {
+ name: string;
+ sent: string;
+ stored: unknown;
+ markdownFields: readonly string[];
+ formats: Readonly>;
+}): StoredTextFormat {
+ const format = (input.formats[input.name] ?? "").toLowerCase();
+
+ if (format === "markdown" || input.markdownFields.includes(input.name)) {
+ return "markdown";
+ }
+
+ const stored = typeof input.stored === "string" ? input.stored : "";
+ return format === "html" || HTML_TAG.test(input.sent) || HTML_TAG.test(stored) ? "html" : "plain";
+}
+
+export function parseFieldSpec(spec: string, readFile: (path: string) => string): { name: string; value: string } {
+ const eq = spec.indexOf("=");
+
+ if (eq <= 0) {
+ throw new Error(`--field expects Name=value or Name=@file, got '${spec}'`);
+ }
+
+ const name = spec.slice(0, eq);
+ const raw = spec.slice(eq + 1);
+
+ if (!raw.startsWith("@")) {
+ return { name, value: raw };
+ }
+
+ const path = raw.slice(1);
+
+ if (path === "") {
+ throw new Error(`--field ${name} has an empty @ path`);
+ }
+
+ return { name, value: readFile(path) };
+}
+
+export function parseReuseFile(text: string): Map {
+ const parsed: unknown = SafeJSON.parse(text);
+
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error("--reuse must be a JSON object of { name: { sha256, url } }");
+ }
+
+ const uploaded = new Map();
+
+ for (const [name, value] of Object.entries(parsed)) {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`--reuse entry ${name} must be an object`);
+ }
+
+ const sha256 = "sha256" in value ? value.sha256 : undefined;
+ const url = "url" in value ? value.url : undefined;
+
+ if (typeof sha256 !== "string" || typeof url !== "string" || sha256 === "" || url === "") {
+ throw new Error(`--reuse entry ${name} needs sha256 and url strings`);
+ }
+
+ uploaded.set(name, { sha256, url });
+ }
+
+ return uploaded;
+}
+
+/** A stored field value as text: a number or boolean as written, an identity as `Name `. */
+function fieldText(value: unknown): string {
+ if (typeof value === "string") {
+ return value;
+ }
+
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+
+ const identity = identityNames(value);
+ return identity.length > 0 ? identity.join(" ") : "";
+}
+
+/** The names an identity field answers to: its display name, unique name, and `Name `. */
+function identityNames(value: unknown): string[] {
+ if (value === null || typeof value !== "object") {
+ return [];
+ }
+
+ const display = "displayName" in value && typeof value.displayName === "string" ? value.displayName : null;
+ const unique = "uniqueName" in value && typeof value.uniqueName === "string" ? value.uniqueName : null;
+ return [display, unique, display && unique ? `${display} <${unique}>` : null].filter(
+ (name): name is string => name !== null
+ );
+}
+
+/** Whether the read-back holds what was sent, compared by the stored value's own type and a text field's format. */
+function storedMatches(stored: unknown, sent: string, format: StoredTextFormat): boolean {
+ if (typeof stored === "number") {
+ return sent.trim() !== "" && Number(sent) === stored;
+ }
+
+ if (typeof stored === "boolean") {
+ return sent.trim().toLowerCase() === String(stored);
+ }
+
+ const identity = identityNames(stored);
+ if (identity.length > 0) {
+ return identity.some((name) => name.toLowerCase() === sent.trim().toLowerCase());
+ }
+
+ return sameStoredText({ stored: fieldText(stored), sent, format });
+}
+
+/** Read, substitute images, and either describe the patch or send it and compare the read-back. */
+export async function executeWorkItemUpdate(
+ client: WorkItemUpdateClient,
+ input: WorkItemUpdateInput
+): Promise {
+ const live = await client.read(input.id);
+
+ if (input.rev !== undefined && input.rev !== live.rev) {
+ return { ok: false, error: `work item ${input.id} is at rev ${live.rev}, not ${input.rev}` };
+ }
+
+ const bySrc = new Map(input.images.map((image) => [image.src, image]));
+ const sources = [...new Set(input.fields.flatMap((field) => localImageSources(field.value)))];
+ const files = new Map(input.images.map((image) => [image.src, image.sha256]));
+ const reuse = imageReuseMap(sources, files, input.reuse);
+ const uploads: WorkItemUpdateImage[] = [];
+
+ for (const src of sources) {
+ if (reuse.has(src)) {
+ continue;
+ }
+
+ const image = bySrc.get(src);
+
+ if (!image) {
+ return { ok: false, error: `local image not provided: ${src}` };
+ }
+
+ uploads.push(image);
+ }
+
+ const urls = new Map(reuse);
+
+ if (input.confirm) {
+ for (const image of uploads) {
+ urls.set(image.src, await client.upload(image.name, image.bytes));
+ }
+ }
+
+ const fields = input.fields.map((field) => ({ name: field.name, value: replaceImageSources(field.value, urls) }));
+ const operations = workItemPatch({
+ rev: live.rev,
+ fields,
+ markdownFields: input.markdownFields,
+ liveFormats: live.multilineFieldsFormat,
+ });
+ const plan: WorkItemUpdatePlan = {
+ rev: live.rev,
+ operations,
+ uploads: uploads.map(({ src, name, sha256 }) => ({ src, name, sha256 })),
+ reused: [...reuse.entries()].map(([src, url]) => ({ src, url })),
+ };
+
+ if (!input.confirm) {
+ return { ok: true, dryRun: true, plan };
+ }
+
+ await client.update(input.id, operations);
+ const after = await client.read(input.id);
+ const mismatches: { field: string; sent: string; stored: string }[] = [];
+
+ for (const field of fields) {
+ const raw = after.fields[field.name];
+ const format = storedTextFormat({
+ name: field.name,
+ sent: field.value,
+ stored: raw,
+ markdownFields: input.markdownFields,
+ formats: after.multilineFieldsFormat,
+ });
+
+ if (!storedMatches(raw, field.value, format)) {
+ mismatches.push({ field: field.name, sent: field.value, stored: fieldText(raw) });
+ }
+ }
+
+ // On its own loop: `--markdown ` may switch a field it does not also set.
+ for (const name of new Set(input.markdownFields)) {
+ const sent = fields.find((field) => field.name === name);
+ const value = sent ? sent.value : fieldText(after.fields[name]);
+
+ if (value !== "" && (after.multilineFieldsFormat[name] ?? "").toLowerCase() !== "markdown") {
+ mismatches.push({ field: name, sent: "Markdown", stored: after.multilineFieldsFormat[name] ?? "" });
+ }
+ }
+
+ if (mismatches.length > 0) {
+ return {
+ ok: false,
+ error: `work item ${input.id} was updated to rev ${after.rev}, but the read-back differs`,
+ mismatches,
+ };
+ }
+
+ const images: Record = {};
+
+ for (const [name, uploaded] of input.reuse) {
+ images[name] = uploaded;
+ }
+
+ for (const image of input.images) {
+ const url = urls.get(image.src);
+
+ if (url) {
+ images[image.name] = { sha256: image.sha256, url };
+ }
+ }
+
+ return { ok: true, dryRun: false, rev: after.rev, images, plan };
+}
diff --git a/src/azure-devops/task-files.ts b/src/azure-devops/task-files.ts
index 2c390247f5..128be86d72 100644
--- a/src/azure-devops/task-files.ts
+++ b/src/azure-devops/task-files.ts
@@ -4,14 +4,15 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
+import { getLocalConfigDir } from "@app/azure-devops/config";
import type { FoundTaskFile } from "@app/azure-devops/types";
import { slugify } from "@genesiscz/utils/string";
/**
- * Get the tasks directory (always in cwd), optionally with category subdirectory
+ * Get the tasks directory (in cwd, or next to an explicit config), optionally with category subdirectory
*/
export function getTasksDir(category?: string): string {
- const base = join(process.cwd(), ".claude/azure/tasks");
+ const base = join(getLocalConfigDir(), "tasks");
return category ? join(base, category) : base;
}
diff --git a/src/azure-devops/templates.ts b/src/azure-devops/templates.ts
index 44c29430d0..2a112bf299 100644
--- a/src/azure-devops/templates.ts
+++ b/src/azure-devops/templates.ts
@@ -4,6 +4,7 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
+import { getLocalConfigDir } from "@app/azure-devops/config";
import type { WorkItem, WorkItemFull, WorkItemTemplate, WorkItemType } from "@app/azure-devops/types";
import { SafeJSON } from "@genesiscz/utils/json";
import { htmlToMarkdown } from "@genesiscz/utils/markdown/html-to-md";
@@ -472,7 +473,7 @@ export function saveTemplate(template: WorkItemTemplate, baseDir?: string): stri
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const filename = `${timestamp}-New${template.type.replace(/\s+/g, "")}.json`;
- const dir = baseDir || join(process.cwd(), ".claude/azure/tasks/created");
+ const dir = baseDir || join(getLocalConfigDir(), "tasks/created");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
diff --git a/src/azure-devops/types.ts b/src/azure-devops/types.ts
index c7a0cb21f0..6716f2b249 100644
--- a/src/azure-devops/types.ts
+++ b/src/azure-devops/types.ts
@@ -37,6 +37,15 @@ export interface AzWorkItemRaw {
relations?: Relation[];
}
+/** One work item as `GET wit/workitems/{id}` returns it, including the multiline field formats. */
+export interface WorkItemLive {
+ id: number;
+ rev: number;
+ fields: Record;
+ multilineFieldsFormat: Record;
+ relations?: Relation[];
+}
+
export interface WorkItem {
id: number;
rev: number;
@@ -398,7 +407,7 @@ export interface WorkItemTemplate {
/** JSON Patch operation for Azure DevOps API */
export interface JsonPatchOperation {
- op: "add" | "remove" | "replace";
+ op: "add" | "remove" | "replace" | "test";
path: string;
value?: unknown;
}
diff --git a/src/azure-devops/wiql-builder.test.ts b/src/azure-devops/wiql-builder.test.ts
index 42a882f699..f05ed5d27d 100644
--- a/src/azure-devops/wiql-builder.test.ts
+++ b/src/azure-devops/wiql-builder.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { buildCombinedQuery } from "@app/azure-devops/wiql-builder";
+import { buildCombinedQuery, wiqlRequestsTimePrecision, wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder";
describe("buildCombinedQuery", () => {
test("scopes to the configured project and matches the current assignee with =", () => {
@@ -54,3 +54,27 @@ describe("buildCombinedQuery", () => {
expect(wiql).toContain("CONTAINS 'O''Neil'");
});
});
+
+describe("wiql time precision", () => {
+ test("a clock in a ChangedDate literal asks for time precision", () => {
+ const wiql = "AND [System.ChangedDate] >= '2026-10-15T02:12:15.000Z'";
+
+ expect(wiqlRequestsTimePrecision(wiql)).toBe(true);
+ expect(wiqlTimePrecisionParam(wiql)).toBe("true");
+ });
+
+ test("a space-separated clock asks for time precision too", () => {
+ expect(wiqlRequestsTimePrecision("AND [System.ChangedDate] >= '2026-10-15 02:12:15'")).toBe(true);
+ });
+
+ test("a bare day stays on date precision", () => {
+ const wiql = "AND [System.ChangedDate] >= '2026-10-15' ORDER BY [System.ChangedDate] DESC";
+
+ expect(wiqlRequestsTimePrecision(wiql)).toBe(false);
+ expect(wiqlTimePrecisionParam(wiql)).toBeUndefined();
+ });
+
+ test("an explicit false keeps date precision even when the literal has a clock", () => {
+ expect(wiqlTimePrecisionParam("[System.ChangedDate] >= '2026-10-15T02:12:15.000Z'", false)).toBeUndefined();
+ });
+});
diff --git a/src/azure-devops/wiql-builder.ts b/src/azure-devops/wiql-builder.ts
index 40d92e6e4f..4bcc5e42e7 100644
--- a/src/azure-devops/wiql-builder.ts
+++ b/src/azure-devops/wiql-builder.ts
@@ -28,6 +28,23 @@ export function escapeWiqlValue(value: string): string {
return value.replace(/'/g, "''");
}
+/**
+ * Whether a WIQL statement contains a clock. The query endpoint runs in date
+ * precision unless `timePrecision=true`, and a clock then fails with
+ * "You cannot supply a time with the date when running a query using date precision."
+ * A bare `YYYY-MM-DD` stays on date precision: that mode is what makes the day mean the whole day.
+ */
+export function wiqlRequestsTimePrecision(wiql: string): boolean {
+ return /'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(wiql);
+}
+
+/** `true` when this call must set the WIQL `timePrecision` query flag. An explicit false wins. */
+export function wiqlTimePrecisionParam(wiql: string, explicit?: boolean): "true" | undefined {
+ const on = explicit ?? wiqlRequestsTimePrecision(wiql);
+
+ return on ? "true" : undefined;
+}
+
function toWiqlList(csv: string): string | null {
const values = csv
.split(",")
diff --git a/src/browser-extension/extension/popup.html b/src/browser-extension/extension/popup.html
index 65bc7fb822..77acdf8363 100644
--- a/src/browser-extension/extension/popup.html
+++ b/src/browser-extension/extension/popup.html
@@ -6,21 +6,39 @@
GenesisTools
-
+
GT GenesisTools
host
-
+
Update
-
+
+
Native host
@@ -37,6 +55,7 @@
This page
Actions
+
Nothing to do on this page. Open a GitHub pull request or a GitLab merge request to review it here.