@kairyou/agent-tools 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const JSON_LIMIT = 4 * 1024 * 1024;
10
+ const BINARY_LIMIT = 20 * 1024 * 1024;
11
+ const INPUT_LIMIT = 1024 * 1024;
12
+ const SECRET_KEYS = /^(?:password|token|authorization|cookie|set-cookie)$/i;
13
+ const RESOLUTIONS = new Set([
14
+ "fixed",
15
+ "notrepro",
16
+ "duplicate",
17
+ "bydesign",
18
+ "external",
19
+ "postponed",
20
+ "willnotfix",
21
+ ]);
22
+ let activeSecrets = [];
23
+
24
+ class CliError extends Error {
25
+ constructor(code, message, status = null) {
26
+ super(message);
27
+ this.code = code;
28
+ this.status = status;
29
+ }
30
+ }
31
+
32
+ function stripJsonc(text) {
33
+ let output = "";
34
+ let inString = false;
35
+ let escaped = false;
36
+ let lineComment = false;
37
+ let blockComment = false;
38
+
39
+ for (let index = 0; index < text.length; index += 1) {
40
+ const char = text[index];
41
+ const next = text[index + 1];
42
+ if (lineComment) {
43
+ if (char === "\n" || char === "\r") {
44
+ lineComment = false;
45
+ output += char;
46
+ } else {
47
+ output += " ";
48
+ }
49
+ continue;
50
+ }
51
+ if (blockComment) {
52
+ if (char === "*" && next === "/") {
53
+ output += " ";
54
+ index += 1;
55
+ blockComment = false;
56
+ } else {
57
+ output += char === "\n" || char === "\r" ? char : " ";
58
+ }
59
+ continue;
60
+ }
61
+ if (inString) {
62
+ output += char;
63
+ if (escaped) escaped = false;
64
+ else if (char === "\\") escaped = true;
65
+ else if (char === '"') inString = false;
66
+ continue;
67
+ }
68
+ if (char === '"') {
69
+ inString = true;
70
+ output += char;
71
+ } else if (char === "/" && next === "/") {
72
+ output += " ";
73
+ index += 1;
74
+ lineComment = true;
75
+ } else if (char === "/" && next === "*") {
76
+ output += " ";
77
+ index += 1;
78
+ blockComment = true;
79
+ } else {
80
+ output += char;
81
+ }
82
+ }
83
+
84
+ let cleaned = "";
85
+ inString = false;
86
+ escaped = false;
87
+ for (let index = 0; index < output.length; index += 1) {
88
+ const char = output[index];
89
+ if (inString) {
90
+ cleaned += char;
91
+ if (escaped) escaped = false;
92
+ else if (char === "\\") escaped = true;
93
+ else if (char === '"') inString = false;
94
+ continue;
95
+ }
96
+ if (char === '"') {
97
+ inString = true;
98
+ cleaned += char;
99
+ continue;
100
+ }
101
+ if (char === ",") {
102
+ let lookahead = index + 1;
103
+ while (/\s/.test(output[lookahead] || "")) lookahead += 1;
104
+ if (output[lookahead] === "}" || output[lookahead] === "]") continue;
105
+ }
106
+ cleaned += char;
107
+ }
108
+ return cleaned;
109
+ }
110
+
111
+ export function parseJsonc(text, label = "config") {
112
+ try {
113
+ return JSON.parse(stripJsonc(text));
114
+ } catch {
115
+ throw new CliError("config_error", `${label} is not valid JSONC`);
116
+ }
117
+ }
118
+
119
+ function configFile(env) {
120
+ const root = env.AGENT_TOOLS_HOME
121
+ ? path.resolve(env.AGENT_TOOLS_HOME)
122
+ : path.join(os.homedir(), ".agent-tools");
123
+ return path.join(root, "config.jsonc");
124
+ }
125
+
126
+ function resolveValue(value, env, label, { required = true } = {}) {
127
+ if (typeof value === "string" && value.trim()) return value.trim();
128
+ if (
129
+ value &&
130
+ typeof value === "object" &&
131
+ !Array.isArray(value) &&
132
+ typeof value.env === "string" &&
133
+ value.env.trim()
134
+ ) {
135
+ const name = value.env.trim();
136
+ if (typeof env[name] === "string" && env[name].trim()) return env[name].trim();
137
+ throw new CliError("config_error", `${label} references unset environment variable ${name}`);
138
+ }
139
+ if (!required && (value === undefined || value === null || value === "")) return null;
140
+ throw new CliError("config_error", `${label} is missing or empty`);
141
+ }
142
+
143
+ export function loadConfig({ env = process.env, file = configFile(env) } = {}) {
144
+ let root = {};
145
+ if (fs.existsSync(file)) root = parseJsonc(fs.readFileSync(file, "utf8"), file);
146
+ const section = root.zentao && typeof root.zentao === "object" ? root.zentao : {};
147
+ const rawUrl = env.ZENTAO_URL || section.url;
148
+ const rawAccount = env.ZENTAO_ACCOUNT || section.account;
149
+ const rawPassword = env.ZENTAO_PASSWORD || section.password;
150
+ const rawToken = env.ZENTAO_TOKEN;
151
+ const urlText = resolveValue(rawUrl, env, "zentao.url");
152
+ let parsedUrl;
153
+ try {
154
+ parsedUrl = new URL(urlText);
155
+ } catch {
156
+ throw new CliError("config_error", "zentao.url must be a valid HTTP(S) URL");
157
+ }
158
+ if (
159
+ !["http:", "https:"].includes(parsedUrl.protocol) ||
160
+ parsedUrl.username ||
161
+ parsedUrl.password ||
162
+ parsedUrl.search ||
163
+ parsedUrl.hash
164
+ ) {
165
+ throw new CliError(
166
+ "config_error",
167
+ "zentao.url must be an HTTP(S) URL without credentials, query, or fragment"
168
+ );
169
+ }
170
+ parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "");
171
+ const token = resolveValue(rawToken, env, "zentao.token", { required: false });
172
+ const account = resolveValue(rawAccount, env, "zentao.account", { required: !token });
173
+ const password = resolveValue(rawPassword, env, "zentao.password", { required: !token });
174
+ return {
175
+ url: parsedUrl.href.replace(/\/$/, ""),
176
+ account,
177
+ password,
178
+ token,
179
+ tokenOnly: Boolean(token),
180
+ secrets: [account, password, token].filter(Boolean),
181
+ };
182
+ }
183
+
184
+ function redactString(value, secrets) {
185
+ let output = value;
186
+ for (const secret of secrets) output = output.split(secret).join("***");
187
+ return output;
188
+ }
189
+
190
+ export function sanitize(value, secrets = []) {
191
+ if (typeof value === "string") return redactString(value, secrets);
192
+ if (Array.isArray(value)) return value.map((entry) => sanitize(entry, secrets));
193
+ if (value && typeof value === "object") {
194
+ const output = {};
195
+ for (const [key, entry] of Object.entries(value)) {
196
+ output[key] = SECRET_KEYS.test(key) ? "***" : sanitize(entry, secrets);
197
+ }
198
+ return output;
199
+ }
200
+ return value;
201
+ }
202
+
203
+ async function readLimited(response, limit) {
204
+ const reader = response.body?.getReader();
205
+ if (!reader) return Buffer.alloc(0);
206
+ const chunks = [];
207
+ let size = 0;
208
+ while (true) {
209
+ const { done, value } = await reader.read();
210
+ if (done) break;
211
+ size += value.byteLength;
212
+ if (size > limit) {
213
+ await reader.cancel();
214
+ throw new CliError("response_too_large", `ZenTao response exceeds ${limit} bytes`);
215
+ }
216
+ chunks.push(Buffer.from(value));
217
+ }
218
+ return Buffer.concat(chunks);
219
+ }
220
+
221
+ function safeRemoteMessage(body, secrets) {
222
+ let value;
223
+ try {
224
+ value = JSON.parse(body);
225
+ } catch {
226
+ value = null;
227
+ }
228
+ const candidates = [value?.message, value?.error, value?.msg].filter(
229
+ (entry) => typeof entry === "string" && entry.trim()
230
+ );
231
+ const message = candidates[0] || "ZenTao returned an error response";
232
+ return redactString(message.slice(0, 500), secrets);
233
+ }
234
+
235
+ class ZenTaoClient {
236
+ constructor(config) {
237
+ this.config = config;
238
+ this.token = config.token;
239
+ }
240
+
241
+ endpoint(relative) {
242
+ const url = new URL(relative, `${this.config.url}/`);
243
+ const base = new URL(this.config.url);
244
+ if (url.origin !== base.origin || !url.pathname.startsWith(`${base.pathname.replace(/\/$/, "")}/`)) {
245
+ throw new CliError("unsafe_url", "ZenTao resource URL is outside the configured endpoint");
246
+ }
247
+ return url;
248
+ }
249
+
250
+ async exchangeToken() {
251
+ if (this.config.tokenOnly) return this.token;
252
+ const response = await fetch(this.endpoint("api.php/v1/tokens"), {
253
+ method: "POST",
254
+ redirect: "manual",
255
+ headers: { "content-type": "application/json" },
256
+ body: JSON.stringify({ account: this.config.account, password: this.config.password }),
257
+ });
258
+ const body = (await readLimited(response, JSON_LIMIT)).toString("utf8");
259
+ if (!response.ok) {
260
+ throw new CliError(
261
+ "auth_error",
262
+ `ZenTao authentication failed (HTTP ${response.status})`,
263
+ response.status
264
+ );
265
+ }
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(body);
269
+ } catch {
270
+ throw new CliError("auth_error", "ZenTao authentication returned invalid JSON");
271
+ }
272
+ if (typeof parsed.token !== "string" || !parsed.token) {
273
+ throw new CliError("auth_error", "ZenTao authentication response has no token");
274
+ }
275
+ this.token = parsed.token;
276
+ this.config.secrets.push(parsed.token);
277
+ return this.token;
278
+ }
279
+
280
+ async request(relative, options = {}, retried = false) {
281
+ if (!this.token) await this.exchangeToken();
282
+ const headers = new Headers(options.headers || {});
283
+ headers.set("Token", this.token);
284
+ const response = await fetch(this.endpoint(relative), {
285
+ ...options,
286
+ headers,
287
+ redirect: "manual",
288
+ });
289
+ if (response.status === 401 && !retried && !this.config.tokenOnly) {
290
+ await response.body?.cancel();
291
+ this.token = null;
292
+ await this.exchangeToken();
293
+ return this.request(relative, options, true);
294
+ }
295
+ return response;
296
+ }
297
+
298
+ async json(relative, options = {}) {
299
+ const response = await this.request(relative, options);
300
+ const body = (await readLimited(response, JSON_LIMIT)).toString("utf8");
301
+ if (!response.ok) {
302
+ throw new CliError(
303
+ response.status === 401 ? "auth_error" : "http_error",
304
+ `ZenTao request failed (HTTP ${response.status}): ${safeRemoteMessage(body, this.config.secrets)}`,
305
+ response.status
306
+ );
307
+ }
308
+ try {
309
+ return JSON.parse(body);
310
+ } catch {
311
+ throw new CliError("response_error", "ZenTao returned invalid JSON");
312
+ }
313
+ }
314
+
315
+ async download(relative, destination) {
316
+ const response = await this.request(relative);
317
+ if (!response.ok) {
318
+ const body = (await readLimited(response, JSON_LIMIT)).toString("utf8");
319
+ throw new CliError(
320
+ "http_error",
321
+ `ZenTao attachment failed (HTTP ${response.status}): ${safeRemoteMessage(body, this.config.secrets)}`,
322
+ response.status
323
+ );
324
+ }
325
+ const body = await readLimited(response, BINARY_LIMIT);
326
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
327
+ fs.writeFileSync(destination, body, { flag: "wx" });
328
+ }
329
+ }
330
+
331
+ function decodeLegacy(value) {
332
+ if (!value || typeof value !== "object") throw new CliError("response_error", "ZenTao returned an invalid response");
333
+ if (value.status && value.status !== "success") {
334
+ throw new CliError("remote_error", "ZenTao reported that the operation failed");
335
+ }
336
+ if (typeof value.data !== "string") return value.data ?? value;
337
+ try {
338
+ return JSON.parse(value.data);
339
+ } catch {
340
+ throw new CliError("response_error", "ZenTao returned invalid nested JSON");
341
+ }
342
+ }
343
+
344
+ function positiveId(value) {
345
+ if (!/^\d+$/.test(value || "") || Number(value) < 1) {
346
+ throw new CliError("usage_error", "item id must be a positive integer");
347
+ }
348
+ return value;
349
+ }
350
+
351
+ function itemKind(value) {
352
+ if (value !== "bug" && value !== "task") {
353
+ throw new CliError("usage_error", "item type must be bug or task");
354
+ }
355
+ return value;
356
+ }
357
+
358
+ function pick(source, keys) {
359
+ const output = {};
360
+ for (const key of keys) if (source?.[key] !== undefined) output[key] = source[key];
361
+ return output;
362
+ }
363
+
364
+ function normalizeDetail(kind, response) {
365
+ const container = response?.data && typeof response.data === "object" ? response.data : response;
366
+ const detail = container?.[kind] || container;
367
+ if (!detail || typeof detail !== "object") {
368
+ throw new CliError("response_error", `ZenTao response has no ${kind} detail`);
369
+ }
370
+ return { raw: detail, safe: pick(detail, [
371
+ "id",
372
+ "title",
373
+ "name",
374
+ "steps",
375
+ "desc",
376
+ "status",
377
+ "severity",
378
+ "pri",
379
+ "module",
380
+ "product",
381
+ "project",
382
+ "execution",
383
+ "type",
384
+ "openedDate",
385
+ "deadline",
386
+ ]) };
387
+ }
388
+
389
+ function attachmentUrls(detail) {
390
+ const found = new Set();
391
+ const html = [detail.steps, detail.desc].filter((entry) => typeof entry === "string").join("\n");
392
+ for (const match of html.matchAll(/(?:src|href)=["']([^"']*\/file-(?:read|download)-\d+[^"']*)["']/gi)) {
393
+ found.add(match[1].replaceAll("&amp;", "&"));
394
+ }
395
+ const files = Array.isArray(detail.files) ? detail.files : Object.values(detail.files || {});
396
+ for (const file of files) {
397
+ for (const key of ["url", "webPath", "downloadURL", "downloadUrl"]) {
398
+ if (typeof file?.[key] === "string" && /\/file-(?:read|download)-\d+/i.test(file[key])) {
399
+ found.add(file[key]);
400
+ break;
401
+ }
402
+ }
403
+ }
404
+ return [...found];
405
+ }
406
+
407
+ function attachmentName(urlText, index) {
408
+ const pathname = new URL(urlText, "http://placeholder").pathname;
409
+ const match = pathname.match(/(file-(?:read|download)-\d+)(?:\.([A-Za-z0-9]{1,10}))?/i);
410
+ if (!match) return `attachment-${index + 1}`;
411
+ return `${match[1]}${match[2] ? `.${match[2]}` : ""}`;
412
+ }
413
+
414
+ async function downloadAttachments(client, detail, directory) {
415
+ const output = [];
416
+ for (const [index, urlText] of attachmentUrls(detail).entries()) {
417
+ const url = client.endpoint(urlText);
418
+ const destination = path.join(directory, attachmentName(url.href, index));
419
+ await client.download(url.href, destination);
420
+ output.push({ path: path.resolve(destination) });
421
+ }
422
+ return output;
423
+ }
424
+
425
+ async function readInput() {
426
+ if (process.stdin.isTTY) throw new CliError("usage_error", "this command requires JSON on stdin");
427
+ const chunks = [];
428
+ let size = 0;
429
+ for await (const chunk of process.stdin) {
430
+ size += chunk.length;
431
+ if (size > INPUT_LIMIT) throw new CliError("usage_error", "stdin JSON is too large");
432
+ chunks.push(chunk);
433
+ }
434
+ let value;
435
+ try {
436
+ value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
437
+ } catch {
438
+ throw new CliError("usage_error", "stdin must contain valid JSON");
439
+ }
440
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
441
+ throw new CliError("usage_error", "stdin JSON must be an object");
442
+ }
443
+ return value;
444
+ }
445
+
446
+ function formBody(fields) {
447
+ const body = new URLSearchParams();
448
+ for (const [key, value] of Object.entries(fields)) {
449
+ if (value !== undefined && value !== null && value !== "") body.set(key, String(value));
450
+ }
451
+ return body;
452
+ }
453
+
454
+ function legacyResult(response) {
455
+ const data = decodeLegacy(response);
456
+ const result = data?.result || data?.status || "success";
457
+ const message = data?.message || data?.msg || null;
458
+ if (result === "fail" || result === "failed" || result === "error") {
459
+ throw new CliError("remote_validation_error", typeof message === "string" ? message.slice(0, 500) : "ZenTao rejected the operation");
460
+ }
461
+ return { ok: true, result, ...(typeof message === "string" ? { message: message.slice(0, 500) } : {}) };
462
+ }
463
+
464
+ function localDateTime(date = new Date()) {
465
+ const part = (value) => String(value).padStart(2, "0");
466
+ return `${date.getFullYear()}-${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`;
467
+ }
468
+
469
+ function help() {
470
+ return `Usage:
471
+ zentao-cli.mjs doctor
472
+ zentao-cli.mjs list <bugs|tasks>
473
+ zentao-cli.mjs get <bug|task> <id> [--download-dir <path>]
474
+ zentao-cli.mjs resolve bug <id> # JSON on stdin
475
+ zentao-cli.mjs comment <bug|task> <id> # {"comment":"..."} on stdin
476
+ zentao-cli.mjs finish task <id> # JSON on stdin`;
477
+ }
478
+
479
+ export async function run(argv, { env = process.env } = {}) {
480
+ activeSecrets = [];
481
+ const [command, ...args] = argv;
482
+ if (!command || command === "help" || command === "--help" || command === "-h") {
483
+ return { help: help() };
484
+ }
485
+ const config = loadConfig({ env });
486
+ activeSecrets = config.secrets;
487
+ const client = new ZenTaoClient(config);
488
+
489
+ if (command === "doctor") {
490
+ await client.json("api.php/v1/user");
491
+ return { ok: true, endpoint: new URL(config.url).origin, authentication: config.tokenOnly ? "token" : "account-password" };
492
+ }
493
+
494
+ if (command === "list") {
495
+ const plural = args[0];
496
+ if (plural !== "bugs" && plural !== "tasks") throw new CliError("usage_error", "list type must be bugs or tasks");
497
+ const singular = plural.slice(0, -1);
498
+ const data = decodeLegacy(await client.json(`my-work-${singular}.json`));
499
+ const fields = singular === "bug"
500
+ ? ["id", "title", "severity", "pri", "status", "project", "product"]
501
+ : ["id", "name", "title", "pri", "status", "project", "execution", "module"];
502
+ const items = Array.isArray(data?.[plural]) ? data[plural].map((item) => pick(item, fields)) : [];
503
+ return { items, ...(data?.pager ? { pager: pick(data.pager, ["recTotal", "recPerPage", "pageID", "pageTotal"]) } : {}) };
504
+ }
505
+
506
+ if (command === "get") {
507
+ const kind = itemKind(args[0]);
508
+ const id = positiveId(args[1]);
509
+ let directory;
510
+ if (args[2] === "--download-dir" && args[3]) directory = path.resolve(args[3]);
511
+ else if (args.length > 2) throw new CliError("usage_error", "get accepts only --download-dir <path>");
512
+ else directory = fs.mkdtempSync(path.join(os.tmpdir(), `agent-tools-zentao-${kind}-${id}-`));
513
+ const detail = normalizeDetail(kind, await client.json(`api.php/v1/${kind}s/${id}`));
514
+ const attachments = await downloadAttachments(client, detail.raw, directory);
515
+ return { item: detail.safe, attachments };
516
+ }
517
+
518
+ if (command === "comment") {
519
+ const kind = itemKind(args[0]);
520
+ const id = positiveId(args[1]);
521
+ const input = await readInput();
522
+ if (typeof input.comment !== "string" || !input.comment.trim()) throw new CliError("usage_error", "comment is required");
523
+ const response = await client.json(`action-comment-${kind}-${id}.json`, {
524
+ method: "POST",
525
+ headers: { "content-type": "application/x-www-form-urlencoded" },
526
+ body: formBody({ comment: input.comment }),
527
+ });
528
+ return legacyResult(response);
529
+ }
530
+
531
+ if (command === "resolve") {
532
+ if (args[0] !== "bug") throw new CliError("usage_error", "resolve supports bugs only");
533
+ const id = positiveId(args[1]);
534
+ if (!config.account) throw new CliError("config_error", "zentao.account is required to resolve a bug");
535
+ const input = await readInput();
536
+ if (!RESOLUTIONS.has(input.resolution)) throw new CliError("usage_error", "resolution is invalid");
537
+ if (input.resolution === "duplicate" && !/^\d+$/.test(String(input.duplicateBug || ""))) {
538
+ throw new CliError("usage_error", "duplicateBug is required for duplicate resolution");
539
+ }
540
+ const response = await client.json(`bug-resolve-${id}.json`, {
541
+ method: "POST",
542
+ headers: { "content-type": "application/x-www-form-urlencoded" },
543
+ body: formBody({
544
+ resolution: input.resolution,
545
+ resolvedBuild: input.resolvedBuild || "trunk",
546
+ responsibleBy: config.account,
547
+ duplicateBug: input.duplicateBug,
548
+ comment: input.comment,
549
+ }),
550
+ });
551
+ return legacyResult(response);
552
+ }
553
+
554
+ if (command === "finish") {
555
+ if (args[0] !== "task") throw new CliError("usage_error", "finish supports tasks only");
556
+ const id = positiveId(args[1]);
557
+ const input = await readInput();
558
+ const current = Number(input.currentConsumed);
559
+ if (!Number.isFinite(current) || current <= 0) throw new CliError("usage_error", "currentConsumed must be positive");
560
+ const form = decodeLegacy(await client.json(`task-finish-${id}.json`));
561
+ const task = form?.task || {};
562
+ const previous = Number(task.consumed || 0);
563
+ const realStarted = task.realStarted || input.realStarted;
564
+ if (typeof realStarted !== "string" || !realStarted.trim()) throw new CliError("usage_error", "realStarted is required");
565
+ const response = await client.json(`task-finish-${id}.json`, {
566
+ method: "POST",
567
+ headers: { "content-type": "application/x-www-form-urlencoded" },
568
+ body: formBody({
569
+ currentConsumed: current,
570
+ consumed: previous + current,
571
+ realStarted,
572
+ finishedDate: input.finishedDate || localDateTime(),
573
+ }),
574
+ });
575
+ return legacyResult(response);
576
+ }
577
+
578
+ throw new CliError("usage_error", `unknown command ${command}`);
579
+ }
580
+
581
+ async function main() {
582
+ try {
583
+ const result = await run(process.argv.slice(2));
584
+ process.stdout.write(`${JSON.stringify(sanitize(result, activeSecrets), null, 2)}\n`);
585
+ } catch (error) {
586
+ const safe = sanitize({
587
+ ok: false,
588
+ error: error instanceof CliError ? error.code : "internal_error",
589
+ message: error instanceof Error ? error.message : "Unknown ZenTao CLI error",
590
+ ...(error instanceof CliError && error.status ? { status: error.status } : {}),
591
+ }, activeSecrets);
592
+ process.stderr.write(`${JSON.stringify(safe)}\n`);
593
+ process.exitCode = 1;
594
+ }
595
+ }
596
+
597
+ export function isMainModule(argvPath, moduleUrl = import.meta.url) {
598
+ if (!argvPath) return false;
599
+ try {
600
+ return fs.realpathSync(argvPath) === fs.realpathSync(fileURLToPath(moduleUrl));
601
+ } catch {
602
+ return false;
603
+ }
604
+ }
605
+
606
+ const invoked = isMainModule(process.argv[1]);
607
+ if (invoked) await main();
@@ -68,10 +68,12 @@ context.
68
68
  AI session log, separate from `dailyLog.output`. It is a markdown file with dated
