pi-orchestrator-config 4.15.0 → 4.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.
package/README.md CHANGED
@@ -807,8 +807,9 @@ PI_PIDIFF_ENABLE=false pi
807
807
  | `myk-pi-tools pr claude-md` | Fetch `CLAUDE.md` and `AGENTS.md` content for a PR's repository |
808
808
  | `myk-pi-tools release create` | Create a GitHub release with changelog |
809
809
  | `myk-pi-tools db` | Review database query commands |
810
- | `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store) |
810
+ | `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store, store-session) |
811
811
  | `myk-pi-tools reviews address` | Apply replies/statuses to a fetched reviews JSON, then post and store it (see below) |
812
+ | `myk-pi-tools reviews store-session` | Store Qodo pr-review-session findings JSON into the reviews DB (idempotent; run at loop convergence) |
812
813
  | `myk-pi-tools memory` | Project memory commands — persistent per-repo learning |
813
814
  | `myk-pi-tools ai-cli` | AI CLI commands (cursor, claude, gemini) |
814
815
  | `myk-pi-tools coderabbit` | CodeRabbit commands (check, trigger, retry) |
@@ -821,13 +822,21 @@ myk-pi-tools reviews address [--json <reviews.json>] [--replies <mapping.txt>] [
821
822
  ```
822
823
 
823
824
  - `--json`: reviews JSON to process (default: newest `pr-*-reviews.json` in `.pi/tmp`)
824
- - `--replies`: mapping file, one rule per line — `<path>:<line>|<reply>` or `*|<reply>` (matches all pending findings);
825
- `#` comments and blank lines ignored
825
+ - `--replies`: mapping file, one rule per line — full syntax:
826
+ - `<path>:<line>|<reply>` — matches the finding's full `[line, end_line]` span
827
+ - `<path>:<line>~<fragment>|<reply>` — discriminator: additionally requires the
828
+ case-insensitive `fragment` in the finding's body (targets one of two findings
829
+ whose spans overlap at the same line)
830
+ - `<path>:*|<reply>` — every finding in that file gets the reply (per-file wildcard)
831
+ - `*|<reply>` — matches all pending findings
832
+ - A finding whose span contains another finding's span (nested findings) matches
833
+ only its own reported line; `#` comments and blank lines ignored
826
834
  - `--set`: target status for matched findings — `addressed` (default), `skipped`, `not_addressed`, or `failed`
827
835
  - Without `--replies`, pending findings that already carry a reply only get their status flipped (status-only mode)
828
836
  - Exit code `0` = replies posted and stored; non-zero = nothing matched, validation failed, or posting failed
829
837
  (the JSON is kept for retry — re-running with the same or a corrected mapping resumes unposted entries)
830
- - Validation failures change nothing: a rule matching several distinct findings at one location, or a status `post`
838
+ - Validation failures change nothing: a rule matching several distinct findings (overlapping
839
+ spans — disambiguate with `<path>:<line>~<fragment>`), or a status `post`
831
840
  rejects for Qodo sticky findings (`not_addressed`/`failed`; `skipped` only with an issue-spec-update reply),
832
841
  exits non-zero before the JSON is saved
833
842
 
