@blogic-cz/agent-tools 0.15.10 → 0.15.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -263,7 +263,7 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
263
263
 
264
264
  ### gh-tool machine contracts
265
265
 
266
- `pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR. `pr request-review --reviewers alice,bob` emits sorted `submittedReviewers` (normalized input) and `requestedReviewers` (GitHub-confirmed result).
266
+ `pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR. `pr request-review --reviewers alice,bob` emits sorted `submittedReviewers` (normalized input), `newlyRequested` and `alreadyPending` (the submitted logins split by whether a pending request already existed, so a fresh re-request is distinguishable from a no-op), and `requestedReviewers` (GitHub-confirmed result). `pr last-human-reviewer` derives `currentRequestedReviewers` from live `reviewRequests` only; timeline events are not replayed because GitHub clears a pending request on review submit without emitting `ReviewRequestRemovedEvent`.
267
267
 
268
268
  `pr watch --prs 12,34 --until terminal --format jsonl` accepts at most 50 unique, digits-only PR numbers and emits only JSONL state transitions. Identity uses `repo/pr/headSha/runId/attempt/jobId`; `runId`, `attempt`, and `jobId` are omitted from an event rather than emitted as `null`, and `checkId` is no longer emitted. State/bucket revisions emit even when identity stays stable; `supersedes` appears only when identity changes. Open PRs with no checks become terminal only after three stable empty snapshots, allowing bounded GitHub eventual consistency, and carry `checksObserved: false` — a terminal snapshot with no observed check is never green evidence. `pr checks`, batch checks, triage, and batch triage keep stderr silent with `--format json`; JSONL watch is also informationally silent. Failures still return structured nonzero errors on stderr.
269
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.15.10",
3
+ "version": "0.15.12",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -141,6 +141,15 @@ export const requestReview = (pr: number, reviewers: string) =>
141
141
  const submittedReviewers = yield* parseReviewers(reviewers);
142
142
  const gh = yield* GitHubService;
143
143
  const repo = yield* gh.getRepoInfo();
144
+ // ponytail: not atomic — a concurrent request between GET and POST misreports newlyRequested.
145
+ const pendingBefore = new Set(
146
+ (yield* confirmedReviewers(
147
+ yield* gh.runGhJson<RequestedReviewersResponse>([
148
+ "api",
149
+ `repos/${repo.owner}/${repo.name}/pulls/${pr}`,
150
+ ]),
151
+ )).map((login) => login.toLowerCase()),
152
+ );
144
153
  const response = yield* gh.runGhJson<RequestedReviewersResponse>([
145
154
  "api",
146
155
  "--method",
@@ -151,6 +160,8 @@ export const requestReview = (pr: number, reviewers: string) =>
151
160
  return {
152
161
  pr,
153
162
  submittedReviewers,
163
+ newlyRequested: submittedReviewers.filter((reviewer) => !pendingBefore.has(reviewer)),
164
+ alreadyPending: submittedReviewers.filter((reviewer) => pendingBefore.has(reviewer)),
154
165
  requestedReviewers: yield* confirmedReviewers(response),
155
166
  };
156
167
  });
