@deftai/directive-core 0.95.0 → 0.96.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 (47) hide show
  1. package/dist/cache/archive.d.ts +134 -0
  2. package/dist/cache/archive.js +630 -0
  3. package/dist/cache/index.d.ts +1 -0
  4. package/dist/cache/index.js +1 -0
  5. package/dist/cache/main.js +298 -1
  6. package/dist/content-contracts/skills/helpers.d.ts +1 -1
  7. package/dist/content-contracts/skills/helpers.js +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/init-deposit/hygiene.d.ts +1 -1
  11. package/dist/init-deposit/hygiene.js +16 -4
  12. package/dist/init-deposit/scaffold.js +6 -5
  13. package/dist/parent-turn-shape/evaluate.d.ts +84 -0
  14. package/dist/parent-turn-shape/evaluate.js +353 -0
  15. package/dist/parent-turn-shape/index.d.ts +8 -0
  16. package/dist/parent-turn-shape/index.js +8 -0
  17. package/dist/review-monitor/constants.js +3 -2
  18. package/dist/review-monitor/tier-detection.d.ts +7 -3
  19. package/dist/review-monitor/tier-detection.js +18 -1
  20. package/dist/review-monitor/verify.js +18 -0
  21. package/dist/scope/index.d.ts +2 -0
  22. package/dist/scope/index.js +2 -0
  23. package/dist/scope/main.d.ts +10 -0
  24. package/dist/scope/main.js +109 -24
  25. package/dist/scope/promote-from-issue.d.ts +49 -0
  26. package/dist/scope/promote-from-issue.js +367 -0
  27. package/dist/scope/promote-path.d.ts +39 -0
  28. package/dist/scope/promote-path.js +105 -0
  29. package/dist/swarm/routing.d.ts +4 -2
  30. package/dist/swarm/routing.js +26 -4
  31. package/dist/triage/actions/index.js +62 -2
  32. package/dist/triage/actions/types.d.ts +8 -1
  33. package/dist/triage/author-filter.d.ts +51 -0
  34. package/dist/triage/author-filter.js +152 -0
  35. package/dist/triage/classify/index.d.ts +2 -2
  36. package/dist/triage/classify/index.js +2 -2
  37. package/dist/triage/classify/label-mirror.d.ts +68 -5
  38. package/dist/triage/classify/label-mirror.js +261 -31
  39. package/dist/triage/help/registry-data.d.ts +49 -38
  40. package/dist/triage/help/registry-data.js +115 -40
  41. package/dist/triage/index.d.ts +1 -0
  42. package/dist/triage/index.js +1 -0
  43. package/dist/triage/queue/index.d.ts +1 -0
  44. package/dist/triage/queue/index.js +1 -0
  45. package/dist/triage/queue/render.d.ts +2 -0
  46. package/dist/triage/queue/render.js +6 -0
  47. package/package.json +7 -3
@@ -1,17 +1,28 @@
1
1
  /**
2
- * Tier-1 deterministic SCM label mirror (#1423 Wave 1).
2
+ * Tier-1 deterministic SCM label mirror (#1423 Wave 1 + Wave 2 bootstrap).
3
3
  *
4
4
  * Classifies cached issues with the existing #1129 engine, then mirrors the
5
5
  * outcome as SCM labels (dry-run default, --apply to write). Never accepts into
6
6
  * the xBRIEF lifecycle and never writes proposed/ scopes.
7
7
  *
8
+ * Wave 2 (#3125): open-only default, operator digest (totals + by state/rule/action
9
+ * + samples), batched rate-limit-aware apply. Bootstrap mass-triage entrypoint is
10
+ * `triage:classify -- --mirror` with these filters (not triage:accept).
11
+ *
8
12
  * Intentionally does NOT import from ./index.js (SLizard P1 cycle). The classify
9
13
  * engine is injected via LabelMirrorEngine / mirrorLabels() wrapper in index.ts.
10
14
  */
11
15
  import type { LabelClient } from "../../vbrief-reconcile/types.js";
16
+ import { type AuthorFilter } from "../author-filter.js";
12
17
  export declare const DEFAULT_IDEMPOTENCY_LABEL = "triaged";
13
18
  export declare const CACHE_DIR_NAME = ".deft-cache";
14
19
  export declare const CACHE_SOURCE = "github-issue";
20
+ /** Default apply batch size for rate-limit awareness (#3125). */
21
+ export declare const DEFAULT_APPLY_BATCH_SIZE = 10;
22
+ /** Default delay between apply batches in ms (#3125). */
23
+ export declare const DEFAULT_APPLY_DELAY_MS = 1000;
24
+ /** Default sample count in human digest (#3125). */
25
+ export declare const DEFAULT_DIGEST_SAMPLE_LIMIT = 15;
15
26
  export type ClassifyAction = "defer" | "archive" | "escalate" | "accept";
