immune-brain 3.6.8 → 4.0.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.
Files changed (82) hide show
  1. package/README.md +11 -4
  2. package/README.zh-CN.md +10 -3
  3. package/package.json +3 -2
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  10. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  11. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
  12. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  13. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  14. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  15. package/plugins/immune-brain/dist/imm-planner.md +53 -32
  16. package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  20. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  21. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  22. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  23. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  24. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  25. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  26. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  27. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  28. package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
  29. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  30. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  31. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  32. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  33. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  34. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  35. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  36. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  37. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  38. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  39. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  40. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  41. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  42. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  43. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  44. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  45. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  46. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  47. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  48. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  49. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  51. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  52. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  53. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  54. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  55. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  56. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  57. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  58. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  59. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  60. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  62. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  63. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  66. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  67. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  68. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  69. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  70. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  71. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  72. package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
  73. package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
  74. package/plugins/immune-brain/skills/registry.yaml +9 -0
  75. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  76. package/plugins/immune-brain/bin/imm-retired +0 -4
  77. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  78. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  79. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  80. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  81. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  82. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -3,11 +3,13 @@ import { createHash } from "node:crypto";
3
3
  import {
4
4
  existsSync,
5
5
  lstatSync,
6
+ mkdirSync,
6
7
  readFileSync,
7
8
  readlinkSync,
8
9
  realpathSync,
10
+ writeFileSync,
9
11
  } from "node:fs";
10
- import { resolve } from "node:path";
12
+ import { dirname, join, resolve } from "node:path";
11
13
 