69
69
  entries, or a directory holding one `<date>.md` report per day; entries under
70
70
  `log.projects` may route their sessions to their own `output`, so check those paths
71
- too. Read the day's content as supplementary evidence, as it captures work that
72
- produced no commits, such as troubleshooting or research sessions. Merge, do not
73
- duplicate, work already backed by commits; when the key or the day's content is
74
- absent, skip this entirely.
71
+ too. When `log.format` is `daily`, read its single-line results only as activity
72
+ leads: they may be truncated and omit important context. Do not state a log-only
73
+ item as a confirmed outcome from a daily entry alone. When `log.format` is `detailed`, its
74
+ per-day reports are stronger supplementary evidence, but still do not replace Git
75
+ or user confirmation. If the `log` block or its output is absent, skip this entirely.
76
+ Merge, do not duplicate, work already backed by commits.
75
77
 
76
78
  ## Output
77
79
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: at-review
3
- description: "Review code changes for bugs, regressions, convention violations, and high-value cleanup opportunities. Use for diffs, commit ranges, PRs, paths, staged changes, or working-tree changes."
4
- argument-hint: "[--fix] [<pr|branch|path>]"
3
+ description: "Review code changes for bugs, regressions, convention violations, and high-value cleanup opportunities. Use for diffs, commit ranges, hosted PR/MR URLs, branches, paths, staged changes, or working-tree changes."
4
+ argument-hint: "[--fix] [<pr-or-mr-url|branch|path>]"
5
5
  ---
6
6
 
7
7
  # Code Review
@@ -12,6 +12,8 @@ You are reviewing for **recall** at high effort: catch every real bug a careful
12
12
 
13
13
  ## Phase 0 — Gather the diff
14
14
 
15
+ If the argument is a hosted pull/merge request URL or a numeric PR/MR identifier, read `references/review-targets.md` from this skill directory before running commands. Follow its read-only resolution and authentication fallback rules; do not switch the user's working tree or write to the hosting service.
16
+
15
17
  Run `git diff "@{upstream}...HEAD"` (or `git diff main...HEAD` / `git diff HEAD~1` if there's no upstream) to get the unified diff under review. If there are uncommitted changes, or the range diff is empty, also run `git diff HEAD` and include the working-tree changes in scope — the review often runs before the commit. If a PR number, branch name, or file path was passed as an argument, review that target instead. Treat this diff as the review scope.
16
18
 
17
19
  ## Phase 1 — Find candidates (3 correctness angles + 3 cleanup angles + 1 altitude angle + 1 conventions angle, up to 6 each)