16
27
  /** Minimal issue shape used by the mirror (matches classify GitHubIssue). */
17
28
  export interface MirrorGitHubIssue {
@@ -73,10 +84,12 @@ export interface ResolvedLabelMirrorPolicy {
73
84
  readonly alwaysLabels: readonly string[];
74
85
  readonly actionLabels: Readonly<Partial<Record<ClassifyAction, readonly string[]>>>;
75
86
  }
76
- export type LabelMirrorStatus = "planned" | "applied" | "unchanged" | "skipped_already_triaged" | "skipped_no_match" | "skipped_unreadable" | "skipped_disabled" | "error";
87
+ export type LabelMirrorStatus = "planned" | "applied" | "unchanged" | "skipped_already_triaged" | "skipped_no_match" | "skipped_unreadable" | "skipped_closed" | "skipped_author" | "skipped_disabled" | "error";
77
88
  export interface LabelMirrorItem {
78
89
  readonly repo: string;
79
90
  readonly issue_number: number;
91
+ /** Issue state from cache (open/closed/unknown). */
92
+ readonly state: string | null;
80
93
  readonly action: string | null;
81
94
  readonly reason: string | null;
82
95
  readonly ruleKind: string | null;
@@ -86,6 +99,24 @@ export interface LabelMirrorItem {
86
99
  readonly status: LabelMirrorStatus;
87
100
  readonly message?: string;
88
101
  }
102
+ /** Operator digest aggregates for bootstrap mass-triage (#3125 / #1423 Wave 2). */
103
+ export interface LabelMirrorDigest {
104
+ readonly by_state: Readonly<Record<string, number>>;
105
+ readonly by_rule: Readonly<Record<string, number>>;
106
+ readonly by_action: Readonly<Record<string, number>>;
107
+ readonly samples: readonly LabelMirrorItem[];
108
+ readonly sample_limit: number;
109
+ readonly sample_truncated: boolean;
110
+ }
111
+ export interface LabelMirrorFilters {
112
+ /** When false (default), closed issues are skipped before classify. */
113
+ readonly include_closed: boolean;
114
+ readonly repo: string | null;
115
+ /** Active author allow-list display (#3129); null when no author filter. */
116
+ readonly author: string | null;
117
+ /** Resolved author logins for machine consumers. */
118
+ readonly author_logins: readonly string[] | null;
119
+ }
89
120
  export interface LabelMirrorOutcome {
90
121
  readonly project_root: string;
91
122
  readonly dry_run: boolean;
@@ -96,10 +127,20 @@ export interface LabelMirrorOutcome {
96
127
  readonly skipped_already_triaged: number;
97
128
  readonly skipped_no_match: number;
98
129
  readonly skipped_unreadable: number;
130
+ /** Closed issues skipped by open-only default (#3125). */
131
+ readonly skipped_closed: number;
132
+ /** Issues skipped by --author filter (#3129). */
133
+ readonly skipped_author: number;
99
134
  readonly errors: number;
135
+ readonly filters: LabelMirrorFilters;
136
+ readonly digest: LabelMirrorDigest;
100
137
  readonly items: readonly LabelMirrorItem[];
101
138
  readonly policy: ResolvedLabelMirrorPolicy;
139
+ /** Apply path: successful writes in this run (same as applied). */
140
+ readonly batch_size?: number;
141
+ readonly delay_ms?: number;
102
142
  }
143
+ export type LabelMirrorSleepFn = (ms: number) => void;
103
144
  export interface LabelMirrorOptions {
104
145
  readonly dryRun?: boolean;
105
146
  readonly repo?: string | null;
@@ -110,6 +151,24 @@ export interface LabelMirrorOptions {
110
151
  /** Prefer live SCM labels when true (default: !dryRun). Cache labels used for dry-run. */
111
152
  readonly useLiveLabels?: boolean;
112
153
  readonly now?: Date;
154
+ /**
155
+ * Include closed issues in classify+mirror. Default false (open-only) for safe
156
+ * bootstrap mass-triage (#3125). Opt in with CLI `--include-closed`.
157
+ */
158
+ readonly includeClosed?: boolean;
159
+ /**
160
+ * Resolved author filter applied before plan/apply walk (#3129).
161
+ * Composes with open-only (AND). CLI resolves `@me` before passing this.
162
+ */
163
+ readonly authorFilter?: AuthorFilter | null;
164
+ /** Max planned/applied samples in human digest (default 15). */
165
+ readonly sampleLimit?: number;
166
+ /** SCM writes per batch before delay (default 10; apply path only). */
167
+ readonly batchSize?: number;
168
+ /** Delay in ms between apply batches (default 1000; apply path only). */
169
+ readonly delayMs?: number;
170
+ /** Injectable sleep for tests (receives ms). Default busy-wait when delayMs > 0. */
171
+ readonly sleepMs?: LabelMirrorSleepFn;
113
172
  /** Required: classify engine (provided by classify/index mirrorLabels wrapper). */
114
173
  readonly engine: LabelMirrorEngine;
115
174
  }
@@ -128,15 +187,19 @@ export declare function resolveLabelMirrorPolicy(options?: {
128
187
  }): ResolvedLabelMirrorPolicy;
129
188
  /** Labels to apply for a classified action (always + action-mapped). */
130
189
  export declare function desiredLabelsForClassification(action: string, policy: ResolvedLabelMirrorPolicy): string[];
190
+ /** Build digest aggregates + samples from mirror items (#3125). */
191
+ export declare function buildLabelMirrorDigest(items: readonly LabelMirrorItem[], sampleLimit?: number): LabelMirrorDigest;
131
192
  /**
132
- * Run Tier-1 label mirror over the github-issue cache.
193
+ * Run Tier-1 label mirror over the github-issue cache (bootstrap mass-triage surface).
133
194
  * Dry-run by default (no SCM writes). Pass dryRun: false to apply.
195
+ * Default state filter is open-only (#3125); pass includeClosed: true for archive stamps.
134
196
  * Requires options.engine (classify/index wrapper injects it).
197
+ * Never calls triage:accept / never writes proposed/ xBRIEFs.
135
198
  */
136
199
  export declare function mirrorLabels(projectRoot: string, options: LabelMirrorOptions): [number, LabelMirrorOutcome];
137
- /** Human-readable digest for dry-run / apply reports. */
200
+ /** Human-readable digest for dry-run / apply reports (bootstrap mass-triage UX). */
138
201
  export declare function renderLabelMirrorReport(outcome: LabelMirrorOutcome): string;
139
- /** JSON-serializable outcome (stable key order not required). */
202
+ /** JSON-serializable outcome including Wave 2 digest aggregates. */
140
203
  export declare function labelMirrorOutcomeToJson(outcome: LabelMirrorOutcome): Record<string, unknown>;
141
204
  /** Validate triageLabelMirror on a plan object (vbrief_validate hook). */
142
205
  export declare function validateTriageLabelMirrorOnPlan(plan: unknown, filepath: string): string[];
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Tier-1 deterministic SCM label mirror (#1423 Wave 1).
2
+ * Tier-1 deterministic SCM label mirror (#1423 Wave 1 + Wave 2 bootstrap).
3
3
  *
4
4
  * Classifies cached issues with the existing #1129 engine, then mirrors the
5
5
  * outcome as SCM labels (dry-run default, --apply to write). Never accepts into
6
6
  * the xBRIEF lifecycle and never writes proposed/ scopes.
7
7
  *
8
+ * Wave 2 (#3125): open-only default, operator digest (totals + by state/rule/action
9
+ * + samples), batched rate-limit-aware apply. Bootstrap mass-triage entrypoint is
10
+ * `triage:classify -- --mirror` with these filters (not triage:accept).
11
+ *
8
12
  * Intentionally does NOT import from ./index.js (SLizard P1 cycle). The classify
9
13
  * engine is injected via LabelMirrorEngine / mirrorLabels() wrapper in index.ts.
10
14
  */
@@ -16,11 +20,18 @@ import { readPlanPolicy } from "../../policy/plan-extensions.js";
16
20
  import { ScmLabelClient } from "../../vbrief-reconcile/labels.js";
17
21
  import { isRepoMutationAllowed } from "../../vbrief-reconcile/repo-guard.js";
18
22
  import { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
23
+ import { authorLoginFromRawIssue, matchesAuthorFilter, } from "../author-filter.js";
19
24
  import { resolveCandidatesLogPath } from "../cache-path.js";
20
25
  import { iterCachedIssues } from "../summary/index.js";
21
26
  export const DEFAULT_IDEMPOTENCY_LABEL = "triaged";
22
27
  export const CACHE_DIR_NAME = ".deft-cache";
23
28
  export const CACHE_SOURCE = "github-issue";
29
+ /** Default apply batch size for rate-limit awareness (#3125). */
30
+ export const DEFAULT_APPLY_BATCH_SIZE = 10;
31
+ /** Default delay between apply batches in ms (#3125). */
32
+ export const DEFAULT_APPLY_DELAY_MS = 1000;
33
+ /** Default sample count in human digest (#3125). */
34
+ export const DEFAULT_DIGEST_SAMPLE_LIMIT = 15;
24
35
  const VALID_ACTIONS = new Set(["defer", "archive", "escalate", "accept"]);
25
36
  /**
26
37
  * Repo-qualified xBRIEF github-issue keys (`owner/name\\0number`).
@@ -308,10 +319,72 @@ function loadLatestDecisionMap(projectRoot) {
308
319
  function decisionMapHas(map, repo, issueNumber) {
309
320
  return map.has(`${repo}\0${issueNumber}`);
310
321
  }
322
+ function defaultSleepMs(ms) {
323
+ if (ms <= 0) {
324
+ return;
325
+ }
326
+ // Sync sleep: LabelClient.apply is sync; keep mirrorLabels non-async (#3125).
327
+ const sab = new SharedArrayBuffer(4);
328
+ const ia = new Int32Array(sab);
329
+ Atomics.wait(ia, 0, 0, ms);
330
+ }
331
+ function normalizeIssueState(raw) {
332
+ if (typeof raw !== "string" || raw.trim().length === 0) {
333
+ return null;
334
+ }
335
+ return raw.trim().toLowerCase();
336
+ }
337
+ function isClosedState(state) {
338
+ return state === "closed";
339
+ }
340
+ /** Build digest aggregates + samples from mirror items (#3125). */
341
+ export function buildLabelMirrorDigest(items, sampleLimit = DEFAULT_DIGEST_SAMPLE_LIMIT) {
342
+ const limit = Number.isFinite(sampleLimit) && sampleLimit >= 0
343
+ ? Math.floor(sampleLimit)
344
+ : DEFAULT_DIGEST_SAMPLE_LIMIT;
345
+ const byState = {};
346
+ const byRule = {};
347
+ const byAction = {};
348
+ const writeItems = items.filter((i) => i.status === "planned" || i.status === "applied");
349
+ for (const item of writeItems) {
350
+ const st = item.state ?? "unknown";
351
+ byState[st] = (byState[st] ?? 0) + 1;
352
+ const rule = item.ruleKind ?? "(none)";
353
+ byRule[rule] = (byRule[rule] ?? 0) + 1;
354
+ const action = item.action ?? "(none)";
355
+ byAction[action] = (byAction[action] ?? 0) + 1;
356
+ }
357
+ const samples = writeItems.slice(0, limit);
358
+ return {
359
+ by_state: byState,
360
+ by_rule: byRule,
361
+ by_action: byAction,
362
+ samples,
363
+ sample_limit: limit,
364
+ sample_truncated: writeItems.length > limit,
365
+ };
366
+ }
367
+ function formatMissingLabelHint(message, labels) {
368
+ const lower = message.toLowerCase();
369
+ const looksMissing = lower.includes("not found") ||
370
+ lower.includes("could not add label") ||
371
+ lower.includes("invalid label") ||
372
+ lower.includes("unknown label") ||
373
+ (lower.includes("label") && (lower.includes("404") || lower.includes("does not exist")));
374
+ if (!looksMissing) {
375
+ return message;
376
+ }
377
+ const want = labels.length > 0 ? labels.join(", ") : "triaged";
378
+ return (`${message} — ensure label(s) exist on the repo before --apply ` +
379
+ `(create missing labels e.g. \`gh label create "${want.split(",")[0]?.trim() ?? "triaged"}"\`; ` +
380
+ `idempotency + actionLabels must exist or apply fails closed per issue).`);
381
+ }
311
382
  /**
312
- * Run Tier-1 label mirror over the github-issue cache.
383
+ * Run Tier-1 label mirror over the github-issue cache (bootstrap mass-triage surface).
313
384
  * Dry-run by default (no SCM writes). Pass dryRun: false to apply.
385
+ * Default state filter is open-only (#3125); pass includeClosed: true for archive stamps.
314
386
  * Requires options.engine (classify/index wrapper injects it).
387
+ * Never calls triage:accept / never writes proposed/ xBRIEFs.
315
388
  */
316
389
  export function mirrorLabels(projectRoot, options) {
317
390
  const root = resolve(projectRoot);
@@ -321,12 +394,39 @@ export function mirrorLabels(projectRoot, options) {
321
394
  const useLiveLabels = options.useLiveLabels ?? !dryRun;
322
395
  const client = options.client ?? (useLiveLabels || !dryRun ? new ScmLabelClient() : undefined);
323
396
  const engine = options.engine;
397
+ const includeClosed = options.includeClosed === true;
398
+ const authorFilter = options.authorFilter !== undefined && options.authorFilter !== null
399
+ ? options.authorFilter
400
+ : null;
401
+ const sampleLimit = options.sampleLimit ?? DEFAULT_DIGEST_SAMPLE_LIMIT;
402
+ const batchSize = options.batchSize !== undefined
403
+ ? Math.max(1, Math.floor(options.batchSize))
404
+ : DEFAULT_APPLY_BATCH_SIZE;
405
+ const delayMs = options.delayMs !== undefined && options.delayMs >= 0
406
+ ? Math.floor(options.delayMs)
407
+ : dryRun
408
+ ? 0
409
+ : DEFAULT_APPLY_DELAY_MS;
410
+ const sleepMs = options.sleepMs ?? defaultSleepMs;
411
+ const repoFilter = options.repo !== undefined && options.repo !== null && options.repo.trim().length > 0
412
+ ? options.repo.trim()
413
+ : null;
414
+ const filters = {
415
+ include_closed: includeClosed,
416
+ repo: repoFilter,
417
+ author: authorFilter !== null ? authorFilter.display : null,
418
+ author_logins: authorFilter !== null ? authorFilter.allowLogins : null,
419
+ };
324
420
  const items = [];
325
421
  const outcomeBase = {
326
422
  project_root: root,
327
423
  dry_run: dryRun,
328
424
  policy,
425
+ filters,
426
+ batch_size: dryRun ? undefined : batchSize,
427
+ delay_ms: dryRun ? undefined : delayMs,
329
428
  };
429
+ const emptyDigest = buildLabelMirrorDigest([], sampleLimit);
330
430
  if (!policy.enabled) {
331
431
  return [
332
432
  0,
@@ -339,11 +439,15 @@ export function mirrorLabels(projectRoot, options) {
339
439
  skipped_already_triaged: 0,
340
440
  skipped_no_match: 0,
341
441
  skipped_unreadable: 0,
442
+ skipped_closed: 0,
443
+ skipped_author: 0,
342
444
  errors: 0,
445
+ digest: emptyDigest,
343
446
  items: [
344
447
  {
345
448
  repo: "",
346
449
  issue_number: 0,
450
+ state: null,
347
451
  action: null,
348
452
  reason: null,
349
453
  ruleKind: null,
@@ -365,8 +469,8 @@ export function mirrorLabels(projectRoot, options) {
365
469
  const decisions = loadLatestDecisionMap(root);
366
470
  const now = options.now ?? new Date();
367
471
  let pairs = iterCachedIssues(cacheRoot);
368
- if (options.repo !== undefined && options.repo !== null && options.repo.trim().length > 0) {
369
- const want = options.repo.trim().toLowerCase();
472
+ if (repoFilter !== null) {
473
+ const want = repoFilter.toLowerCase();
370
474
  pairs = pairs.filter(([repo]) => repo.toLowerCase() === want);
371
475
  }
372
476
  let planned = 0;
@@ -375,7 +479,10 @@ export function mirrorLabels(projectRoot, options) {
375
479
  let skippedAlready = 0;
376
480
  let skippedNoMatch = 0;
377
481
  let skippedUnreadable = 0;
482
+ let skippedClosed = 0;
483
+ let skippedAuthor = 0;
378
484
  let errors = 0;
485
+ let applyWritesSinceSleep = 0;
379
486
  for (const [repo, issueNumber] of pairs) {
380
487
  const issue = readCachedRawIssue(cacheRoot, repo, issueNumber);
381
488
  if (issue === null) {
@@ -383,6 +490,7 @@ export function mirrorLabels(projectRoot, options) {
383
490
  items.push({
384
491
  repo,
385
492
  issue_number: issueNumber,
493
+ state: null,
386
494
  action: null,
387
495
  reason: null,
388
496
  ruleKind: null,
@@ -394,6 +502,48 @@ export function mirrorLabels(projectRoot, options) {
394
502
  });
395
503
  continue;
396
504
  }
505
+ const state = normalizeIssueState(issue.state);
506
+ // Open-only default: skip closed before classify (avoids mass-stamping archive).
507
+ if (!includeClosed && isClosedState(state)) {
508
+ skippedClosed += 1;
509
+ items.push({
510
+ repo,
511
+ issue_number: issueNumber,
512
+ state,
513
+ action: null,
514
+ reason: null,
515
+ ruleKind: null,
516
+ current: issueLabelNames(issue).sort(),
517
+ desired: [],
518
+ add: [],
519
+ status: "skipped_closed",
520
+ message: "open-only default; pass includeClosed / --include-closed to mirror closed issues",
521
+ });
522
+ continue;
523
+ }
524
+ // Author filter (#3129): AND with open-only / other filters; missing author = non-match.
525
+ if (authorFilter !== null) {
526
+ const login = authorLoginFromRawIssue(issue);
527
+ if (!matchesAuthorFilter(login, authorFilter)) {
528
+ skippedAuthor += 1;
529
+ items.push({
530
+ repo,
531
+ issue_number: issueNumber,
532
+ state,
533
+ action: null,
534
+ reason: null,
535
+ ruleKind: null,
536
+ current: issueLabelNames(issue).sort(),
537
+ desired: [],
538
+ add: [],
539
+ status: "skipped_author",
540
+ message: login === null
541
+ ? `author filter ${authorFilter.display}: missing author on cache row (unknown — excluded)`
542
+ : `author filter ${authorFilter.display}: author.login=${login} does not match`,
543
+ });
544
+ continue;
545
+ }
546
+ }
397
547
  let current;
398
548
  if (useLiveLabels && client !== undefined) {
399
549
  try {
@@ -406,6 +556,7 @@ export function mirrorLabels(projectRoot, options) {
406
556
  items.push({
407
557
  repo,
408
558
  issue_number: issueNumber,
559
+ state,
409
560
  action: null,
410
561
  reason: null,
411
562
  ruleKind: null,
@@ -427,6 +578,7 @@ export function mirrorLabels(projectRoot, options) {
427
578
  items.push({
428
579
  repo,
429
580
  issue_number: issueNumber,
581
+ state,
430
582
  action: null,
431
583
  reason: null,
432
584
  ruleKind: null,
@@ -455,6 +607,7 @@ export function mirrorLabels(projectRoot, options) {
455
607
  items.push({
456
608
  repo,
457
609
  issue_number: issueNumber,
610
+ state,
458
611
  action: null,
459
612
  reason: null,
460
613
  ruleKind: null,
@@ -473,6 +626,7 @@ export function mirrorLabels(projectRoot, options) {
473
626
  items.push({
474
627
  repo,
475
628
  issue_number: issueNumber,
629
+ state,
476
630
  action: classification.action,
477
631
  reason: classification.reason,
478
632
  ruleKind: classification.ruleKind,
@@ -488,6 +642,7 @@ export function mirrorLabels(projectRoot, options) {
488
642
  items.push({
489
643
  repo,
490
644
  issue_number: issueNumber,
645
+ state,
491
646
  action: classification.action,
492
647
  reason: classification.reason,
493
648
  ruleKind: classification.ruleKind,
@@ -498,7 +653,7 @@ export function mirrorLabels(projectRoot, options) {
498
653
  });
499
654
  continue;
500
655
  }
501
- // --apply path: SCM boundary + write
656
+ // --apply path: SCM boundary + write (batched + delay for rate-limit awareness)
502
657
  const mutateGate = isRepoMutationAllowed(repo, root, {
503
658
  allowCrossRepo: options.allowCrossRepo,
504
659
  allowlist: options.repoAllowlist,
@@ -509,6 +664,7 @@ export function mirrorLabels(projectRoot, options) {
509
664
  items.push({
510
665
  repo,
511
666
  issue_number: issueNumber,
667
+ state,
512
668
  action: classification.action,
513
669
  reason: classification.reason,
514
670
  ruleKind: classification.ruleKind,
@@ -525,6 +681,7 @@ export function mirrorLabels(projectRoot, options) {
525
681
  items.push({
526
682
  repo,
527
683
  issue_number: issueNumber,
684
+ state,
528
685
  action: classification.action,
529
686
  reason: classification.reason,
530
687
  ruleKind: classification.ruleKind,
@@ -537,11 +694,18 @@ export function mirrorLabels(projectRoot, options) {
537
694
  continue;
538
695
  }
539
696
  try {
697
+ // Count every SCM write *attempt* toward the batch (including failures) so
698
+ // rate-limit delay still applies under partial failure storms (#3125 Greptile P1).
699
+ if (applyWritesSinceSleep > 0 && applyWritesSinceSleep % batchSize === 0 && delayMs > 0) {
700
+ sleepMs(delayMs);
701
+ }
702
+ applyWritesSinceSleep += 1;
540
703
  client.apply(repo, issueNumber, add, []);
541
704
  applied += 1;
542
705
  items.push({
543
706
  repo,
544
707
  issue_number: issueNumber,
708
+ state,
545
709
  action: classification.action,
546
710
  reason: classification.reason,
547
711
  ruleKind: classification.ruleKind,
@@ -553,9 +717,11 @@ export function mirrorLabels(projectRoot, options) {
553
717
  }
554
718
  catch (exc) {
555
719
  errors += 1;
720
+ const rawMsg = exc instanceof Error ? exc.message : String(exc);
556
721
  items.push({
557
722
  repo,
558
723
  issue_number: issueNumber,
724
+ state,
559
725
  action: classification.action,
560
726
  reason: classification.reason,
561
727
  ruleKind: classification.ruleKind,
@@ -563,10 +729,12 @@ export function mirrorLabels(projectRoot, options) {
563
729
  desired,
564
730
  add,
565
731
  status: "error",
566
- message: exc instanceof Error ? exc.message : String(exc),
732
+ message: formatMissingLabelHint(rawMsg, add),
567
733
  });
734
+ // Partial failure: continue remaining issues (idempotent re-run skips applied).
568
735
  }
569
736
  }
737
+ const digest = buildLabelMirrorDigest(items, sampleLimit);
570
738
  const outcome = {
571
739
  ...outcomeBase,
572
740
  scanned: pairs.length,
@@ -576,35 +744,75 @@ export function mirrorLabels(projectRoot, options) {
576
744
  skipped_already_triaged: skippedAlready,
577
745
  skipped_no_match: skippedNoMatch,
578
746
  skipped_unreadable: skippedUnreadable,
747
+ skipped_closed: skippedClosed,
748
+ skipped_author: skippedAuthor,
579
749
  errors,
750
+ digest,
580
751
  items,
581
752
  };
582
753
  return [errors > 0 ? 1 : 0, outcome];
583
754
  }
584
- /** Human-readable digest for dry-run / apply reports. */
755
+ function formatCountMap(map) {
756
+ const keys = Object.keys(map).sort();
757
+ if (keys.length === 0) {
758
+ return [" (none)"];
759
+ }
760
+ return keys.map((k) => ` ${k}: ${map[k]}`);
761
+ }
762
+ /** Human-readable digest for dry-run / apply reports (bootstrap mass-triage UX). */
585
763
  export function renderLabelMirrorReport(outcome) {
586
764
  const lines = [];
587
765
  const mode = outcome.dry_run ? "dry-run" : "apply";
588
- lines.push(`triage:classify --mirror (${mode})`);
766
+ lines.push(`triage:classify --mirror (${mode}) — bootstrap mass-triage (#1423 Wave 2)`);
767
+ const stateFilter = outcome.filters.include_closed ? "all (include-closed)" : "open-only";
768
+ const repoPart = outcome.filters.repo ?? "*";
769
+ const authorPart = outcome.filters.author ?? "*";
770
+ lines.push(`filters: state=${stateFilter} repo=${repoPart} author=${authorPart}`);
589
771
  lines.push(`scanned=${outcome.scanned} planned=${outcome.planned} applied=${outcome.applied} ` +
590
772
  `unchanged=${outcome.unchanged} already_triaged=${outcome.skipped_already_triaged} ` +
591
- `no_match=${outcome.skipped_no_match} unreadable=${outcome.skipped_unreadable} ` +
592
- `errors=${outcome.errors}`);
773
+ `no_match=${outcome.skipped_no_match} closed_skipped=${outcome.skipped_closed} ` +
774
+ `author_skipped=${outcome.skipped_author} ` +
775
+ `unreadable=${outcome.skipped_unreadable} errors=${outcome.errors}`);
593
776
  lines.push(`idempotencyLabel=${outcome.policy.idempotencyLabel} alwaysLabels=${JSON.stringify(outcome.policy.alwaysLabels)}`);
777
+ if (!outcome.dry_run) {
778
+ lines.push(`apply: batch_size=${outcome.batch_size ?? DEFAULT_APPLY_BATCH_SIZE} delay_ms=${outcome.delay_ms ?? DEFAULT_APPLY_DELAY_MS}`);
779
+ }
594
780
  lines.push("");
595
- const plannedOrApplied = outcome.items.filter((i) => i.status === "planned" || i.status === "applied");
596
- lines.push(outcome.dry_run ? "Would add labels:" : "Added labels:");
597
- if (plannedOrApplied.length === 0) {
781
+ lines.push("By state (planned/applied):");
782
+ lines.push(...formatCountMap(outcome.digest.by_state));
783
+ lines.push("By rule (planned/applied):");
784
+ lines.push(...formatCountMap(outcome.digest.by_rule));
785
+ lines.push("By action (planned/applied):");
786
+ lines.push(...formatCountMap(outcome.digest.by_action));
787
+ lines.push("");
788
+ const writeTotal = outcome.planned + outcome.applied;
789
+ lines.push(outcome.dry_run
790
+ ? `Samples (up to ${outcome.digest.sample_limit} of ${writeTotal} planned):`
791
+ : `Samples (up to ${outcome.digest.sample_limit} of ${writeTotal} applied/planned):`);
792
+ if (outcome.digest.samples.length === 0) {
598
793
  lines.push("- none");
599
794
  }
600
795
  else {
601
- for (const item of plannedOrApplied) {
796
+ for (const item of outcome.digest.samples) {
602
797
  const actionPart = item.action !== null ? ` action=${item.action}` : "";
603
798
  const rulePart = item.ruleKind !== null ? ` rule=${item.ruleKind}` : "";
799
+ const statePart = item.state !== null ? ` state=${item.state}` : "";
604
800
  const addPart = sanitizeReportFragment(item.add.join(", +"));
605
- lines.push(`- ${item.repo}#${item.issue_number}:${actionPart}${rulePart} +${addPart}`);
801
+ lines.push(`- ${item.repo}#${item.issue_number}:${statePart}${actionPart}${rulePart} +${addPart}`);
802
+ }
803
+ if (outcome.digest.sample_truncated) {
804
+ const remaining = writeTotal - outcome.digest.samples.length;
805
+ lines.push(`… and ${remaining} more (use --json for full items list)`);
606
806
  }
607
807
  }
808
+ if (outcome.skipped_closed > 0) {
809
+ lines.push("");
810
+ lines.push(`Skipped closed (open-only default): ${outcome.skipped_closed} — re-run with --include-closed to include archive`);
811
+ }
812
+ if (outcome.skipped_author > 0) {
813
+ lines.push("");
814
+ lines.push(`Skipped (author filter ${outcome.filters.author ?? "?"}): ${outcome.skipped_author}`);
815
+ }
608
816
  const already = outcome.items.filter((i) => i.status === "skipped_already_triaged");
609
817
  if (already.length > 0) {
610
818
  lines.push("");
@@ -617,19 +825,19 @@ export function renderLabelMirrorReport(outcome) {
617
825
  const errs = outcome.items.filter((i) => i.status === "error");
618
826
  if (errs.length > 0) {
619
827
  lines.push("");
620
- lines.push("Errors:");
828
+ lines.push(`Errors (partial failure report; ${errs.length} of ${outcome.scanned}):`);
621
829
  for (const item of errs) {
622
830
  const msg = sanitizeReportFragment(item.message ?? "unknown error");
623
831
  lines.push(`- ${item.repo}#${item.issue_number}: ${msg}`);
624
832
  }
625
833
  }
626
- if (outcome.dry_run && plannedOrApplied.length > 0) {
834
+ if (outcome.dry_run && writeTotal > 0) {
627
835
  lines.push("");
628
- lines.push("Dry-run -- re-run with --mirror --apply to write these labels via SCM.");
836
+ lines.push("Dry-run re-run with --mirror --apply to write these labels via SCM (batched; never triage:accept).");
629
837
  }
630
838
  return `${lines.join("\n")}\n`;
631
839
  }
632
- /** JSON-serializable outcome (stable key order not required). */
840
+ /** JSON-serializable outcome including Wave 2 digest aggregates. */
633
841
  export function labelMirrorOutcomeToJson(outcome) {
634
842
  return {
635
843
  project_root: outcome.project_root,
@@ -641,25 +849,47 @@ export function labelMirrorOutcomeToJson(outcome) {
641
849
  skipped_already_triaged: outcome.skipped_already_triaged,
642
850
  skipped_no_match: outcome.skipped_no_match,
643
851
  skipped_unreadable: outcome.skipped_unreadable,
852
+ skipped_closed: outcome.skipped_closed,
853
+ skipped_author: outcome.skipped_author,
644
854
  errors: outcome.errors,
855
+ filters: {
856
+ include_closed: outcome.filters.include_closed,
857
+ repo: outcome.filters.repo,
858
+ author: outcome.filters.author,
859
+ author_logins: outcome.filters.author_logins,
860
+ },
861
+ digest: {
862
+ by_state: { ...outcome.digest.by_state },
863
+ by_rule: { ...outcome.digest.by_rule },
864
+ by_action: { ...outcome.digest.by_action },
865
+ sample_limit: outcome.digest.sample_limit,
866
+ sample_truncated: outcome.digest.sample_truncated,
867
+ samples: outcome.digest.samples.map((i) => itemToJson(i)),
868
+ },
869
+ ...(outcome.batch_size !== undefined ? { batch_size: outcome.batch_size } : {}),
870
+ ...(outcome.delay_ms !== undefined ? { delay_ms: outcome.delay_ms } : {}),
645
871
  policy: {
646
872
  enabled: outcome.policy.enabled,
647
873
  idempotencyLabel: outcome.policy.idempotencyLabel,
648
874
  alwaysLabels: [...outcome.policy.alwaysLabels],
649
875
  actionLabels: Object.fromEntries(Object.entries(outcome.policy.actionLabels).map(([k, v]) => [k, [...(v ?? [])]])),
650
876
  },
651
- items: outcome.items.map((i) => ({
652
- repo: i.repo,
653
- issue_number: i.issue_number,
654
- action: i.action,
655
- reason: i.reason,
656
- ruleKind: i.ruleKind,
657
- current: [...i.current],
658
- desired: [...i.desired],
659
- add: [...i.add],
660
- status: i.status,
661
- ...(i.message !== undefined ? { message: i.message } : {}),
662
- })),
877
+ items: outcome.items.map((i) => itemToJson(i)),
878
+ };
879
+ }
880
+ function itemToJson(i) {
881
+ return {
882
+ repo: i.repo,
883
+ issue_number: i.issue_number,
884
+ state: i.state,
885
+ action: i.action,
886
+ reason: i.reason,
887
+ ruleKind: i.ruleKind,
888
+ current: [...i.current],
889
+ desired: [...i.desired],
890
+ add: [...i.add],
891
+ status: i.status,
892
+ ...(i.message !== undefined ? { message: i.message } : {}),
663
893
  };
664
894
  }
665
895
  /** Validate triageLabelMirror on a plan object (vbrief_validate hook). */