@@ -126,27 +126,6 @@ const LAST_HUMAN_REVIEWER_QUERY = `
126
126
  submittedAt
127
127
  }
128
128
  }
129
- timelineItems(first: 250, itemTypes: [REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT]) {
130
- nodes {
131
- __typename
132
- ... on ReviewRequestedEvent {
133
- requestedReviewer {
134
- __typename
135
- ... on User { login }
136
- ... on Team { name }
137
- ... on Bot { login }
138
- }
139
- }
140
- ... on ReviewRequestRemovedEvent {
141
- requestedReviewer {
142
- __typename
143
- ... on User { login }
144
- ... on Team { name }
145
- ... on Bot { login }
146
- }
147
- }
148
- }
149
- }
150
129
  }
151
130
  }
152
131
  }
@@ -237,12 +216,6 @@ type LastHumanReviewerQueryResult = {
237
216
  submittedAt: string | null;
238
217
  }>;
239
218
  };
240
- timelineItems: {
241
- nodes: Array<{
242
- __typename: string;
243
- requestedReviewer: RequestedReviewerNode;
244
- }>;
245
- };
246
219
  };
247
220
  };
248
221
  };
@@ -1066,30 +1039,11 @@ export const fetchLastHumanReviewer = Effect.fn("pr.fetchLastHumanReviewer")(fun
1066
1039
 
1067
1040
  const prNode = response.repository.pullRequest;
1068
1041
 
1069
- const requestedFromTimeline: string[] = [];
1070
- for (const item of prNode.timelineItems.nodes) {
1071
- const login = requestedReviewerLogin(item.requestedReviewer);
1072
- if (login === null) {
1073
- continue;
1074
- }
1075
- if (item.__typename === "ReviewRequestedEvent") {
1076
- if (!requestedFromTimeline.includes(login)) {
1077
- requestedFromTimeline.push(login);
1078
- }
1079
- } else {
1080
- const index = requestedFromTimeline.indexOf(login);
1081
- if (index !== -1) {
1082
- requestedFromTimeline.splice(index, 1);
1083
- }
1084
- }
1085
- }
1086
-
1087
- const currentRequestedReviewers =
1088
- requestedFromTimeline.length > 0
1089
- ? requestedFromTimeline
1090
- : prNode.reviewRequests.nodes
1091
- .map((node) => requestedReviewerLogin(node.requestedReviewer))
1092
- .filter((login): login is string => login !== null);
1042
+ // GitHub clears a pending request on review submit without a ReviewRequestRemovedEvent, so
1043
+ // only reviewRequests is authoritative; timeline replay would report stale reviewers forever.
1044
+ const currentRequestedReviewers = prNode.reviewRequests.nodes
1045
+ .map((node) => requestedReviewerLogin(node.requestedReviewer))
1046
+ .filter((login): login is string => login !== null);
1093
1047
 
1094
1048
  const latestHumanReview = prNode.reviews.nodes.reduce<{ login: string; at: string } | null>(
1095
1049
  (latest, review) => {
@@ -1,11 +1,12 @@
1
1
  import { Effect } from "effect";
2
2
  import { readdir } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
3
4
 
4
5
  import type { MessageSummary } from "./types";
5
6
 
6
7
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
7
8
 
8
- export const encodeProjectPath = (dir: string): string => dir.replaceAll("/", "-");
9
+ export const encodeProjectPath = (dir: string): string => dir.replaceAll(/[/\\:]/g, "-");
9
10
 
10
11
  export type ContentBlock =
11
12
  | { type: "text"; text: string }
@@ -172,7 +173,7 @@ export const getClaudeCodeSessions = (
172
173
  continue;
173
174
  }
174
175
 
175
- const projectPath = `${basePath}/${entry.name}`;
176
+ const projectPath = join(basePath, entry.name);
176
177
  let files: string[];
177
178
  try {
178
179
  // eslint-disable-next-line eslint/no-await-in-loop -- directory scan must finish before filtering file list
@@ -186,7 +187,7 @@ export const getClaudeCodeSessions = (
186
187
  continue;
187
188
  }
188
189
 
189
- sessionFiles.push(`${projectPath}/${fileName}`);
190
+ sessionFiles.push(join(projectPath, fileName));
190
191
  }
191
192
  }
192
193
 
@@ -200,8 +201,8 @@ export const getClaudeCodeSessions = (
200
201
  });
201
202
 
202
203
  const getFileNameWithoutExtension = (filePath: string): string => {
203
- const fileName = filePath.split("/").pop();
204
- if (fileName === undefined) {
204
+ const fileName = basename(filePath);
205
+ if (fileName === "") {
205
206
  return "";
206
207
  }
207
208
 
@@ -2,6 +2,7 @@ import { Effect } from "effect";
2
2
  // node:fs/promises, not Bun.file/Bun.Glob: vitest runs under Node, where the Bun globals do not
3
3
  // exist, and every function below is reached by the suite. See AGENTS.md.
4
4
  import { open, readFile, readdir, stat } from "node:fs/promises";
5
+ import { join } from "node:path";
5
6
 
6
7
  import type { MessageSummary, SessionSummary } from "./types";
7
8
 
@@ -117,10 +118,10 @@ const walkSessionFiles = async (basePath: string): Promise<string[]> => {
117
118
  directories
118
119
  .filter((entry) => entry.isDirectory())
119
120
  .map(async (directory) => {
120
- const directoryPath = `${basePath}/${directory.name}`;
121
+ const directoryPath = join(basePath, directory.name);
121
122
  return (await readdir(directoryPath, { withFileTypes: true }))
122
123
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
123
- .map((entry) => `${directoryPath}/${entry.name}`);
124
+ .map((entry) => join(directoryPath, entry.name));
124
125
  }),
125
126
  );
126
127
  return files.flat();
@@ -1,5 +1,6 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import { readdir } from "node:fs/promises";
3
+ import { basename } from "node:path";
3
4
 
4
5
  import type { MessageSummary, SessionInfo, SessionSource, SessionSummary } from "./types";
5
6
 
@@ -52,7 +53,7 @@ const UUID_SESSION_ID_REGEX =
52
53
  /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/u;
53
54
 
54
55
  const getSessionIdFromClaudeFile = (filePath: string): string => {
55
- const fileName = filePath.split("/").pop() ?? "";
56
+ const fileName = basename(filePath);
56
57
  return fileName.endsWith(".jsonl") ? fileName.slice(0, -".jsonl".length) : fileName;
57
58
  };
58
59
 
@@ -333,7 +334,7 @@ export class SessionService extends Context.Service<
333
334
 
334
335
  summaries.push({
335
336
  sessionID: parsed.sessionID ?? sessionId,
336
- id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
337
+ id: parsed.id ?? basename(filePath).replace(".json", ""),
337
338
  title: parsed.summary.title,
338
339
  body: parsed.summary.body ?? "",
339
340
  created: parsed.time?.created ?? 0,