12
14
  export interface GitWorkspaceSnapshot {
13
15
  kind: "git-workspace-v1";
@@ -34,6 +36,75 @@ function comparePaths(left: string, right: string): number {
34
36
  return 0;
35
37
  }
36
38
 
39
+ function isDeliveryAttachment(path: string): boolean {
40
+ return path === ".imm/audit" || path.startsWith(".imm/audit/");
41
+ }
42
+
43
+ function isNonDeliveryPath(path: string): boolean {
44
+ return isDeliveryAttachment(path) || isRuntimeAuthorityPath(path);
45
+ }
46
+
47
+ function isOwnPlanningSidecar(path: string, taskId?: string): boolean {
48
+ return Boolean(taskId) && (path === `docs/plans/${taskId}.intent.json` || path === `docs/plans/archive/${taskId}.intent.json`);
49
+ }
50
+
51
+ function isOtherTaskSidecar(path: string, taskId?: string): boolean {
52
+ return /^docs\/plans\/(?:archive\/)?[^/]+\.intent\.json$/.test(path) && !isOwnPlanningSidecar(path, taskId);
53
+ }
54
+
55
+ function isPlanningNoise(path: string, taskId?: string): boolean {
56
+ return path.startsWith("docs/plans/") && !isOtherTaskSidecar(path, taskId) && !isOwnPlanningSidecar(path, taskId);
57
+ }
58
+
59
+ function enrollmentBaselinePath(root: string): string {
60
+ return join(root, ".imm/state/enrollment-baseline.json");
61
+ }
62
+
63
+ export function writeEnrollmentBaseline(root: string): void {
64
+ const snapshot = captureGitWorkspaceSnapshot(root);
65
+ if (!snapshot) return;
66
+ const path = enrollmentBaselinePath(root);
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ writeFileSync(path, `${JSON.stringify(snapshot)}\n`);
69
+ }
70
+
71
+ function readEnrollmentBaseline(root: string): GitWorkspaceSnapshot | null {
72
+ const baselinePath = enrollmentBaselinePath(root);
73
+ if (!existsSync(baselinePath)) return null;
74
+ try {
75
+ const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as GitWorkspaceSnapshot;
76
+ return isGitWorkspaceSnapshot(baseline) ? baseline : null;
77
+ } catch {
78
+ throw new Error("enrollment baseline is unreadable");
79
+ }
80
+ }
81
+
82
+ function assertNoEnvelopeEscape(
83
+ root: string,
84
+ stagedPaths: readonly string[],
85
+ scope: string[],
86
+ taskId?: string,
87
+ ): void {
88
+ const baseline = readEnrollmentBaseline(root);
89
+ const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
90
+ if (baseline && !current) throw new Error("enrollment baseline cannot be compared because Git is unavailable");
91
+ const escaped = [...new Set(stagedPaths)]
92
+ .filter((path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && !isPlanningNoise(path, taskId) && !taskPathMatchesScope(path, scope))
93
+ .filter((path) => !baseline || baseline.dirty_files[path] !== current?.dirty_files[path])
94
+ .sort(comparePaths);
95
+ if (escaped.length > 0)
96
+ throw new Error(`task delivery contains paths outside the authorization envelope: ${escaped.join(", ")}`);
97
+ if (!baseline || !current) return;
98
+ const staged = new Set(stagedPaths);
99
+ const mixed = [...new Set([...Object.keys(baseline.dirty_files), ...Object.keys(current.dirty_files)])]
100
+ .filter((path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && !isPlanningNoise(path, taskId) && !taskPathMatchesScope(path, scope))
101
+ .filter((path) => !isOtherTaskSidecar(path, taskId) || staged.has(path))
102
+ .filter((path) => baseline.dirty_files[path] !== current.dirty_files[path])
103
+ .sort(comparePaths);
104
+ if (mixed.length > 0)
105
+ throw new Error(`task delivery contains paths outside the authorization envelope: ${mixed.join(", ")}`);
106
+ }
107
+
37
108
  function isRuntimeAuthorityPath(path: string): boolean {
38
109
  return (
39
110
  // Kernel v2 task state, workspace coordination, and authority journals are
@@ -315,7 +386,7 @@ function taskPathMatchesScope(path: string, scope: string[]): boolean {
315
386
  return scope.some((scopePath) => pathMatchesScope(path, scopePath));
316
387
  }
317
388
 
318
- function taskSnapshotOnce(root: string, scope: string[]): GitTaskSnapshot {
389
+ function taskSnapshotOnce(root: string, scope: string[], taskId?: string): GitTaskSnapshot {
319
390
  const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
320
391
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
321
392
  if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
@@ -343,14 +414,18 @@ function taskSnapshotOnce(root: string, scope: string[]): GitTaskSnapshot {
343
414
  "untracked task paths",
344
415
  );
345
416
  assertNoCaseFoldCollisions([...stagedPaths, ...unstagedPaths, ...untrackedPaths], "Git task paths");
417
+ assertNoEnvelopeEscape(root, stagedPaths, scope, taskId);
346
418
  const uncommittedInScope = [...new Set([...unstagedPaths, ...untrackedPaths])]
347
419
  .filter((path) => taskPathMatchesScope(path, scope))
348
420
  .sort(comparePaths);
349
421
  if (uncommittedInScope.length > 0)
350
422
  throw new Error(`task scope contains unstaged or untracked changes: ${uncommittedInScope.join(", ")}`);
351
423
 
424
+ const baseline = readEnrollmentBaseline(root);
425
+ const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
352
426
  const taskPaths = [...new Set(stagedPaths)]
353
- .filter((path) => taskPathMatchesScope(path, scope))
427
+ .filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
428
+ .filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined))
354
429
  .sort(comparePaths);
355
430
  const stagedFiles: Record<string, GitTaskIndexEntry> = {};
356
431
  for (const path of taskPaths) {
@@ -377,6 +452,7 @@ function taskSnapshotOnce(root: string, scope: string[]): GitTaskSnapshot {
377
452
  export function captureGitTaskSnapshot(
378
453
  projectRoot: string,
379
454
  scopeHint: unknown,
455
+ taskId?: string,
380
456
  ): GitTaskSnapshot {
381
457
  const requestedRoot = resolve(projectRoot);
382
458
  const requestedStat = lstatSync(requestedRoot);
@@ -384,9 +460,9 @@ export function captureGitTaskSnapshot(
384
460
  throw new Error("task snapshot root must be a real directory");
385
461
  const root = realpathSync(requestedRoot);
386
462
  const scope = assertCanonicalTaskScope(scopeHint);
387
- const before = taskSnapshotOnce(root, scope);
463
+ const before = taskSnapshotOnce(root, scope, taskId);
388
464
  gitTaskSnapshotTestHook?.();
389
- const after = taskSnapshotOnce(root, scope);
465
+ const after = taskSnapshotOnce(root, scope, taskId);
390
466
  if (JSON.stringify(after) !== JSON.stringify(before))
391
467
  throw new Error("Git task snapshot changed while being captured");
392
468
  return before;
@@ -404,16 +480,17 @@ function hashTaskSnapshot(snapshot: object): string {
404
480
  export function taskDiffIdentity(
405
481
  projectRoot: string,
406
482
  scopeHint: unknown,
483
+ taskId?: string,
407
484
  ): GitTaskDiffIdentity {
408
- const snapshot = captureGitTaskSnapshot(projectRoot, scopeHint);
485
+ const snapshot = captureGitTaskSnapshot(projectRoot, scopeHint, taskId);
409
486
  return {
410
487
  diff_hash: hashTaskSnapshot(snapshot),
411
488
  changed_paths: Object.keys(snapshot.staged_files).sort(comparePaths),
412
489
  };
413
490
  }
414
491
 
415
- export function taskDiffHash(projectRoot: string, scopeHint: unknown): string {
416
- return taskDiffIdentity(projectRoot, scopeHint).diff_hash;
492
+ export function taskDiffHash(projectRoot: string, scopeHint: unknown, taskId?: string): string {
493
+ return taskDiffIdentity(projectRoot, scopeHint, taskId).diff_hash;
417
494
  }
418
495
 
419
496
  function gitRequired(root: string, args: string[], failure: string): string {
@@ -441,6 +518,7 @@ function taskRevisionSnapshotOnce(
441
518
  root: string,
442
519
  scope: string[],
443
520
  baseHead: string,
521
+ taskId?: string,
444
522
  ): GitTaskRevisionSnapshot {
445
523
  const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
446
524
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
@@ -476,7 +554,11 @@ function taskRevisionSnapshotOnce(
476
554
  gitBytes(root, ["ls-files", "--others", "--exclude-standard", "-z", "--"]),
477
555
  "untracked task revision paths",
478
556
  );
479
- const scopedStagedPaths = stagedPaths.filter((path) => taskPathMatchesScope(path, scope));
557
+ assertNoEnvelopeEscape(root, stagedPaths, scope, taskId);
558
+ const baseline = readEnrollmentBaseline(root);
559
+ const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
560
+ const scopedStagedPaths = stagedPaths.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
561
+ .filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined));
480
562
  const scopedUnstagedPaths = unstagedPaths.filter((path) => taskPathMatchesScope(path, scope));
481
563
  const scopedUntrackedPaths = untrackedPaths.filter((path) => taskPathMatchesScope(path, scope));
482
564
  assertNoCaseFoldCollisions(
@@ -517,6 +599,7 @@ export function captureGitTaskRevisionSnapshot(
517
599
  projectRoot: string,
518
600
  scopeHint: unknown,
519
601
  baseHead: unknown,
602
+ taskId?: string,
520
603
  ): GitTaskRevisionSnapshot {
521
604
  const requestedRoot = resolve(projectRoot);
522
605
  const requestedStat = lstatSync(requestedRoot);
@@ -527,9 +610,9 @@ export function captureGitTaskRevisionSnapshot(
527
610
  throw new Error("task revision base must be a Git commit id");
528
611
  const scope = assertCanonicalTaskScope(scopeHint);
529
612
  const normalizedBase = baseHead.toLowerCase();
530
- const before = taskRevisionSnapshotOnce(root, scope, normalizedBase);
613
+ const before = taskRevisionSnapshotOnce(root, scope, normalizedBase, taskId);
531
614
  gitTaskSnapshotTestHook?.();
532
- const after = taskRevisionSnapshotOnce(root, scope, normalizedBase);
615
+ const after = taskRevisionSnapshotOnce(root, scope, normalizedBase, taskId);
533
616
  if (JSON.stringify(after) !== JSON.stringify(before))
534
617
  throw new Error("Git task revision changed while being captured");
535
618
  return before;
@@ -539,8 +622,9 @@ export function taskRevisionIdentity(
539
622
  projectRoot: string,
540
623
  scopeHint: unknown,
541
624
  baseHead: string,
625
+ taskId?: string,
542
626
  ): GitTaskDiffIdentity {
543
- const snapshot = captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead);
627
+ const snapshot = captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead, taskId);
544
628
  return {
545
629
  diff_hash: hashTaskSnapshot(snapshot),
546
630
  changed_paths: Object.keys(snapshot.changed_paths).sort(comparePaths),
@@ -552,8 +636,9 @@ export function taskRevisionDiffHash(
552
636
  projectRoot: string,
553
637
  scopeHint: unknown,
554
638
  baseHead: string,
639
+ taskId?: string,
555
640
  ): string {
556
- return taskRevisionIdentity(projectRoot, scopeHint, baseHead).diff_hash;
641
+ return taskRevisionIdentity(projectRoot, scopeHint, baseHead, taskId).diff_hash;
557
642
  }
558
643
 
559
644
  function isGitWorkspaceSnapshot(value: unknown): value is GitWorkspaceSnapshot {
@@ -9,8 +9,8 @@ Use [`../../dist/imm-planner.md`](../../dist/imm-planner.md) as the canonical co
9
9
  index, not a whole-document read. Explicit entry only: ordinary host requests stay
10
10
  host-native.
11
11
 
12
- Mandatory constraints before any action: Planner writes candidate Specs and
13
- TaskIntents only; it never implements, overwrites an enrolled TaskIntent, or
12
+ Mandatory constraints before any action: Planner writes a TaskIntent, and a Spec
13
+ only for complex work; it never implements, overwrites an enrolled TaskIntent, or
14
14
  grants execution authority — only the native Enrollment gate can.
15
15
 
16
16
  Section routes - load a section's instructions only when its branch applies.
@@ -35,7 +35,7 @@ own routes instead of these stages.
35
35
  - retirement design: [Retirement Completion Contract](../../dist/imm-planner.md#retirement-completion-contract)
36
36
  - optional research dispatch: [Research Dispatch](../../dist/imm-planner.md#research-dispatch)
37
37
 
38
- Plan-only requests stop after candidate Spec/TaskIntent validation. Requests
38
+ Plan-only requests stop after candidate TaskIntent validation, plus Spec validation when the work is complex. Requests
39
39
  that include execution invoke the current Host's native Enrollment gate
40
40
  directly, without chat pre-confirmation. Native-gate failure stays fail-closed
41
41
  in that Host: report its reason and one retry action only; never suggest
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: imm-review-retro
3
+ description: Use when the user explicitly requests Immune-Brain ranking of models by cross-model review load or a project usage retro.
4
+ ---
5
+
6
+ # Immune-Brain: Review Retro
7
+
8
+ Use [`../../dist/imm-review-retro.md`](../../dist/imm-review-retro.md) as the
9
+ canonical contract index, not a whole-document read. This is a
10
+ standalone host-native analysis entry, not a Managed Path continuation
11
+ and not an `imm-loop` internal-role dispatch.
12
+
13
+ Mandatory constraints: read-only on pi session logs. Do not edit code, tests,
14
+ Specs, or workflow state. Do not write session logs or `.imm/` files.
15
+
16
+ Section routes - load a section's instructions only when its branch applies.
17
+ Read each linked heading body up to the next heading; nested sections and
18
+ references load only under their own condition. Never read the whole contract
19
+ or all references as an entry prerequisite.
20
+
21
+ - common: [Boundary](../../dist/imm-review-retro.md#boundary), [Invocation](../../dist/imm-review-retro.md#invocation)
22
+ - running the analyzer: [Counting rules](../../dist/imm-review-retro.md#counting-rules), [CLI](../../dist/imm-review-retro.md#cli)
23
+ - interpreting the report: [Report](../../dist/imm-review-retro.md#report), [Caveats](../../dist/imm-review-retro.md#caveats)
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Retro: how many code reviews did each model's own code trigger, across recent pi sessions.
4
+ *
5
+ * Usage: bun review_retro.ts <days> [--root <sessions-dir>] [--project <substr>] [--top N]
6
+ *
7
+ * Counting rules (the 口径 that keeps the numbers honest):
8
+ * review executed = Agent(subagent_type="Review") tool call
9
+ * avgSc / pass% = average score (0-10) and PASS rate from [SCORE: ...] tags in Review toolResult
10
+ * kernel:submit_review = the *registration* of that same review, reported separately (never added)
11
+ * attribution = the model behind the most recent edit/write before the review (the code's author)
12
+ * findings = imm_kernel_canary record_finding, deduped per session, harness bookkeeping split out
13
+ */
14
+ import { createReadStream, existsSync, readdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { createInterface } from "node:readline";
18
+ import { pathToFileURL } from "node:url";
19
+
20
+ const BOOKKEEPING = /recorded cleanly|receipt recorded|round recorded|no findings?\b/i;
21
+ const EDIT_TOOLS = new Set(["edit", "write", "multiedit"]);
22
+ const SCORE_RE = /\[SCORE:\s*([\d.]+)\s*(?:\/\s*10)?\]/i;
23
+ const VERDICT_RE = /\[VERDICT:\s*(\w+)\]/i;
24
+ const RISK_RE = /\[RISK:\s*(\w+)\]/i;
25
+ const BLOCK_RE = /\[BLOCKING:\s*(\d+)\]/i;
26
+ const ADVIS_RE = /\[ADVISORY:\s*(\d+)\]/i;
27
+
28
+ type ReviewTag = {
29
+ score: number;
30
+ verdict: string;
31
+ risk: string;
32
+ blocking: number;
33
+ advisory: number;
34
+ };
35
+
36
+ function parseReviewTag(text: string): ReviewTag | null {
37
+ if (!text.includes("[SCORE:")) return null;
38
+ const sc = SCORE_RE.exec(text);
39
+ if (!sc) return null;
40
+ const score = Number(sc[1]);
41
+ if (!Number.isFinite(score)) return null;
42
+ return {
43
+ score,
44
+ verdict: (VERDICT_RE.exec(text)?.[1] ?? "UNKNOWN").toUpperCase(),
45
+ risk: (RISK_RE.exec(text)?.[1] ?? "UNKNOWN").toUpperCase(),
46
+ blocking: Number(BLOCK_RE.exec(text)?.[1] ?? 0),
47
+ advisory: Number(ADVIS_RE.exec(text)?.[1] ?? 0),
48
+ };
49
+ }
50
+
51
+ function extractText(content: unknown): string {
52
+ if (typeof content === "string") return content;
53
+ if (Array.isArray(content)) {
54
+ return content
55
+ .map((item) => {
56
+ if (typeof item === "string") return item;
57
+ if (item && typeof item === "object") {
58
+ const rec = item as Record<string, unknown>;
59
+ if ("text" in rec) return String(rec.text);
60
+ if ("result" in rec) return String(rec.result);
61
+ }
62
+ return "";
63
+ })
64
+ .join("\n");
65
+ }
66
+ return "";
67
+ }
68
+
69
+ function norm(a: unknown): Record<string, unknown> {
70
+ if (typeof a === "string") {
71
+ try {
72
+ return JSON.parse(a) as Record<string, unknown>;
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+ return a && typeof a === "object" ? (a as Record<string, unknown>) : {};
78
+ }
79
+
80
+ function bump(map: Map<string, number>, key: string, n = 1): void {
81
+ map.set(key, (map.get(key) ?? 0) + n);
82
+ }
83
+
84
+ type Args = { days: number; root: string; project: string; top: number };
85
+
86
+ function parseArgs(argv: string[]): Args {
87
+ const out: Args = {
88
+ days: NaN,
89
+ root: join(homedir(), ".pi/agent/sessions"),
90
+ project: "",
91
+ top: 15,
92
+ };
93
+ const rest: string[] = [];
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const a = argv[i]!;
96
+ if (a === "--root") out.root = argv[++i] ?? "";
97
+ else if (a === "--project") out.project = argv[++i] ?? "";
98
+ else if (a === "--top") out.top = Number(argv[++i]);
99
+ else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
100
+ else rest.push(a);
101
+ }
102
+ out.days = Number(rest[0]);
103
+ if (!(out.days > 0) || rest[1] !== undefined) {
104
+ throw new Error("usage: review_retro.ts <days> [--root <dir>] [--project <substr>] [--top N]");
105
+ }
106
+ if (!Number.isFinite(out.top) || out.top <= 0) throw new Error("--top must be > 0");
107
+ return out;
108
+ }
109
+
110
+ function listJsonl(root: string): string[] {
111
+ if (!existsSync(root)) return [];
112
+ const paths: string[] = [];
113
+ for (const dir of readdirSync(root, { withFileTypes: true })) {
114
+ if (!dir.isDirectory()) continue;
115
+ const folder = join(root, dir.name);
116
+ for (const name of readdirSync(folder)) {
117
+ if (name.endsWith(".jsonl")) paths.push(join(folder, name));
118
+ }
119
+ }
120
+ return paths;
121
+ }
122
+
123
+ export async function run(argv: string[]): Promise<string> {
124
+ const args = parseArgs(argv);
125
+ const cut = new Date(Date.now() - args.days * 86400000).toISOString().slice(0, 19);
126
+ const home = homedir();
127
+ const dev = new Map<string, number>();
128
+ const turns = new Map<string, number>();
129
+ const rev = new Map<string, number>();
130
+ const sub = new Map<string, number>();
131
+ const tools = new Map<string, number>();
132
+ const proj = new Map<string, number>();
133
+ const episode = new Map<string, Set<string>>();
134
+ const tasks = new Map<string, Set<string>>();
135
+ const findUniq = new Map<string, Set<string>>();
136
+ const findingsRaw = new Map<string, number>();
137
+ const scores = new Map<string, number[]>();
138
+ const verdicts = new Map<string, number>();
139
+ const risks = new Map<string, number>();
140
+ let files = 0;
141
+
142
+ for (const path of listJsonl(args.root)) {
143
+ let editor: string | null = null;
144
+ let cwd = "";
145
+ let used = false;
146
+ const pending = new Map<string, string>();
147
+ const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }) });
148
+ for await (const raw of rl) {
149
+ const line = raw.trim();
150
+ if (!line) continue;
151
+ let o: Record<string, unknown>;
152
+ try {
153
+ o = JSON.parse(line) as Record<string, unknown>;
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (o.type === "session") {
158
+ cwd = String(o.cwd ?? "");
159
+ continue;
160
+ }
161
+ const m = (o.message && typeof o.message === "object" ? o.message : {}) as Record<string, unknown>;
162
+ const role = m.role;
163
+ if (String(o.timestamp ?? "") < cut) continue;
164
+ if (args.project && !cwd.includes(args.project)) continue;
165
+
166
+ if (role === "assistant") {
167
+ used = true;
168
+ const mo = `${m.provider ?? "?"}/${m.model ?? "?"}`;
169
+ bump(turns, mo);
170
+ const content = Array.isArray(m.content) ? m.content : [];
171
+ for (const c of content) {
172
+ if (!c || typeof c !== "object") continue;
173
+ const call = c as Record<string, unknown>;
174
+ if (call.type !== "toolCall") continue;
175
+ const name = String(call.name ?? "");
176
+ const a = norm(call.arguments);
177
+ const cid = typeof call.id === "string" ? call.id : "";
178
+ bump(tools, name || "?");
179
+ if (EDIT_TOOLS.has(name)) {
180
+ bump(dev, mo);
181
+ editor = mo;
182
+ } else if (name === "Agent" && a.subagent_type === "Review") {
183
+ const owner = editor ?? "no-edit (review-only)";
184
+ bump(rev, owner);
185
+ const ep = episode.get(owner) ?? new Set();
186
+ ep.add(`${path}\0${String(a.description ?? "")}${String(a.prompt ?? "").slice(0, 240)}`);
187
+ episode.set(owner, ep);
188
+ const projKey = `${cwd.replace(home, "~")}\0${owner}`;
189
+ bump(proj, projKey);
190
+ if (cid) pending.set(cid, owner);
191
+ } else if (name === "imm_kernel_canary") {
192
+ const act = norm(a.action);
193
+ const op = act.op;
194
+ const owner = editor ?? "no-edit (review-only)";
195
+ if (op === "submit_review") {
196
+ bump(sub, owner);
197
+ const t = tasks.get(owner) ?? new Set();
198
+ t.add(`${cwd}\0${String(a.task_id ?? "")}`);
199
+ tasks.set(owner, t);
200
+ } else if (op === "record_finding") {
201
+ const f = norm(act.finding);
202
+ const summ = String(f.summary ?? "");
203
+ const kind = BOOKKEEPING.test(summ) ? "bookkeeping" : String(f.kind ?? "");
204
+ const rawKey = `${owner}\0${kind}`;
205
+ bump(findingsRaw, rawKey);
206
+ const uniq = findUniq.get(rawKey) ?? new Set();
207
+ uniq.add(`${path}\0${summ.slice(0, 160)}`);
208
+ findUniq.set(rawKey, uniq);
209
+ }
210
+ }
211
+ }
212
+ } else if (role === "toolResult") {
213
+ const tcid = String(m.toolCallId ?? "");
214
+ const owner = pending.get(tcid);
215
+ if (!owner) continue;
216
+ pending.delete(tcid);
217
+ const tag = parseReviewTag(extractText(m.content));
218
+ if (!tag) continue;
219
+ const sl = scores.get(owner) ?? [];
220
+ sl.push(tag.score);
221
+ scores.set(owner, sl);
222
+ bump(verdicts, `${owner}\0${tag.verdict}`);
223
+ bump(risks, `${owner}\0${tag.risk}`);
224
+ }
225
+ }
226
+ if (used) files += 1;
227
+ }
228
+
229
+ const lines: string[] = [];
230
+ const emit = (s = "") => lines.push(s);
231
+ emit(`window: last ${args.days}d (UTC >= ${cut}Z) | sessions with activity: ${files} | root: ${args.root}`);
232
+ emit("review = Agent(Review) executed; attributed to the model that last edited the code under review");
233
+ emit("avgSc/pass% = parsed from Review [SCORE: .../10] [VERDICT: ...] tags (shows '-' if untagged)");
234
+ emit("");
235
+ const hdr =
236
+ `${"model".padEnd(42)}${"devEdits".padStart(9)}${"turns".padStart(6)}${"reviews".padStart(8)}${"uniq".padStart(5)}${"rev/100ed".padStart(10)}${"avgSc".padStart(6)}${"pass%".padStart(6)}${"registr".padStart(8)}${"block".padStart(6)}${"advis".padStart(6)}${"noisy".padStart(6)}`;
237
+ emit(hdr);
238
+ emit("-".repeat(hdr.length));
239
+
240
+ const models = [...new Set([...rev.keys(), ...dev.keys()])].sort(
241
+ (a, b) => (rev.get(b) ?? 0) - (rev.get(a) ?? 0),
242
+ );
243
+ const shown: string[] = [];
244
+ for (const mo of models) {
245
+ if (!rev.get(mo) && (dev.get(mo) ?? 0) < 30) continue;
246
+ shown.push(mo);
247
+ const uq = episode.get(mo)?.size ?? 0;
248
+ const d = dev.get(mo) ?? 0;
249
+ const r = rev.get(mo) ?? 0;
250
+ const rate = d ? ((100 * r) / d).toFixed(1) : "-";
251
+ const sl = scores.get(mo) ?? [];
252
+ const avgSc = sl.length ? (sl.reduce((x, y) => x + y, 0) / sl.length).toFixed(1) : "-";
253
+ const passCnt = verdicts.get(`${mo}\0PASS`) ?? 0;
254
+ const passPct = sl.length ? `${Math.round((100 * passCnt) / sl.length)}%` : "-";
255
+ const block = findUniq.get(`${mo}\0blocking`)?.size ?? 0;
256
+ const advis = findUniq.get(`${mo}\0advisory`)?.size ?? 0;
257
+ const noisy = findUniq.get(`${mo}\0bookkeeping`)?.size ?? 0;
258
+ emit(
259
+ `${mo.padEnd(42)}${String(d).padStart(9)}${String(turns.get(mo) ?? 0).padStart(6)}${String(r).padStart(8)}${String(uq).padStart(5)}${rate.padStart(10)}${avgSc.padStart(6)}${passPct.padStart(6)}${String(sub.get(mo) ?? 0).padStart(8)}${String(block).padStart(6)}${String(advis).padStart(6)}${String(noisy).padStart(6)}`,
260
+ );
261
+ }
262
+ emit("-".repeat(hdr.length));
263
+ const totalScores = [...scores.values()].flat();
264
+ const totAvg = totalScores.length
265
+ ? (totalScores.reduce((x, y) => x + y, 0) / totalScores.length).toFixed(1)
266
+ : "-";
267
+ const totPass = shown.reduce((n, mo) => n + (verdicts.get(`${mo}\0PASS`) ?? 0), 0);
268
+ const totPassPct = totalScores.length ? `${Math.round((100 * totPass) / totalScores.length)}%` : "-";
269
+ const totBlock = [...findUniq.entries()].reduce((n, [k, v]) => n + (k.endsWith("\0blocking") ? v.size : 0), 0);
270
+ const totAdvis = [...findUniq.entries()].reduce((n, [k, v]) => n + (k.endsWith("\0advisory") ? v.size : 0), 0);
271
+ const totNoisy = [...findUniq.entries()].reduce((n, [k, v]) => n + (k.endsWith("\0bookkeeping") ? v.size : 0), 0);
272
+ const totDev = [...dev.values()].reduce((a, b) => a + b, 0);
273
+ const totTurns = [...turns.values()].reduce((a, b) => a + b, 0);
274
+ const totRev = [...rev.values()].reduce((a, b) => a + b, 0);
275
+ const totUniq = [...episode.values()].reduce((n, s) => n + s.size, 0);
276
+ const totSub = [...sub.values()].reduce((a, b) => a + b, 0);
277
+ emit(
278
+ `${"TOTAL".padEnd(42)}${String(totDev).padStart(9)}${String(totTurns).padStart(6)}${String(totRev).padStart(8)}${String(totUniq).padStart(5)}${"".padStart(10)}${totAvg.padStart(6)}${totPassPct.padStart(6)}${String(totSub).padStart(8)}${String(totBlock).padStart(6)}${String(totAdvis).padStart(6)}${String(totNoisy).padStart(6)}`,
279
+ );
280
+
281
+ emit("");
282
+ emit("=== usage (sessions / turns / edits / tools) ===");
283
+ emit(`sessions: ${files} | turns: ${totTurns} | edits: ${totDev}`);
284
+ const toolRows = [...tools.entries()].sort((a, b) => b[1] - a[1]);
285
+ if (toolRows.length === 0) emit("(no tool calls in this window)");
286
+ else for (const [name, n] of toolRows) emit(`${String(n).padStart(5)} ${name}`);
287
+
288
+ emit("");
289
+ emit("=== review quality & scores (new rubric) ===");
290
+ const scoredModels = [...scores.keys()]
291
+ .filter((m) => (scores.get(m) ?? []).length > 0)
292
+ .sort((a, b) => (scores.get(b)?.length ?? 0) - (scores.get(a)?.length ?? 0));
293
+ if (scoredModels.length) {
294
+ const qHdr = `${"model".padEnd(42)}${"scored".padStart(7)}${"avgScore".padStart(9)}${"PASS".padStart(6)}${"REVISE".padStart(7)}${"REJECT".padStart(7)}${"highRisk".padStart(9)}`;
295
+ emit(qHdr);
296
+ emit("-".repeat(qHdr.length));
297
+ for (const mo of scoredModels) {
298
+ const sl = scores.get(mo) ?? [];
299
+ const avgS = (sl.reduce((x, y) => x + y, 0) / sl.length).toFixed(2);
300
+ const pC = verdicts.get(`${mo}\0PASS`) ?? 0;
301
+ const revC = verdicts.get(`${mo}\0REVISE`) ?? 0;
302
+ const rejC = verdicts.get(`${mo}\0REJECT`) ?? 0;
303
+ const highR = (risks.get(`${mo}\0HIGH`) ?? 0) + (risks.get(`${mo}\0CRITICAL`) ?? 0);
304
+ emit(
305
+ `${mo.padEnd(42)}${String(sl.length).padStart(7)}${avgS.padStart(9)}${String(pC).padStart(6)}${String(revC).padStart(7)}${String(rejC).padStart(7)}${String(highR).padStart(9)}`,
306
+ );
307
+ }
308
+ } else {
309
+ emit("(no scored reviews found in this window yet; reviews with [SCORE: .../10] will appear here)");
310
+ }
311
+
312
+ emit("");
313
+ emit("=== where the reviews landed (project x author) ===");
314
+ const projRows = [...proj.entries()].sort((a, b) => b[1] - a[1]).slice(0, args.top);
315
+ for (const [key, v] of projRows) {
316
+ const [p, mo] = key.split("\0");
317
+ emit(`${String(v).padStart(5)} ${String(p).padEnd(58)} ${mo}`);
318
+ }
319
+
320
+ emit("");
321
+ emit("=== review rounds per kernel task (rework signal) ===");
322
+ const taskModels = [...tasks.keys()].sort((a, b) => (sub.get(b) ?? 0) - (sub.get(a) ?? 0));
323
+ for (const mo of taskModels) {
324
+ const nt = tasks.get(mo)?.size ?? 0;
325
+ if (!nt) continue;
326
+ const s = sub.get(mo) ?? 0;
327
+ emit(
328
+ `${(s / nt).toFixed(1).padStart(6)} rounds/task ${String(s).padStart(4)} registrations / ${String(nt).padStart(3)} tasks ${mo}`,
329
+ );
330
+ }
331
+
332
+ const rawSum = [...findingsRaw.values()].reduce((a, b) => a + b, 0);
333
+ const uniqSum = [...findUniq.values()].reduce((n, s) => n + s.size, 0);
334
+ if (rawSum && rawSum !== uniqSum) {
335
+ emit("");
336
+ emit(
337
+ `(findings: ${rawSum} raw calls deduped to ${uniqSum} distinct — re-submits of the same finding are counted once)`,
338
+ );
339
+ }
340
+ emit("caveats: 'bookkeep' = kernel self-entries mislabelled as findings, excluded from blocking/advisory;");
341
+ emit(" high rounds/task can be canary/QA harness re-registration, not human-visible rework.");
342
+ return `${lines.join("\n")}\n`;
343
+ }
344
+
345
+ const isMain = Boolean(process.argv[1]) && pathToFileURL(process.argv[1]!).href === import.meta.url;
346
+ if (isMain) {
347
+ run(process.argv.slice(2))
348
+ .then((text) => {
349
+ process.stdout.write(text);
350
+ })
351
+ .catch((err: unknown) => {
352
+ console.error(err);
353
+ process.exit(1);
354
+ });
355
+ }
@@ -56,3 +56,12 @@ skills:
56
56
  output_artifacts: [maintain_report]
57
57
  next_actions: []
58
58
  boundary: Minimize tracked agent-instruction context after explicit manifest approval; no Managed authority mutation, contract installation, or reference-document creation.
59
+ - name: imm-review-retro
60
+ path: skills/imm-review-retro/SKILL.md
61
+ role: execute
62
+ title: Review Retro
63
+ role_class: discovery
64
+ canonical: true
65
+ output_artifacts: [retro_report]
66
+ next_actions: []
67
+ boundary: Rank models by review load and report project usage from pi session logs. Read-only. No Managed Path mutation.
@@ -1,4 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
- PLUGIN_ROOT=$(cd "$(dirname "$0")/.." && pwd -P)
4
- exec bun "$PLUGIN_ROOT/runtime/v4_runtime.ts" cli imm-retire-stale-wrapper "$@"
@@ -1,4 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
- PLUGIN_ROOT=$(cd "$(dirname "$0")/.." && pwd -P)
4
- exec bun "$PLUGIN_ROOT/runtime/v4_runtime.ts" cli imm-work "$@"