@@ -3,7 +3,7 @@
3
3
  * recall them at write time (issue #918 Parts A and B).
4
4
  *
5
5
  * Part A — a `tool_result` watcher recognizes `myk-pi-tools reviews address`
6
- * completions in main-session bash, then (fire-and-forget — scheduled via
6
+ * AND `myk-pi-tools reviews store-session` completions in main-session bash, then (fire-and-forget — scheduled via
7
7
  * setImmediate so the handler returns before any synchronous I/O starts)
8
8
  * distills the ADDRESSED findings of the review that command stored from
9
9
  * `.pi/data/reviews.db` into one-line `lesson` memory entries via the internal
@@ -11,11 +11,13 @@
11
11
  * (`derivedFrom="reviews.db:<comment-id>;<owner>/<repo>"`).
12
12
  *
13
13
  * Review selection: the addressed PR is identified from the command's
14
- * `--json` file (or the newest `pr-*-reviews.json` in the project tmp dir —
15
- * mirroring address.py's default), and the newest stored review row for that
16
- * PR is distilled. Already-distilled review ids are tracked in
17
- * `.pi/data/review-learning-state.json`, so re-fires (multi-group address
18
- * runs, partial runs that did not store) are no-ops — evidence only grows
14
+ * address `--json` file or the store-session JSON's `pr_url`. For a known
15
+ * PR, ALL stored review rows of that PR distill — session loops store one
16
+ * row per commit (Qodo 16d84431). Lessons already successfully written are
17
+ * tracked per review in `.pi/data/review-learning-state.json`, so re-fires
18
+ * (multi-group address runs, partial runs, every store-session round) never
19
+ * re-write them, and the per-fire lesson cap walks forward to the remaining
20
+ * findings — evidence only grows
19
21
  * when a NEW review stores a NEW comment row for the same issue (a real
20
22
  * recurrence, via addMemoryEntry's duplicate detection).
21
23
  *
@@ -91,6 +93,17 @@ const MAX_TRACKED_REVIEWS = 200;
91
93
  /** `reviews address` as a subcommand invocation (not `reviews address-foo`). */
92
94
  const REVIEWS_ADDRESS_RE = /(?:^|[\s;&|])reviews\s+address(?=[\s;&|]|$)/i;
93
95
 
96
+ /** `reviews store-session` as a subcommand invocation (not `store-session-foo`). */
97
+ const REVIEWS_STORE_SESSION_RE = /(?:^|[\s;&|])reviews\s+store-session(?=[\s;&|]|$)/i;
98
+
99
+ /** Positional JSON path of a `reviews store-session <path>` invocation —
100
+ * stops at shell separators so a chained command
101
+ * (`store-session /tmp/x.json; echo done`) does not capture the `;`. */
102
+ const REVIEWS_STORE_SESSION_PATH_RE = /(?:^|[\s;&|])reviews\s+store-session\s+([^;\s&|]+)/i;
103
+
104
+ /** owner/repo/pr from a GitHub PR URL (`https://github.com/o/r/pull/N`). */
105
+ const PR_URL_PARTS_RE = /^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i;
106
+
94
107
  /**
95
108
  * True when a bash command invokes `myk-pi-tools reviews address`.
96
109
  * Tolerates `uv run ... myk-pi-tools ... reviews address` and `uv run --group X ...`
@@ -103,6 +116,19 @@ export function isReviewsAddressCommand(command: unknown): boolean {
103
116
  return REVIEWS_ADDRESS_RE.test(normalized);
104
117
  }
105
118
 
119
+ /**
120
+ * True when a bash command invokes `myk-pi-tools reviews store-session` —
121
+ * the command a Qodo pr-review-session loop runs after every completed
122
+ * round (issue #942/#944 coupling: session-stored reviews must distill the
123
+ * same way address-stored ones do). `reviews store` alone never matches.
124
+ */
125
+ export function isReviewsStoreSessionCommand(command: unknown): boolean {
126
+ if (typeof command !== "string") return false;
127
+ const normalized = command.trim();
128
+ if (!/\bmyk-pi-tools\b/i.test(normalized)) return false;
129
+ return REVIEWS_STORE_SESSION_RE.test(normalized);
130
+ }
131
+
106
132
  // ── Repo resolution ────────────────────────────────────────────────────────
107
133
 
108
134
  export interface RepoInfo {
@@ -203,6 +229,36 @@ export function getLatestReview(dbPath: string, owner: string, repo: string, prN
203
229
  }
204
230
  }
205
231
 
232
+ /**
233
+ * All review rows for owner/repo/PR, oldest first — a session loop stores
234
+ * ONE review row per commit (issue #942), so a PR's findings live in MANY
235
+ * rows; distillation must process every group, not just the highest id
236
+ * (Qodo 16d84431).
237
+ */
238
+ export function getReviewsForPr(dbPath: string, owner: string, repo: string, prNumber: number): ReviewRow[] {
239
+ const db = openReviewsDb(dbPath);
240
+ if (!db) return [];
241
+ try {
242
+ const rows = db
243
+ .prepare(
244
+ "SELECT id, pr_number, commit_sha, owner, repo FROM reviews WHERE owner=? AND repo=? AND pr_number=? ORDER BY id ASC",
245
+ )
246
+ .all(owner, repo, prNumber) as any[];
247
+ return rows.map((row) => ({
248
+ id: Number(row.id),
249
+ prNumber: Number(row.pr_number),
250
+ commitSha: String(row.commit_sha),
251
+ owner: String(row.owner),
252
+ repo: String(row.repo),
253
+ }));
254
+ } catch (error: any) {
255
+ log.debug("reviews_db_query_failed", { dbPath, reason: String(error?.message ?? error).slice(0, 200) });
256
+ return [];
257
+ } finally {
258
+ db.close();
259
+ }
260
+ }
261
+
206
262
  /**
207
263
  * Addressed findings for one review id — the review the address command
208
264
  * stored, not every review sharing its commit SHA (skipped rows excluded by
@@ -308,7 +364,12 @@ function writeStateFile(dataDir: string, distilledReviews: number[], reviewLesso
308
364
  /** Record a review id as distilled (bounded — oldest entries drop off). Fail-soft. */
309
365
  export function markReviewDistilled(dataDir: string, reviewId: number): void {
310
366
  try {
311
- writeStateFile(dataDir, [...loadDistilledReviews(dataDir), reviewId], loadReviewLessons(dataDir));
367
+ // Dedupe: the PR walk re-marks already-distilled reviews on every fire
368
+ // (their completed lessons make it a no-op walk), which would append the
369
+ // same id once per fire and grow the state file without bound.
370
+ const distilled = [...new Set(loadDistilledReviews(dataDir))];
371
+ if (!distilled.includes(reviewId)) distilled.push(reviewId);
372
+ writeStateFile(dataDir, distilled, loadReviewLessons(dataDir));
312
373
  } catch (error: any) {
313
374
  log.debug("distilled_state_write_failed", { reason: String(error?.message ?? error).slice(0, 200) });
314
375
  }
@@ -330,23 +391,136 @@ export function recordReviewLessons(dataDir: string, reviewId: number, texts: st
330
391
  // ── Addressed-PR identification ────────────────────────────────────────────
331
392
 
332
393
  /**
333
- * PR number of the review an address command targeted — ONLY from the
334
- * command's explicit `--json` file (still on disk for partial runs).
335
- * Deliberately no newest-JSON fallback: the final, storing run deletes its
336
- * own pr-<N>-reviews.json before the tool result fires, so a fallback would
337
- * pick up a leftover JSON from ANOTHER PR and target the wrong review.
338
- * Returns null when absent/unreadable — callers then select the newest
339
- * review for the repo (final storing runs), never a different explicit PR.
394
+ * Expand `$VAR` / `${VAR}` shell expressions in a captured command path and
395
+ * strip surrounding shell quotes. Bash expands these before the tool runs,
396
+ * so the hook normally sees the literal path — but a command recorded with
397
+ * the variable expression or quoted path (a combined poll+store line
398
+ * submitted verbatim, `store-session "${PROJECT_TMP_DIR}/x.json"`) must
399
+ * not read a nonexistent literal `"${...}"` path (Qodo 657c5633,
400
+ * bda69aff). Unset variables expand to the empty string, matching shell
401
+ * semantics.
402
+ */
403
+ function expandEnvVars(value: string): string {
404
+ const expanded = value.replace(
405
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
406
+ (_match, braced: string | undefined, plain: string | undefined) => process.env[braced ?? plain ?? ""] ?? "",
407
+ );
408
+ return expanded.replace(/^["']+|["']+$/g, "");
409
+ }
410
+
411
+ /**
412
+ * True when a captured `--json` token is a shell redirection (e.g.
413
+ * `2>/dev/null`), not a file path — the documented Qodo poll pipes
414
+ * `--json 2>/dev/null`, and treating the redirect as a path makes the read
415
+ * fail and lose the PR identity (Qodo beb11c94).
416
+ */
417
+ function isRedirectionToken(token: string): boolean {
418
+ return /^\d*>>?\S/.test(token) || token.startsWith(">") || token.includes("/dev/null");
419
+ }
420
+
421
+ /**
422
+ * PR number of the review an address or store-session command targeted —
423
+ * ONLY from the command's explicit inputs (the address `--json` file or the
424
+ * store-session session JSON's `pr_url`).
425
+ *
426
+ * Order and failure semantics (Qodo beb11c94/86f1855b):
427
+ * - A readable address `--json` file wins (partial runs keep it on disk).
428
+ * - An unreadable/invalid `--json` token (redirection capture, deleted file
429
+ * on the final storing run) FALLS THROUGH to the store-session path
430
+ * instead of failing the whole lookup — a combined poll+store command
431
+ * still resolves its PR from the session JSON.
432
+ * - An unreadable store-session JSON is logged (warn) — the caller decides
433
+ * not to fall back to the repository's newest review, which would distill
434
+ * a DIFFERENT PR's findings.
435
+ * - No explicit input at all → null — callers then select the newest review
436
+ * for the repo (final storing runs), never a different explicit PR.
340
437
  */
341
438
  export function prNumberFromCommand(command: string | undefined, cwd: string): number | null {
342
- try {
343
- const m = /(?:^|\s)--json(?:\s+|=)(\S+)/.exec(command ?? "");
344
- if (!m) return null;
345
- const pr = Number(JSON.parse(readFileSync(resolve(cwd, m[1]!), "utf-8"))?.metadata?.pr_number);
346
- return Number.isInteger(pr) && pr > 0 ? pr : null;
347
- } catch {
348
- return null;
439
+ const cmd = command ?? "";
440
+ const m = /(?:^|\s)--json(?:\s+|=)(\S+)/.exec(cmd);
441
+ if (m && !isRedirectionToken(m[1]!)) {
442
+ try {
443
+ const pr = Number(JSON.parse(readFileSync(resolve(cwd, expandEnvVars(m[1]!)), "utf-8"))?.metadata?.pr_number);
444
+ if (Number.isInteger(pr) && pr > 0) return pr;
445
+ log.debug("pr_from_address_json_invalid", { token: m[1]!.slice(0, 120) });
446
+ } catch (error: any) {
447
+ // Deleted file (final storing run) or bad token — fall through to the
448
+ // store-session path; a null here would trigger the repo-newest
449
+ // fallback and could distill another PR.
450
+ log.debug("pr_from_address_json_unreadable", {
451
+ token: m[1]!.slice(0, 120),
452
+ reason: String(error?.message ?? error).slice(0, 120),
453
+ });
454
+ }
455
+ }
456
+ // `reviews store-session <path>`: the session JSON identifies its PR via
457
+ // pr_url (issue #944) — the positional path replaces address's --json.
458
+ const s = REVIEWS_STORE_SESSION_PATH_RE.exec(cmd);
459
+ if (s) {
460
+ try {
461
+ const prUrl: unknown = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(s[1]!)), "utf-8"))?.pr_url;
462
+ if (typeof prUrl === "string") {
463
+ const pr = Number(/\/pull\/(\d+)/.exec(prUrl)?.[1]);
464
+ if (Number.isInteger(pr) && pr > 0) return pr;
465
+ log.debug("pr_from_session_json_invalid", { prUrl: prUrl.slice(0, 120) });
466
+ } else {
467
+ log.debug("pr_from_session_json_no_pr_url", { path: s[1]!.slice(0, 200) });
468
+ }
469
+ } catch (error: any) {
470
+ // The session JSON is never deleted by the loop — unreadable means a
471
+ // real error (bad path, race). Loud, because the caller must NOT fall
472
+ // back to the newest review: that would distill another PR's findings
473
+ // (Qodo 86f1855b).
474
+ log.warn("pr_from_session_json_unreadable", {
475
+ path: s[1]!.slice(0, 200),
476
+ reason: String(error?.message ?? error).slice(0, 200),
477
+ });
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+
483
+ /**
484
+ * The FULL review target a command names — owner/repo/prNumber from the
485
+ * address `--json` metadata or the store-session pr_url — or null when the
486
+ * command carries no readable target. Used by distill() to query the repo
487
+ * the rows were actually STORED under: a fork-hosted session stores rows
488
+ * under the pr_url's upstream repo while origin names the fork, and a
489
+ * PR-number-only lookup against origin would find nothing (Qodo befc495a).
490
+ */
491
+ export function prTargetFromCommand(
492
+ command: string | undefined,
493
+ cwd: string,
494
+ ): { owner: string; repo: string; prNumber: number } | null {
495
+ const cmd = command ?? "";
496
+ const m = /(?:^|\s)--json(?:\s+|=)(\S+)/.exec(cmd);
497
+ if (m && !isRedirectionToken(m[1]!)) {
498
+ try {
499
+ const meta = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(m[1]!)), "utf-8"))?.metadata;
500
+ const owner = typeof meta?.owner === "string" ? meta.owner : null;
501
+ const repo = typeof meta?.repo === "string" ? meta.repo : null;
502
+ const pr = Number(meta?.pr_number);
503
+ if (owner && repo && Number.isInteger(pr) && pr > 0) return { owner, repo, prNumber: pr };
504
+ } catch {
505
+ // unreadable --json — try the store-session path
506
+ }
507
+ }
508
+ const s = REVIEWS_STORE_SESSION_PATH_RE.exec(cmd);
509
+ if (s) {
510
+ try {
511
+ const prUrl: unknown = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(s[1]!)), "utf-8"))?.pr_url;
512
+ if (typeof prUrl === "string") {
513
+ const parts = PR_URL_PARTS_RE.exec(prUrl.trim());
514
+ if (parts) {
515
+ const pr = Number(parts[3]);
516
+ if (Number.isInteger(pr) && pr > 0) return { owner: parts[1]!, repo: parts[2]!, prNumber: pr };
517
+ }
518
+ }
519
+ } catch {
520
+ // unreadable session JSON — prNumberFromCommand already warned
521
+ }
349
522
  }
523
+ return null;
350
524
  }
351
525
 
352
526
  // ── Distillation (script-only engine) ──────────────────────────────────────
@@ -469,8 +643,21 @@ function findingIdentity(f: AddressedFinding): string | null {
469
643
  * provenance lookups can distinguish new occurrences from re-fires. The
470
644
  * dedupe key is the UNTRUNCATED identity (two findings whose titles first
471
645
  * differ past the 100-char display cap stay distinct lessons).
646
+ *
647
+ * `completedTexts` (lesson texts already written for this review, from the
648
+ * persisted reviewLessons state) are excluded BEFORE the cap — a review with
649
+ * more findings than the per-fire cap distills its remaining lessons on the
650
+ * NEXT fire instead of re-picking the same top slice forever (Qodo
651
+ * 16d84431): session loops re-fire store-session every round, so the walk
652
+ * completes across rounds. It also makes findings APPENDED to an
653
+ * already-processed review row distill: their lessons are not in the
654
+ * completed set, so they surface as pending work.
472
655
  */
473
- export function distillLessons(findings: AddressedFinding[]): DistilledLesson[] {
656
+ export function distillLessons(
657
+ findings: AddressedFinding[],
658
+ completedTexts: Set<string> = new Set(),
659
+ cap: number = MAX_FINDINGS_PER_FIRE,
660
+ ): DistilledLesson[] {
474
661
  const byIdentity = new Map<string, DistilledLesson>();
475
662
  for (const finding of rankFindings(findings)) {
476
663
  const text = lessonFromFinding(finding);
@@ -480,7 +667,10 @@ export function distillLessons(findings: AddressedFinding[]): DistilledLesson[]
480
667
  if (existing) existing.findingIds.push(finding.id); // merged duplicate keeps its identity
481
668
  else byIdentity.set(key, { findingIds: [finding.id], text });
482
669
  }
483
- return [...byIdentity.values()].slice(0, MAX_FINDINGS_PER_FIRE);
670
+ const pool = completedTexts.size > 0
671
+ ? [...byIdentity.values()].filter((lesson) => !completedTexts.has(lesson.text))
672
+ : [...byIdentity.values()];
673
+ return pool.slice(0, Math.max(0, cap));
484
674
  }
485
675
 
486
676
  // ── Memory write with provenance dedupe ────────────────────────────────────
@@ -931,19 +1121,22 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
931
1121
  };
932
1122
 
933
1123
  /**
934
- * Full distillation pipeline for one address fire. `command` identifies the
935
- * addressed PR (explicit `--json` file → metadata.pr_number — no newest-JSON
936
- * fallback, which could target a leftover file from another PR after the
937
- * final run deletes its own). Selection is PR-SCOPED when the PR is known:
938
- * an explicit PR with no stored review, or one already distilled, is a
939
- * no-op — a partial re-run must never distill a review belonging to a
940
- * DIFFERENT PR. Repo-wide newest selection happens ONLY when no explicit
941
- * target exists (prNumber null), which is exactly the final, storing run
942
- * whose JSON was just deleted — the just-stored row. A review whose lesson
943
- * writes were REJECTED stays retryable, but only its UNWRITTEN lessons are
944
- * re-attempted (completed lessons are persisted per review — re-processing
945
- * them would inflate evidence without a new occurrence). Settings-gated,
946
- * fail-soft.
1124
+ * Full distillation pipeline for one address or store-session fire.
1125
+ * `command` identifies the addressed PR (address `--json` file →
1126
+ * metadata.pr_number, or the store-session JSON's pr_url — no
1127
+ * newest-JSON fallback, which could target a leftover file from another
1128
+ * PR after the final run deletes its own). Selection is PR-SCOPED when
1129
+ * the PR is known: ALL review rows of that PR are processed — session
1130
+ * loops store one row per commit, so earlier groups and findings appended
1131
+ * to already-processed rows must distill too (Qodo 16d84431). A store-
1132
+ * session command whose PR cannot be resolved is a NO-OP (never the
1133
+ * repo-newest fallback — that would distill another PR, Qodo 86f1855b).
1134
+ * Repo-wide newest selection happens ONLY when no explicit target exists
1135
+ * (prNumber null — the final storing run whose JSON was just deleted).
1136
+ * A review whose lesson writes were REJECTED stays retryable, but only its
1137
+ * UNWRITTEN lessons are re-attempted (completed lessons are persisted per
1138
+ * review — re-processing them would inflate evidence without a new
1139
+ * occurrence). Settings-gated, fail-soft.
947
1140
  */
948
1141
  const distill = async (cwd: string, command?: string): Promise<void> => {
949
1142
  if (!getSetting(cwd, "review_learning")) {
@@ -959,88 +1152,171 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
959
1152
  const dbPath = join(dataDir, "reviews.db");
960
1153
  const distilled = loadDistilledReviews(dataDir);
961
1154
  const prNumber = prNumberFromCommand(command, cwd);
962
- let review: ReviewRow | null = null;
1155
+ if (prNumber === null && command !== undefined && isReviewsStoreSessionCommand(command)) {
1156
+ // The session JSON is the loop's own record and is never deleted — an
1157
+ // unresolved PR means the read failed; falling back to the newest
1158
+ // review would distill a DIFFERENT PR's findings.
1159
+ log.warn("skipped_store_session_pr_unresolved", {
1160
+ cwd,
1161
+ command: String(command).slice(0, 120),
1162
+ });
1163
+ return;
1164
+ }
1165
+ let reviews: ReviewRow[];
1166
+ /** The repo the matched rows are stored under — lesson provenance must
1167
+ * name it, not the checkout origin: a fork session stores rows (and
1168
+ * recalls them) under the pr_url repo, and provenance keyed to the
1169
+ * fork would never match at write time (Qodo 66cc6bfe). */
1170
+ let rowsRepo: RepoInfo = repo;
963
1171
  if (prNumber !== null) {
964
- // Explicit target: PR-scoped only — never fall back to another PR.
965
- review = getLatestReview(dbPath, repo.owner, repo.repo, prNumber);
966
- if (!review) {
967
- log.debug("skipped_no_review_row", { owner: repo.owner, repo: repo.repo, prNumber });
968
- return;
1172
+ // Explicit target: every review row of the PR, oldest first. The
1173
+ // command-named repo (address --json metadata or session pr_url) is
1174
+ // AUTHORITATIVE — rows are always stored under the repo the fetch/
1175
+ // store ran as, so a target with no rows is a no-op, NEVER a fall
1176
+ // back to origin: an unrelated same-numbered PR there would distill
1177
+ // the wrong repository's findings (Qodo 1c8fc091). A fork checkout
1178
+ // (origin = fork, rows under the upstream pr_url repo) still finds
1179
+ // its rows through the target.
1180
+ const target = prTargetFromCommand(command, cwd);
1181
+ if (target) {
1182
+ rowsRepo = { owner: target.owner, repo: target.repo };
1183
+ reviews = getReviewsForPr(dbPath, target.owner, target.repo, prNumber);
1184
+ } else {
1185
+ reviews = getReviewsForPr(dbPath, repo.owner, repo.repo, prNumber);
969
1186
  }
970
- if (distilled.has(review.id)) {
971
- log.debug("skipped_review_already_distilled", { reviewId: review.id, prNumber: review.prNumber });
1187
+ if (reviews.length === 0) {
1188
+ log.debug("skipped_no_review_row", {
1189
+ owner: rowsRepo.owner, repo: rowsRepo.repo, prNumber,
1190
+ origin: `${repo.owner}/${repo.repo}`,
1191
+ });
972
1192
  return;
973
1193
  }
974
1194
  } else {
975
1195
  // No explicit target (final storing run deleted its JSON): the newest
976
- // not-yet-distilled review for the repo is exactly the just-stored row.
977
- review = getLatestReview(dbPath, repo.owner, repo.repo);
978
- if (!review) {
1196
+ // review for the repo is exactly the just-stored row. Already
1197
+ // distilled → no-op (nothing can have been appended without a PR).
1198
+ const latest = getLatestReview(dbPath, repo.owner, repo.repo);
1199
+ if (!latest) {
979
1200
  log.debug("skipped_no_review_row", { owner: repo.owner, repo: repo.repo, prNumber: null });
980
1201
  return;
981
1202
  }
982
- if (distilled.has(review.id)) {
983
- log.debug("skipped_review_already_distilled", { reviewId: review.id, prNumber: review.prNumber });
1203
+ if (distilled.has(latest.id)) {
1204
+ log.debug("skipped_review_already_distilled", { reviewId: latest.id, prNumber: latest.prNumber });
984
1205
  return;
985
1206
  }
1207
+ reviews = [latest];
986
1208
  }
987
- // Per-review serialization: overlapping address fires (two bash results
988
- // in one turn, a retried command) would otherwise both read the
989
- // completed-lesson state before either async write finishes and
990
- // double-write the same lessons — inflating counts and evidence. The
991
- // second fire waits for the first, then re-reads state (the first may
992
- // have just completed the review) before doing any work.
1209
+ // Per-FIRE write budget across ALL reviews of the PR (Qodo 062e8e28):
1210
+ // the docs' "~5 lessons per fire" cap is per distill() call, not per
1211
+ // review row — a multi-round PR must not dump 5×N lessons into memory
1212
+ // at once. Remaining lessons distill on the next fire (completed-aware
1213
+ // lesson selection walks forward).
1214
+ let budget = MAX_MEMORY_WRITES_PER_FIRE;
1215
+ let writes = 0;
1216
+ let remainingLessons = 0;
1217
+ for (const review of reviews) {
1218
+ if (budget <= 0) {
1219
+ log.debug("distill_budget_exhausted", { reviewId: review.id, prNumber: review.prNumber });
1220
+ remainingLessons += 1; // at least this review still has pending work
1221
+ continue;
1222
+ }
1223
+ const result = await distillWithLock(cwd, review, dataDir, dbPath, rowsRepo, budget);
1224
+ budget -= result.consumed;
1225
+ writes += result.writes;
1226
+ remainingLessons += result.remaining;
1227
+ }
1228
+ if (remainingLessons > 0 && writes > 0) {
1229
+ // Lessons remain and this fire made progress — schedule the next
1230
+ // burst so a session's FINAL store still drains every lesson (no
1231
+ // later trigger exists after convergence, Qodo eceb273e). Progress
1232
+ // gating (writes > 0) prevents an infinite loop when every remaining
1233
+ // lesson is persistently rejected.
1234
+ log.debug("distill_continuation_scheduled", { remainingLessons, writes });
1235
+ setImmediate(() => {
1236
+ void distill(cwd, command).catch((error) =>
1237
+ log.warn("distill_unexpected_failure", { reason: String((error as any)?.message ?? error).slice(0, 300) }),
1238
+ );
1239
+ });
1240
+ }
1241
+ };
1242
+
1243
+ /**
1244
+ * Per-review serialization: overlapping address fires (two bash results in
1245
+ * one turn, a retried command) would otherwise both read the completed-
1246
+ * lesson state before either async write finishes and double-write the
1247
+ * same lessons — inflating counts and evidence. The second fire waits for
1248
+ * the first, then re-reads state (the first may have just completed the
1249
+ * review) before doing any work. Returns the number of memory writes
1250
+ * consumed (bounded by `budget` — the per-fire cap is shared across all
1251
+ * reviews of the PR, Qodo 062e8e28).
1252
+ */
1253
+ const distillWithLock = async (
1254
+ cwd: string,
1255
+ review: ReviewRow,
1256
+ dataDir: string,
1257
+ dbPath: string,
1258
+ repo: RepoInfo,
1259
+ budget: number,
1260
+ ): Promise<{ consumed: number; writes: number; remaining: number }> => {
993
1261
  const previous = distillLocks.get(review.id) ?? Promise.resolve();
994
1262
  let release!: () => void;
995
1263
  const current = new Promise<void>((resolve) => (release = resolve));
996
1264
  distillLocks.set(review.id, current);
997
1265
  try {
998
1266
  await previous;
999
- if (loadDistilledReviews(dataDir).has(review.id)) {
1000
- log.debug("skipped_review_already_distilled", { reviewId: review.id, prNumber: review.prNumber, reason: "completed by a concurrent fire" });
1001
- return;
1002
- }
1003
- await distillLockedReview(cwd, review, dataDir, dbPath, repo);
1267
+ return await distillLockedReview(cwd, review, dataDir, dbPath, repo, budget);
1004
1268
  } finally {
1005
1269
  release();
1006
1270
  if (distillLocks.get(review.id) === current) distillLocks.delete(review.id);
1007
1271
  }
1008
1272
  };
1009
1273
 
1010
- /** The distill pipeline body — runs holding the per-review lock. */
1274
+ /**
1275
+ * The distill pipeline body — runs holding the per-review lock. Budget is
1276
+ * the fire's remaining write allowance; REJECTED lessons do not consume
1277
+ * it (a persistently rejected lesson must not starve the PR's later
1278
+ * reviews, Qodo 850bb32d — it is re-attempted on later fires for free).
1279
+ * A review whose lessons were cut short by the budget is not marked
1280
+ * distilled-error: the completed-aware lesson walk picks up its remainder
1281
+ * on the next fire.
1282
+ */
1011
1283
  const distillLockedReview = async (
1012
1284
  cwd: string,
1013
1285
  review: ReviewRow,
1014
1286
  dataDir: string,
1015
1287
  dbPath: string,
1016
1288
  repo: RepoInfo,
1017
- ): Promise<void> => {
1289
+ budget: number,
1290
+ ): Promise<{ consumed: number; writes: number; remaining: number }> => {
1018
1291
  const findings = getAddressedComments(dbPath, review.id);
1019
1292
  if (findings.length === 0) {
1020
1293
  log.debug("skipped_no_addressed_findings", { reviewId: review.id });
1021
- return;
1294
+ return { consumed: 0, writes: 0, remaining: 0 };
1022
1295
  }
1023
1296
  log.info("distill_start", {
1024
1297
  owner: repo.owner, repo: repo.repo, prNumber: review.prNumber, reviewId: review.id, findings: findings.length,
1025
1298
  });
1026
- const lessons = distillLessons(findings);
1299
+ // Per-lesson retry bookkeeping BEFORE the cap: lessons already written
1300
+ // for this review are excluded upstream of MAX_FINDINGS_PER_FIRE, so a
1301
+ // review with more findings than one fire's cap distills its remaining
1302
+ // lessons on the next fire (Qodo 16d84431).
1303
+ const completed = loadReviewLessons(dataDir).get(review.id) ?? new Set<string>();
1304
+ const lessons = distillLessons(findings, completed);
1027
1305
  if (lessons.length === 0) {
1028
1306
  log.info("distill_finish", { findings: findings.length, lessons: 0, reason: "no lessons distilled" });
1029
1307
  markReviewDistilled(dataDir, review.id);
1030
- return;
1308
+ return { consumed: 0, writes: 0, remaining: 0 };
1031
1309
  }
1032
- // Per-lesson retry bookkeeping: only lessons not yet successfully written.
1033
- const completed = loadReviewLessons(dataDir).get(review.id) ?? new Set<string>();
1034
- const pending = lessons.filter((l) => !completed.has(l.text));
1310
+ const pending = lessons.slice(0, Math.max(0, budget));
1035
1311
  if (pending.length === 0) {
1036
- // Everything already written on an earlier fire (rejected then resolved
1037
- // elsewhere) — the review is complete.
1038
- markReviewDistilled(dataDir, review.id);
1039
- log.info("distill_finish", { reviewId: review.id, findings: findings.length, lessons: lessons.length, reason: "all lessons already written" });
1040
- return;
1312
+ log.debug("distill_budget_exhausted_before_review", { reviewId: review.id });
1313
+ return { consumed: 0, writes: 0, remaining: 1 };
1041
1314
  }
1042
1315
  const written = await writeLessons(cwd, pending, repo);
1043
1316
  if (written.completed.length > 0) recordReviewLessons(dataDir, review.id, written.completed);
1317
+ // Only actual memory writes consume the budget — rejected lessons are
1318
+ // re-attempted on later fires without starving anyone (Qodo 850bb32d).
1319
+ const consumed = written.added + written.reinforced + written.skipped;
1044
1320
  if (written.rejected > 0) {
1045
1321
  // Rejected writes are retryable, not complete — the review stays
1046
1322
  // unmarked; the next fire re-attempts ONLY the still-unwritten lessons.
@@ -1050,9 +1326,16 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
1050
1326
  completed: written.completed.length,
1051
1327
  pending: pending.length,
1052
1328
  });
1053
- return;
1054
1329
  }
1055
- markReviewDistilled(dataDir, review.id);
1330
+ // The review is marked distilled only when every distinct lesson was
1331
+ // completed; a review cut short by the budget (or with rejected
1332
+ // lessons) stays unmarked — the next fire re-runs it (completed-aware)
1333
+ // and writes the remainder.
1334
+ const totalLessons = distillLessons(findings, new Set(), Number.MAX_SAFE_INTEGER).length;
1335
+ const doneAfter = completed.size + written.completed.length;
1336
+ if (doneAfter >= totalLessons) {
1337
+ markReviewDistilled(dataDir, review.id);
1338
+ }
1056
1339
  log.info("distill_finish", {
1057
1340
  reviewId: review.id,
1058
1341
  findings: findings.length,
@@ -1061,7 +1344,9 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
1061
1344
  reinforced: written.reinforced,
1062
1345
  rejected: written.rejected,
1063
1346
  skipped: written.skipped,
1347
+ budgetRemaining: budget - consumed,
1064
1348
  });
1349
+ return { consumed, writes: consumed, remaining: doneAfter >= totalLessons ? 0 : 1 };
1065
1350
  };
1066
1351
 
1067
1352
  const register = (pi: ExtensionAPI): void => {
@@ -1089,8 +1374,8 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
1089
1374
  const tool = String(event?.toolName ?? "").toLowerCase();
1090
1375
  if (tool !== "bash") return;
1091
1376
  const command = event?.input?.command;
1092
- if (!isReviewsAddressCommand(command)) return;
1093
- log.debug("address_detected", { cwd: ctx?.cwd });
1377
+ if (!isReviewsAddressCommand(command) && !isReviewsStoreSessionCommand(command)) return;
1378
+ log.debug("address_detected", { cwd: ctx?.cwd, command: String(command).slice(0, 120) });
1094
1379
  // Fire-and-forget, deferred past the handler's synchronous frame —
1095
1380
  // the tool result must not wait for git exec, SQLite queries, or the
1096
1381
  // provenance lookup (all sync I/O inside `distill` before its first
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pidiff-ui",
3
3
  "private": true,
4
- "version": "4.15.0",
4
+ "version": "4.16.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.15.0",
3
+ "version": "4.16.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -373,9 +373,12 @@ If any approved tasks weren't implemented, review with user.
373
373
 
374
374
  ### Phase 5: Prepare Reply Mappings
375
375
 
376
- 🚨 **NEVER hand-edit the reviews JSON.** No python heredocs, no `jq`, no manual
377
- `status`/`reply` field mutation. The `myk-pi-tools reviews address` command applies
376
+ 🚨 **NEVER hand-edit a reviews JSON's `status`/`reply` fields.** No python heredocs, no `jq`, no manual
377
+ `status`/`reply` mutation — those bypass posting and the database store. The
378
+ `myk-pi-tools reviews address` command applies
378
379
  replies, flips statuses, posts, and stores in one step — you only prepare mapping files.
380
+ (The single exception: narrowing `line`/`end_line` for span disambiguation — see
381
+ span semantics below; that affects matching only, never posting.)
379
382
 
380
383
  Write one mapping file per target status. Each line targets one finding:
381
384
 
@@ -386,11 +389,48 @@ Write one mapping file per target status. Each line targets one finding:
386
389
  - `<path>:<line>` — the finding's fetched `path` and `line`. A thread finding and its
387
390
  paired sticky finding are addressed together automatically (the command handles
388
391
  dedupe and pairing — you never target stickies separately).
392
+ - `<path>:<line>~<fragment>` — **discriminator**: additionally requires `<fragment>`
393
+ (case-insensitive) in the finding's body — the way to target one of two findings
394
+ whose spans overlap at the same line.
395
+ - `<path>:*` — per-file wildcard: same reply for EVERY finding in that file.
389
396
  - `*` — matches ALL pending findings (single-line mapping file).
390
397
  - Group findings by their target status — Phase 8 runs one `reviews address` call per
391
398
  status group.
392
399
  - Posting happens in Phase 8, AFTER commit/push — do not post before the fixes are pushed.
393
400
 
401
+ **How a rule matches a finding (span semantics — issue #945):**
402
+
403
+ - A specific rule matches the finding's FULL `[line, end_line]` span — any line in
404
+ the span reaches the finding.
405
+ - When one finding's span CONTAINS another finding's span (nested findings, same
406
+ file), the containing finding matches only its own reported `line` — so a rule at
407
+ any inner line reaches the inner finding alone, and a rule at the outer finding's
408
+ reported line reaches the outer one alone. Equal spans: both findings match only
409
+ their own reported lines.
410
+ - A rule matching 2+ distinct findings is rejected ATOMICALLY — nothing is posted.
411
+ Two-pass addressing (reply to one, then the other) cannot work: ALL rules are
412
+ validated before anything posts. Disambiguate with `~<fragment>`, use
413
+ `<path>:*` / `*` when the same reply fits all, or narrow `line`/`end_line` in the
414
+ reviews JSON (the ONE sanctioned JSON edit — it affects matching only, never
415
+ posting).
416
+ - Sticky records (untitled duplicates of findings) participate in matching through
417
+ their cluster — you never target them separately, and a rule reaching the thread
418
+ reaches its sticky.
419
+
420
+ **Mapping cheat sheet (use this — never read the address source code):**
421
+
422
+ ```text
423
+ src/a.py:10|Fixed in abc1234 # one finding (any line in its span)
424
+ src/a.py:10~backoff|Fixed the backoff in abc1234 # ONE of two findings overlapping at 10
425
+ src/a.py:*|Same reply for every finding in this file
426
+ *|All findings fixed in def5678 # every pending finding
427
+ ```
428
+
429
+ When `reviews address` rejects a rule as ambiguous, re-read the finding bodies in
430
+ the reviews JSON, pick a unique word from the target finding's title, and re-run
431
+ with `<path>:<line>~<word>|<reply>`. Do NOT run the command twice to reach two
432
+ findings at one location — the second run would fail validation the same way.
433
+
394
434
  **Valid status values (the `--set` flag for each mapping group):**
395
435
 
396
436
  | Status | Behavior |
@@ -705,3 +745,52 @@ Each cycle displays a status update so the user knows the loop is active:
705
745
  [auto] CodeRabbit rate-limited. Handling automatically via reviews poll --source coderabbit...
706
746
  [auto] 🎉 {source} approved PR #{pr_number}. Displaying remaining findings summary.
707
747
  ```
748
+
749
+ ## Qodo Review-Session Loops (Storage — MANDATORY)
750
+
751
+ PRs reviewed through the Qodo `pr-review-session` CLI (poll
752
+ `qodo read pr-review-session findings --pr-url <url> --json`, fix,
753
+ `qodo pr-review-session mark-implemented`) bypass GitHub review comments
754
+ until convergence, so `reviews fetch` sees nothing — every thread is
755
+ resolved by the time the loop converges, and the fetch default excludes
756
+ resolved threads. The reviews database therefore never hears about these
757
+ reviews unless you store them explicitly.
758
+
759
+ **When a pr-review-session loop runs, every completed round follows this
760
+ EXACT order — the store step runs AFTER the round's findings are resolved
761
+ (mark-implemented / dismissed), on a FRESH fetch, so stored statuses are
762
+ final, not poll-time snapshots:**
763
+
764
+ ```bash
765
+ # 1. Poll — the loop's record (kept on disk, never deleted)
766
+ qodo read pr-review-session findings --pr-url <pr-url> --json 2>/dev/null | head -1 \
767
+ > ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
768
+
769
+ # 2. Fix the round's findings, then resolve them:
770
+ qodo pr-review-session mark-implemented --finding-ids <ids> --explanation "..." --json
771
+
772
+ # 3. Store on a FRESH fetch — statuses now reflect the resolution. The
773
+ # store is idempotent (per finding UUID) and REFRESHES the attribution
774
+ # on already-stored rows, so re-storing earlier rounds self-heals them.
775
+ qodo read pr-review-session findings --pr-url <pr-url> --json 2>/dev/null | head -1 \
776
+ > ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
777
+ myk-pi-tools reviews store-session ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
778
+ ```
779
+
780
+ NEVER store the poll-time snapshot as the round's final state — findings
781
+ saved before mark-implemented carry pending attribution, and the status
782
+ report shows stale pendings. If a round was stored pre-resolution (e.g. a
783
+ crash between steps 2 and 3), the NEXT round's step-3 store refreshes it;
784
+ at convergence, run step 3 once more after the final mark-implemented.
785
+
786
+ The command converts the session findings JSON into reviews DB rows
787
+ (source `qodo`, status from each finding's `attribution_status`, grouped
788
+ by the commit the finding was raised on). It is idempotent — re-running
789
+ skips findings already stored (per session UUID) — and it does NOT delete
790
+ the JSON, so storing every round is safe and `reviews status --pr <n>`
791
+ stays current while the loop runs.
792
+
793
+ A clean convergence round is stored the same way — it is just the last
794
+ round. Without this step, `reviews status --pr <n>` renders an empty HTML
795
+ report for the PR mid-loop and after convergence, and the review analytics
796
+ lose the whole loop (issue #942).