@codacy/verity-cli 0.28.1-experimental.e910abc → 0.28.1-experimental.f9181dc

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 (3) hide show
  1. package/README.md +5 -0
  2. package/bin/verity.js +845 -116
  3. package/package.json +1 -1
package/bin/verity.js CHANGED
@@ -10389,7 +10389,6 @@ var MAX_DELTA_BYTES = 194560;
10389
10389
  var MAX_FILES = 20;
10390
10390
  var MAX_FILE_BYTES = 51200;
10391
10391
  var DEBOUNCE_SECONDS = 30;
10392
- var MAX_ITERATIONS = 2;
10393
10392
  var MAX_SPEC_FILES = 10;
10394
10393
  var MAX_SPEC_FILE_BYTES = 10240;
10395
10394
  var MAX_TOTAL_SPEC_BYTES = 30720;
@@ -10636,14 +10635,14 @@ async function readGlobalCredential(remote) {
10636
10635
  const parsed = parseCredentialLine(line);
10637
10636
  if (parsed && parsed.remote === key) last = parsed.rec;
10638
10637
  }
10639
- if (last) return last;
10638
+ if (last) return { ...last, keyed: true };
10640
10639
  }
10641
10640
  let plain = null;
10642
10641
  for (const line of lines) {
10643
10642
  const parsed = parseCredentialLine(line);
10644
10643
  if (parsed && parsed.remote === "") plain = parsed.rec;
10645
10644
  }
10646
- return plain;
10645
+ return plain ? { ...plain, keyed: false } : null;
10647
10646
  }
10648
10647
  async function upsertGlobalCredential(remote, rec) {
10649
10648
  const path = globalCredentialsPath();
@@ -10707,6 +10706,36 @@ async function removeSupersededUserCredentials(loginServiceUrl, loginUserId) {
10707
10706
  }
10708
10707
  return removed;
10709
10708
  }
10709
+ async function removeGlobalCredential(remote) {
10710
+ const path = globalCredentialsPath();
10711
+ let content;
10712
+ try {
10713
+ content = await (0, import_promises.readFile)(path, "utf-8");
10714
+ } catch {
10715
+ return false;
10716
+ }
10717
+ const key = encodeRemoteKey(remote);
10718
+ const kept = [];
10719
+ let removed = false;
10720
+ for (const line of content.split("\n")) {
10721
+ const parsed = parseCredentialLine(line);
10722
+ if (parsed && parsed.remote === key) {
10723
+ removed = true;
10724
+ continue;
10725
+ }
10726
+ kept.push(line);
10727
+ }
10728
+ if (!removed) return false;
10729
+ while (kept.length && kept[kept.length - 1].trim() === "") kept.pop();
10730
+ try {
10731
+ await (0, import_promises.writeFile)(path, kept.length ? kept.join("\n") + "\n" : "", { mode: 384 });
10732
+ await (0, import_promises.chmod)(path, 384).catch(() => {
10733
+ });
10734
+ } catch {
10735
+ return false;
10736
+ }
10737
+ return true;
10738
+ }
10710
10739
  function parseLocalCredentialFile(content) {
10711
10740
  const tokenMatch = content.match(/token:\s*((?:gate_|verity_)[a-f0-9]+)/);
10712
10741
  if (!tokenMatch) return null;
@@ -11188,7 +11217,13 @@ async function resolveToken(flagToken) {
11188
11217
  if (rec) {
11189
11218
  return {
11190
11219
  ok: true,
11191
- data: { token: rec.token, source: "global", userId: rec.userId, email: rec.email }
11220
+ data: {
11221
+ token: rec.token,
11222
+ source: "global",
11223
+ userId: rec.userId,
11224
+ email: rec.email,
11225
+ keyed: rec.keyed
11226
+ }
11192
11227
  };
11193
11228
  }
11194
11229
  const local = await readLegacyLocalCredential();
@@ -11220,6 +11255,16 @@ function reverifyNudge(who) {
11220
11255
  }
11221
11256
  return null;
11222
11257
  }
11258
+ function isLegacyPerRepoCredential(auth2) {
11259
+ return auth2.source === "local" || auth2.source === "global" && auth2.keyed === true;
11260
+ }
11261
+ async function shouldUpgradeOnLogin(auth2) {
11262
+ if (!isLegacyPerRepoCredential(auth2)) return false;
11263
+ if (auth2.userId == null) return true;
11264
+ const bare = await readGlobalCredential("");
11265
+ if (bare?.userId == null) return true;
11266
+ return bare.userId === auth2.userId;
11267
+ }
11223
11268
  function authDenialRemedy(error) {
11224
11269
  if (error.startsWith("STALE_VERIFICATION")) {
11225
11270
  return {
@@ -11233,6 +11278,12 @@ function authDenialRemedy(error) {
11233
11278
  remedy: 'No access grant for this repository \u2014 run "verity login" to refresh your grants (a repo granted after your last login needs one), or get write access to it.'
11234
11279
  };
11235
11280
  }
11281
+ if (error.startsWith("INVALID_TOKEN")) {
11282
+ return {
11283
+ code: "INVALID_TOKEN",
11284
+ remedy: 'Your Verity login has expired or was revoked \u2014 run "verity login" to sign in again.'
11285
+ };
11286
+ }
11236
11287
  return null;
11237
11288
  }
11238
11289
  async function probeService(serviceUrl, verbose) {
@@ -11273,6 +11324,7 @@ async function maybeHealServiceUrl(resolution, verbose) {
11273
11324
 
11274
11325
  // src/lib/register.ts
11275
11326
  var readline = __toESM(require("node:readline/promises"));
11327
+ var import_node_os = require("node:os");
11276
11328
 
11277
11329
  // src/lib/provider-auth.ts
11278
11330
  var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -11460,6 +11512,15 @@ async function registerProject(opts) {
11460
11512
  }
11461
11513
  return { ok: true, data: { projectId: project_id, serviceUrl: service_url, email, userId } };
11462
11514
  }
11515
+ function deviceLabel() {
11516
+ const override = process.env.VERITY_DEVICE_LABEL?.trim();
11517
+ if (override) return override;
11518
+ try {
11519
+ return (0, import_node_os.hostname)() || void 0;
11520
+ } catch {
11521
+ return void 0;
11522
+ }
11523
+ }
11463
11524
  async function loginOnce(opts) {
11464
11525
  const usingTokenOverride = Boolean(process.env.VERITY_PROVIDER_TOKEN);
11465
11526
  const providerAuth = await githubDeviceFlow();
@@ -11480,13 +11541,19 @@ async function loginOnce(opts) {
11480
11541
  path: "/auth/login",
11481
11542
  serviceUrl: opts.serviceUrl,
11482
11543
  extraHeaders: { "X-Provider-Token": providerToken },
11544
+ // Label the session so its owner can tell their machines apart in
11545
+ // `verity sessions list` — a list of identical "login" rows is unusable when
11546
+ // the question is "which of these is the laptop I lost?". The hostname is the
11547
+ // useful default; VERITY_DEVICE_LABEL overrides it for anyone who would
11548
+ // rather not send it. Server-side it is sanitized and capped.
11549
+ body: { device: deviceLabel() },
11483
11550
  verbose: opts.verbose,
11484
11551
  cmd: "login"
11485
11552
  });
11486
11553
  if (!result.ok) {
11487
11554
  return { ok: false, error: result.error };
11488
11555
  }
11489
- const { token, service_url, user_id, user, repo_count } = result.data;
11556
+ const { token, service_url, user_id, user, repo_count, expires_at } = result.data;
11490
11557
  const loginUserId = user_id ?? user?.id ?? void 0;
11491
11558
  try {
11492
11559
  await upsertGlobalCredential("", {
@@ -11510,15 +11577,16 @@ async function loginOnce(opts) {
11510
11577
  email: user?.email,
11511
11578
  userId: loginUserId,
11512
11579
  repoCount: repo_count ?? 0,
11513
- prunedCredentials: pruned
11580
+ prunedCredentials: pruned,
11581
+ expiresAt: expires_at
11514
11582
  }
11515
11583
  };
11516
11584
  }
11517
11585
 
11518
11586
  // src/commands/auth.ts
11519
11587
  function registerAuthCommands(program2) {
11520
- const auth = program2.command("auth").description("Manage project authentication");
11521
- auth.command("register").description("Register a project with Verity").requiredOption("--project <name>", "Project name").option("--remote <url>", "Git remote URL (auto-detected if omitted)").action(async (opts) => {
11588
+ const auth2 = program2.command("auth").description("Manage project authentication");
11589
+ auth2.command("register").description("Register a project with Verity").requiredOption("--project <name>", "Project name").option("--remote <url>", "Git remote URL (auto-detected if omitted)").action(async (opts) => {
11522
11590
  const globals = program2.opts();
11523
11591
  const serviceUrl = globals.serviceUrl ?? DEFAULT_SERVICE_URL;
11524
11592
  let remote = opts.remote;
@@ -11545,7 +11613,7 @@ function registerAuthCommands(program2) {
11545
11613
  if (email) printInfo(`Authenticated as: ${email}`);
11546
11614
  printJson({ project_id: projectId, service_url: resolvedUrl });
11547
11615
  });
11548
- auth.command("verify").description("Verify the current token is valid").action(async () => {
11616
+ auth2.command("verify").description("Verify the current token is valid").action(async () => {
11549
11617
  const globals = program2.opts();
11550
11618
  const tokenResult = await resolveToken(globals.token);
11551
11619
  if (!tokenResult.ok) {
@@ -11571,7 +11639,7 @@ function registerAuthCommands(program2) {
11571
11639
  printInfo(`Token valid. Project: ${result.data.project_name}`);
11572
11640
  printJson(result.data);
11573
11641
  });
11574
- auth.command("discover").description("Check if a project is registered").option("--remote <url>", "Git remote URL (auto-detected if omitted)").action(async (opts) => {
11642
+ auth2.command("discover").description("Check if a project is registered").option("--remote <url>", "Git remote URL (auto-detected if omitted)").action(async (opts) => {
11575
11643
  const globals = program2.opts();
11576
11644
  let remote = opts.remote;
11577
11645
  if (!remote) {
@@ -11627,13 +11695,19 @@ function registerLoginCommand(program2) {
11627
11695
  const who = await whoami(existing.data.token, serviceUrl, globals.verbose);
11628
11696
  if (who.ok && who.data.logged_in) {
11629
11697
  const nudge = reverifyNudge(who.data);
11630
- if (!nudge) {
11698
+ const upgrade = await shouldUpgradeOnLogin(existing.data);
11699
+ if (!nudge && !upgrade) {
11631
11700
  printInfo(`Already logged in as ${who.data.email ?? `user #${who.data.user_id}`}. \u2713`);
11632
11701
  printInfo(" Re-authenticate with: verity login --force");
11633
11702
  return;
11634
11703
  }
11635
- printWarn(nudge);
11636
- printInfo("Re-verifying your repository access\u2026");
11704
+ if (nudge) {
11705
+ printWarn(nudge);
11706
+ printInfo("Re-verifying your repository access\u2026");
11707
+ } else {
11708
+ printInfo("You are signed in with a per-repository token (the old format).");
11709
+ printInfo(" Upgrading to a single login that covers every repository you can write to\u2026");
11710
+ }
11637
11711
  } else if (who.ok && who.data.anonymous) {
11638
11712
  printInfo("You have an anonymous token (the gate runs, but nothing is saved). Logging you in\u2026");
11639
11713
  } else if (!who.ok) {
@@ -11650,6 +11724,10 @@ function registerLoginCommand(program2) {
11650
11724
  const identity = out.email ?? (out.userId != null ? `user #${out.userId}` : "your account");
11651
11725
  printInfo(`Logged in as ${identity}. \u2713`);
11652
11726
  printInfo(` Access granted to ${out.repoCount} ${out.repoCount === 1 ? "repository" : "repositories"}.`);
11727
+ if (out.expiresAt) {
11728
+ printInfo(` This login expires on ${out.expiresAt.slice(0, 10)} \u2014 "verity login" again to renew.`);
11729
+ printInfo(' See your machines with "verity sessions list"; sign out with "verity logout".');
11730
+ }
11653
11731
  printInfo(" Runs, history, and cloud memory now sync to Verity everywhere you have write access.");
11654
11732
  if (out.prunedCredentials > 0) {
11655
11733
  printVerbose(`Pruned ${out.prunedCredentials} superseded per-repository credential line(s).`, globals.verbose);
@@ -11680,14 +11758,24 @@ function registerLoginCommand(program2) {
11680
11758
  const rec = await readGlobalCredential(remote);
11681
11759
  if (rec && rec.token !== out.token) {
11682
11760
  const otherBackend = rec.serviceUrl != null && rec.serviceUrl !== out.serviceUrl;
11761
+ const otherIdentity = rec.userId != null && out.userId != null && rec.userId !== out.userId;
11683
11762
  if (otherBackend) {
11684
11763
  printWarn(` Note: this repository is pinned to a different Verity service (${rec.serviceUrl})`);
11685
11764
  printWarn(" by its own credential line, which takes precedence here \u2014 this login does not");
11686
11765
  printWarn(" change that. To move the repository, remove its line from ~/.verity/credentials.");
11766
+ printWarn(' Until that line is removed, "verity login" here cannot fast-path and will run');
11767
+ printWarn(" the full GitHub flow every time.");
11768
+ } else if (otherIdentity) {
11769
+ printWarn(" Note: this repository uses a different account's credential, which takes");
11770
+ printWarn(' precedence here \u2014 this login leaves it in place, and "verity login" in this');
11771
+ printWarn(" repository will report that account. Remove its line from ~/.verity/credentials");
11772
+ printWarn(" only if you want this repository on the login you just completed.");
11687
11773
  } else {
11688
11774
  const kind = rec.userId != null ? "superseded per-repository" : "anonymous project-specific";
11689
11775
  printWarn(` Note: this repository has a ${kind} credential that takes`);
11690
11776
  printWarn(" precedence here. Remove its line from ~/.verity/credentials to use your login.");
11777
+ printWarn(' Until then, "verity login" in this repository re-runs the full GitHub flow');
11778
+ printWarn(" every time.");
11691
11779
  }
11692
11780
  }
11693
11781
  }
@@ -11809,6 +11897,177 @@ function registerTokenCommand(program2) {
11809
11897
  });
11810
11898
  }
11811
11899
 
11900
+ // src/commands/sessions.ts
11901
+ function shortDate(iso) {
11902
+ return iso ? iso.slice(0, 10) : "\u2014";
11903
+ }
11904
+ function daysUntil(iso) {
11905
+ if (!iso) return null;
11906
+ const ms = Date.parse(iso);
11907
+ if (Number.isNaN(ms)) return null;
11908
+ return Math.round((ms - Date.now()) / 864e5);
11909
+ }
11910
+ async function auth(globals) {
11911
+ const tokenResult = await resolveToken(globals.token);
11912
+ if (!tokenResult.ok) {
11913
+ printError(tokenResult.error);
11914
+ process.exit(1);
11915
+ }
11916
+ const urlResult = await resolveServiceUrl(globals.serviceUrl);
11917
+ if (!urlResult.ok) {
11918
+ printError(urlResult.error);
11919
+ process.exit(1);
11920
+ }
11921
+ return { token: tokenResult.data.token, serviceUrl: urlResult.data };
11922
+ }
11923
+ function explain(error) {
11924
+ if (error.startsWith("FORBIDDEN")) {
11925
+ printInfo(' Sessions belong to a logged-in account \u2014 run "verity login" first.');
11926
+ } else if (error.startsWith("INVALID_TOKEN")) {
11927
+ printInfo(' This login has expired or was revoked \u2014 run "verity login" to sign in again.');
11928
+ }
11929
+ }
11930
+ function registerSessionsCommands(program2) {
11931
+ const sessions = program2.command("sessions").description("List and revoke your Verity logins (one per machine)");
11932
+ sessions.command("list").description("List your active logins \u2014 device, last use, and expiry").option("--json", "Output raw JSON").action(async (opts) => {
11933
+ const globals = program2.opts();
11934
+ const { token, serviceUrl } = await auth(globals);
11935
+ const result = await apiRequest({
11936
+ method: "GET",
11937
+ path: "/auth/sessions",
11938
+ serviceUrl,
11939
+ token,
11940
+ verbose: globals.verbose,
11941
+ cmd: "sessions"
11942
+ });
11943
+ if (!result.ok) {
11944
+ printError(result.error);
11945
+ explain(result.error);
11946
+ process.exit(1);
11947
+ }
11948
+ if (opts.json) {
11949
+ printJson(result.data);
11950
+ return;
11951
+ }
11952
+ const list = result.data.sessions;
11953
+ if (list.length === 0) {
11954
+ printInfo('No active logins. (Run "verity login".)');
11955
+ return;
11956
+ }
11957
+ printInfo(`${list.length} active login${list.length === 1 ? "" : "s"}:`);
11958
+ printInfo("");
11959
+ printInfo(`${"SESSION ID".padEnd(38)}${"DEVICE".padEnd(24)}${"CREATED".padEnd(12)}${"LAST USED".padEnd(12)}EXPIRES`);
11960
+ for (const s of list) {
11961
+ const days = daysUntil(s.expires_at);
11962
+ const expiry = s.expires_at ? `${shortDate(s.expires_at)}${days != null ? ` (${days}d)` : ""}` : "never";
11963
+ const device = (s.device ?? "login").slice(0, 22);
11964
+ printInfo(
11965
+ `${s.id.padEnd(38)}${device.padEnd(24)}${shortDate(s.created_at).padEnd(12)}${shortDate(s.last_used_at).padEnd(12)}${expiry}${s.current ? " \u2190 this machine" : ""}`
11966
+ );
11967
+ }
11968
+ printInfo("");
11969
+ printInfo("Revoke one: verity sessions revoke <session-id>");
11970
+ printInfo("Sign out everywhere else: verity logout --others");
11971
+ });
11972
+ sessions.command("revoke <session-id>").description("Revoke one login. Revoking this machine's also clears the local credential").action(async (sessionId) => {
11973
+ const globals = program2.opts();
11974
+ const { token, serviceUrl } = await auth(globals);
11975
+ const result = await apiRequest({
11976
+ method: "DELETE",
11977
+ path: `/auth/sessions/${encodeURIComponent(sessionId)}`,
11978
+ serviceUrl,
11979
+ token,
11980
+ verbose: globals.verbose,
11981
+ cmd: "sessions-revoke"
11982
+ });
11983
+ if (!result.ok) {
11984
+ printError(result.error);
11985
+ if (result.http_status === 404) {
11986
+ printInfo(' No session with that id on your account \u2014 check "verity sessions list".');
11987
+ }
11988
+ explain(result.error);
11989
+ process.exit(1);
11990
+ }
11991
+ printInfo(`Session ${sessionId} revoked. \u2713`);
11992
+ if (result.data.was_current) {
11993
+ const cleared = await removeGlobalCredential("");
11994
+ printInfo(cleared ? ' That was this machine \u2014 local credential cleared. Run "verity login" to sign back in.' : ' That was this machine. Run "verity login" to sign back in.');
11995
+ }
11996
+ });
11997
+ }
11998
+ function registerLogoutCommand(program2) {
11999
+ program2.command("logout").description("Sign out of Verity on this machine (--all / --others for every machine)").option("--all", "Revoke every login on every machine, including this one").option("--others", "Revoke every login EXCEPT this machine (e.g. a lost laptop)").action(async (opts) => {
12000
+ const globals = program2.opts();
12001
+ if (opts.all && opts.others) {
12002
+ printError("Use either --all or --others, not both.");
12003
+ process.exit(1);
12004
+ }
12005
+ const { token, serviceUrl } = await auth(globals);
12006
+ if (opts.all || opts.others) {
12007
+ const result = await apiRequest({
12008
+ method: "DELETE",
12009
+ path: opts.others ? "/auth/sessions?others=true" : "/auth/sessions",
12010
+ serviceUrl,
12011
+ token,
12012
+ verbose: globals.verbose,
12013
+ cmd: "logout"
12014
+ });
12015
+ if (!result.ok) {
12016
+ printError(result.error);
12017
+ explain(result.error);
12018
+ process.exit(1);
12019
+ }
12020
+ const n = result.data.revoked;
12021
+ printInfo(`Revoked ${n} login${n === 1 ? "" : "s"}. \u2713`);
12022
+ if (opts.others) {
12023
+ printInfo(" This machine is still signed in.");
12024
+ return;
12025
+ }
12026
+ const cleared2 = await removeGlobalCredential("");
12027
+ if (cleared2) printInfo(" Local credential cleared.");
12028
+ printInfo(' Run "verity login" to sign back in.');
12029
+ return;
12030
+ }
12031
+ const list = await apiRequest({
12032
+ method: "GET",
12033
+ path: "/auth/sessions",
12034
+ serviceUrl,
12035
+ token,
12036
+ verbose: globals.verbose,
12037
+ cmd: "logout"
12038
+ });
12039
+ if (!list.ok) {
12040
+ printError(list.error);
12041
+ explain(list.error);
12042
+ process.exit(1);
12043
+ }
12044
+ const current = list.data.sessions.find((s) => s.current);
12045
+ if (!current) {
12046
+ printWarn("This machine is not signed in with a Verity login.");
12047
+ const cleared2 = await removeGlobalCredential("");
12048
+ if (cleared2) printInfo(" Cleared the local login credential anyway.");
12049
+ return;
12050
+ }
12051
+ const revoked = await apiRequest({
12052
+ method: "DELETE",
12053
+ path: `/auth/sessions/${current.id}`,
12054
+ serviceUrl,
12055
+ token,
12056
+ verbose: globals.verbose,
12057
+ cmd: "logout"
12058
+ });
12059
+ if (!revoked.ok) {
12060
+ printError(revoked.error);
12061
+ explain(revoked.error);
12062
+ process.exit(1);
12063
+ }
12064
+ const cleared = await removeGlobalCredential("");
12065
+ printInfo("Signed out on this machine. \u2713");
12066
+ if (cleared) printInfo(" Local credential cleared.");
12067
+ printInfo(' Your other machines are unaffected \u2014 use "verity logout --all" for all of them.');
12068
+ });
12069
+ }
12070
+
11812
12071
  // src/lib/hooks.ts
11813
12072
  var import_promises4 = require("node:fs/promises");
11814
12073
  var import_node_path5 = require("node:path");
@@ -12430,7 +12689,7 @@ function getRecentCommitMessages() {
12430
12689
  // src/lib/context-identity.ts
12431
12690
  var import_node_crypto2 = require("node:crypto");
12432
12691
  var import_node_fs5 = require("node:fs");
12433
- var import_node_os = require("node:os");
12692
+ var import_node_os2 = require("node:os");
12434
12693
  var import_node_path6 = require("node:path");
12435
12694
  var SHARED_SENTINELS = /* @__PURE__ */ new Set([
12436
12695
  "",
@@ -12485,7 +12744,7 @@ function contextIdentity(input) {
12485
12744
  }
12486
12745
  function verityHome() {
12487
12746
  const override = process.env.VERITY_HOME;
12488
- return override && override.trim() ? (0, import_node_path6.resolve)(override) : (0, import_node_path6.join)((0, import_node_os.homedir)(), ".verity");
12747
+ return override && override.trim() ? (0, import_node_path6.resolve)(override) : (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".verity");
12489
12748
  }
12490
12749
  function dossierDir(identity) {
12491
12750
  return (0, import_node_path6.join)(verityHome(), "sessions", identity.userKey, identity.treeKey, identity.sessionKey);
@@ -13331,6 +13590,70 @@ var import_node_fs10 = require("node:fs");
13331
13590
  var import_node_crypto5 = require("node:crypto");
13332
13591
  var import_node_path11 = require("node:path");
13333
13592
 
13593
+ // src/lib/skip-detection.ts
13594
+ function isBareAckPrompt(prompt) {
13595
+ if (typeof prompt !== "string") return false;
13596
+ const trimmed = prompt.trim();
13597
+ if (trimmed.length === 0) return false;
13598
+ if (trimmed.length > 20) return false;
13599
+ const bareAckPattern = /^(\d{1,2}|y|n|yes|no|yep|nope|ok(ay)?|sure|skip|cancel|stop|done|noted|got\s+it|sounds\s+good|thanks|thank\s+you|thx)[.!?]*$/i;
13600
+ return bareAckPattern.test(trimmed);
13601
+ }
13602
+ function isContinuationPrompt(prompt) {
13603
+ if (typeof prompt !== "string") return false;
13604
+ const trimmed = prompt.trim();
13605
+ if (trimmed.length === 0) return false;
13606
+ if (trimmed.length > 24) return false;
13607
+ const continuation = /^(let['’]?s\s+(go|do\s+it|start|continue)|go|go\s+ahead|go\s+on|proceed|continue|carry\s+on|keep\s+going|do\s+it|make\s+it\s+so|next|start|begin|ship\s+it|yes\s+please|please\s+continue|perfect|great|nice|excellent|agreed)[.!]*$/i;
13608
+ return continuation.test(trimmed) || isBareAckPrompt(trimmed);
13609
+ }
13610
+ function resolveGoalPrompt(prompts) {
13611
+ if (prompts.length === 0) return null;
13612
+ const latest = prompts[prompts.length - 1];
13613
+ if (!isContinuationPrompt(latest.prompt)) return { entry: latest, turnsBack: 0 };
13614
+ for (let i = prompts.length - 2; i >= 0; i--) {
13615
+ if (!isContinuationPrompt(prompts[i].prompt)) {
13616
+ return { entry: prompts[i], turnsBack: prompts.length - 1 - i };
13617
+ }
13618
+ }
13619
+ return { entry: latest, turnsBack: 0 };
13620
+ }
13621
+ function isReflectionQuestion(response) {
13622
+ if (!response || typeof response !== "string") return false;
13623
+ const markers = [
13624
+ /reflection\s+for\s+future\s+agents/i,
13625
+ /what(?:'s|\s+is)\s+one\s+thing\s+you\s+learned/i,
13626
+ /say\s+['"]?skip['"]?\s+to\s+skip/i,
13627
+ /quick\s+reflection\s+question/i,
13628
+ // Post-flip (VRT-21): the agent drafts the reflection itself and, when
13629
+ // interactive, asks the user to confirm/correct before recording. That
13630
+ // turn authors no code either, so it's still a reflection turn.
13631
+ /reflection\s+draft/i,
13632
+ /confirm,?\s+correct,?\s+or\s+add/i
13633
+ ];
13634
+ return markers.some((m) => m.test(response));
13635
+ }
13636
+ function isMetaTaskLabel(label2) {
13637
+ if (label2 === null || label2 === void 0) return false;
13638
+ if (typeof label2 !== "string") return false;
13639
+ const trimmed = label2.trim();
13640
+ if (trimmed.length === 0) return true;
13641
+ const metaPatterns = [
13642
+ /^verity\s+[\w-]+\s+response$/i,
13643
+ // "Verity reflect response"
13644
+ /^simple user response$/i,
13645
+ /^verity\s+command$/i,
13646
+ // "Verity command"
13647
+ /^user\s+(question|reply|response|ack)$/i
13648
+ ];
13649
+ return metaPatterns.some((p) => p.test(trimmed));
13650
+ }
13651
+ function shouldSkipForBareAck(input) {
13652
+ if (!isBareAckPrompt(input.prompt)) return false;
13653
+ if (input.turnAuthoredCode) return false;
13654
+ return input.canSeeTurnAuthorship;
13655
+ }
13656
+
13334
13657
  // src/lib/dossier.ts
13335
13658
  var import_node_fs9 = require("node:fs");
13336
13659
  var import_node_crypto4 = require("node:crypto");
@@ -13700,6 +14023,13 @@ function reduce(state, events, now) {
13700
14023
  }
13701
14024
  case "verdict": {
13702
14025
  state.meta.last_verdict_seq = ev.seq;
14026
+ state.meta.channel = {
14027
+ emittedLast: ev.emitted === true,
14028
+ // Reset by ANY movement, so the counter measures a standstill rather
14029
+ // than session length.
14030
+ consecutiveIdle: ev.idle === false ? 0 : (state.meta.channel?.consecutiveIdle ?? 0) + 1
14031
+ };
14032
+ state.meta.last_adjudication = ev.intent_verdict ? { verdict: ev.intent_verdict, score: ev.intent_score ?? null, at: ev.at, decision: ev.decision } : void 0;
13703
14033
  if (ev.intent_sig) {
13704
14034
  state.meta.intent_repeat = state.meta.intent_repeat && state.meta.intent_repeat.sig === ev.intent_sig ? { sig: ev.intent_sig, consecutive: state.meta.intent_repeat.consecutive + 1 } : { sig: ev.intent_sig, consecutive: 1 };
13705
14035
  } else {
@@ -14066,6 +14396,14 @@ function projectMemory(state, opts) {
14066
14396
  ...active.delivered && { delivered: active.delivered }
14067
14397
  };
14068
14398
  }
14399
+ if (opts.capture) {
14400
+ const missed = Math.max(0, opts.capture.seen - opts.capture.captured);
14401
+ p.capture = { seen: opts.capture.seen, captured: opts.capture.captured, missed };
14402
+ }
14403
+ if (state.meta.last_adjudication) {
14404
+ const a = state.meta.last_adjudication;
14405
+ p.last_adjudication = { verdict: a.verdict, score: a.score, at: a.at };
14406
+ }
14069
14407
  const ageOf = (seq, carried) => carried ? "carried" : seq > lastVerdictSeq ? "this_turn" : "this_session";
14070
14408
  if (opts.spoken.length > 0) {
14071
14409
  p.statements = opts.spoken.map((s) => ({
@@ -14308,7 +14646,8 @@ function recall(d, input) {
14308
14646
  continuity,
14309
14647
  spoken: reanchored.spoken,
14310
14648
  refused: reanchored.dropped.length,
14311
- lastVerdictSeq
14649
+ lastVerdictSeq,
14650
+ ...input.capture && { capture: input.capture }
14312
14651
  });
14313
14652
  return {
14314
14653
  state: effective,
@@ -14419,7 +14758,19 @@ function sessionDossier(token, sessionId) {
14419
14758
  const d = openDossier(identity);
14420
14759
  return d ? { d, identity } : null;
14421
14760
  }
14761
+ function hasActiveGoal(d) {
14762
+ try {
14763
+ if (!(0, import_node_fs10.existsSync)(d.eventsPath)) return false;
14764
+ return (0, import_node_fs10.readFileSync)(d.eventsPath, "utf8").includes('"k":"goal"');
14765
+ } catch {
14766
+ return false;
14767
+ }
14768
+ }
14422
14769
  function recordGoal(d, prompt, source = "prompt") {
14770
+ if (source === "prompt" && isContinuationPrompt(prompt) && hasActiveGoal(d)) {
14771
+ appendEvent(d, { k: "goal_continue", text: prompt.slice(0, 64) });
14772
+ return;
14773
+ }
14423
14774
  const text = prompt.slice(0, MAX_GOAL_CHARS);
14424
14775
  appendEvent(d, {
14425
14776
  k: "goal",
@@ -14522,6 +14873,11 @@ function recordVerdict(d, v) {
14522
14873
  line_sha: at !== void 0 ? lineSha(at) : null
14523
14874
  });
14524
14875
  }
14876
+ const foldedNow = foldDossier(d);
14877
+ const sig = intentSignature(v.intent, {
14878
+ goalSeq: foldedNow.goal.find((g) => g.status === "active")?.seq ?? 0,
14879
+ idle: v.idle !== false
14880
+ });
14525
14881
  appendEvent(d, {
14526
14882
  k: "verdict",
14527
14883
  run_id: v.runId,
@@ -14529,13 +14885,19 @@ function recordVerdict(d, v) {
14529
14885
  watermark_sha: v.watermarkSha,
14530
14886
  branch: v.branch,
14531
14887
  decision: v.decision,
14532
- ...intentSignature(v.intent) && { intent_sig: intentSignature(v.intent) }
14888
+ ...sig && { intent_sig: sig },
14889
+ emitted: v.emitted === true,
14890
+ idle: v.idle !== false,
14891
+ ...v.intent?.verdict && { intent_verdict: v.intent.verdict },
14892
+ ...typeof v.intent?.score === "number" && { intent_score: v.intent.score }
14533
14893
  });
14534
14894
  }
14535
- function intentSignature(intent) {
14895
+ function intentSignature(intent, ctx) {
14536
14896
  if (!intent?.verdict) return null;
14537
14897
  if (intent.verdict !== "misaligned" && intent.verdict !== "partial") return null;
14538
- return `${intent.verdict}:${lineSha(intent.gaps?.[0] ?? "")}`;
14898
+ const goal = ctx ? `g${ctx.goalSeq}` : "g?";
14899
+ const moved = ctx?.idle === false ? "active" : "idle";
14900
+ return `${intent.verdict}:${goal}:${moved}`;
14539
14901
  }
14540
14902
  function toRegister(severity) {
14541
14903
  switch (severity) {
@@ -14572,7 +14934,9 @@ function recallMemory(d, identity, opts) {
14572
14934
  const state = foldDossier(d);
14573
14935
  const watermark = state.meta.watermark?.sha ?? null;
14574
14936
  const watermarkPaths = (state.authored ?? []).map((a) => a.path);
14937
+ const captureCmp = typeof opts.userMessagesSeen === "number" ? { seen: opts.userMessagesSeen, captured: state.meta.goal_chain } : void 0;
14575
14938
  const r = recall(d, {
14939
+ ...captureCmp && { capture: captureCmp },
14576
14940
  identity,
14577
14941
  currentSessionKey: opts.currentSessionKey,
14578
14942
  branchNow: getCurrentBranch(),
@@ -14829,6 +15193,8 @@ function collectCodeDelta(files, opts) {
14829
15193
  let totalSize = 0;
14830
15194
  let truncationReason = null;
14831
15195
  const droppedPaths = [];
15196
+ const excluded = [];
15197
+ const exclude = (path, reason) => excluded.push({ path, reason, stage: "collectCodeDelta", kind: "capacity" });
14832
15198
  for (const filepath of sorted) {
14833
15199
  if (result.length >= maxFiles) {
14834
15200
  truncationReason ??= "max_files";
@@ -14836,14 +15202,21 @@ function collectCodeDelta(files, opts) {
14836
15202
  continue;
14837
15203
  }
14838
15204
  const resolved = resolveFile(filepath);
14839
- if (!resolved) continue;
15205
+ if (!resolved) {
15206
+ exclude(filepath, "path-not-resolvable");
15207
+ continue;
15208
+ }
14840
15209
  let size;
14841
15210
  try {
14842
15211
  size = (0, import_node_fs11.statSync)(resolved).size;
14843
15212
  } catch {
15213
+ exclude(filepath, "not-stattable");
15214
+ continue;
15215
+ }
15216
+ if (size > maxFileBytes) {
15217
+ exclude(filepath, `over-file-size-limit-${maxFileBytes}b`);
14844
15218
  continue;
14845
15219
  }
14846
- if (size > maxFileBytes) continue;
14847
15220
  if (totalSize + size > maxTotalBytes) {
14848
15221
  truncationReason ??= "max_total_bytes";
14849
15222
  const idx = sorted.indexOf(filepath);
@@ -14854,6 +15227,7 @@ function collectCodeDelta(files, opts) {
14854
15227
  try {
14855
15228
  content = (0, import_node_fs11.readFileSync)(resolved, "utf-8");
14856
15229
  } catch {
15230
+ exclude(filepath, "not-readable");
14857
15231
  continue;
14858
15232
  }
14859
15233
  totalSize += size;
@@ -14867,10 +15241,14 @@ function collectCodeDelta(files, opts) {
14867
15241
  (sum, f) => sum + f.content.split("\n").length,
14868
15242
  0
14869
15243
  );
15244
+ for (const path of droppedPaths) {
15245
+ exclude(path, truncationReason === "max_files" ? "max-files-cap" : "max-total-bytes-cap");
15246
+ }
14870
15247
  return {
14871
15248
  files: result,
14872
15249
  total_lines: totalLines,
14873
15250
  total_files: result.length,
15251
+ excluded,
14874
15252
  ...truncationReason && {
14875
15253
  truncated: {
14876
15254
  reason: truncationReason,
@@ -16040,40 +16418,40 @@ function narrowToRecent(files, sessionId) {
16040
16418
  });
16041
16419
  return recent.length > 0 ? recent : files;
16042
16420
  }
16043
- function readIteration(currentCommit, _contentHash) {
16044
- if (!(0, import_node_fs15.existsSync)(ITERATION_FILE)) return 1;
16421
+ function readIterationState(currentCommit) {
16422
+ if (!(0, import_node_fs15.existsSync)(ITERATION_FILE)) return { iteration: 1, fingerprint: null };
16045
16423
  try {
16046
16424
  const stored = (0, import_node_fs15.readFileSync)(ITERATION_FILE, "utf-8").trim();
16047
16425
  const parts = stored.split(":");
16048
16426
  const iter = parseInt(parts[0], 10);
16049
16427
  const storedCommit = parts[1] ?? "";
16050
16428
  const storedTimestamp = parseInt(parts[2] ?? "0", 10);
16051
- if (isNaN(iter)) return 1;
16052
- if (storedCommit !== currentCommit) return 1;
16429
+ const fingerprint = parts.slice(3).join(":") || null;
16430
+ if (isNaN(iter)) return { iteration: 1, fingerprint: null };
16431
+ if (storedCommit !== currentCommit) return { iteration: 1, fingerprint: null };
16053
16432
  if (storedTimestamp > 0) {
16054
16433
  const elapsed = Math.floor(Date.now() / 1e3) - storedTimestamp;
16055
- if (elapsed > 600) return 1;
16434
+ if (elapsed > 600) return { iteration: 1, fingerprint: null };
16056
16435
  }
16057
- return iter;
16436
+ return { iteration: iter, fingerprint };
16058
16437
  } catch {
16059
- return 1;
16438
+ return { iteration: 1, fingerprint: null };
16060
16439
  }
16061
16440
  }
16062
- function checkMaxIterations(currentCommit, maxIterations = MAX_ITERATIONS, contentHash) {
16063
- const iteration = readIteration(currentCommit, contentHash);
16064
- if (iteration > maxIterations) {
16065
- writeIteration(1, currentCommit, contentHash);
16066
- return {
16067
- skip: `Max Verity iterations (${maxIterations}) reached \u2014 accepting to prevent infinite loop. Human review required before deploying.`,
16068
- iteration
16069
- };
16070
- }
16071
- return { skip: null, iteration };
16441
+ function findingsFingerprint(findings) {
16442
+ const keys = findings.map((f) => `${String(f.pattern_id ?? "?")}|${String(f.file ?? "?")}`).filter((k) => k !== "?|?");
16443
+ return [...new Set(keys)].sort().join(",");
16444
+ }
16445
+ function isSameProblem(previous, current) {
16446
+ if (!previous || !current) return false;
16447
+ const prev = new Set(previous.split(","));
16448
+ return current.split(",").some((k) => prev.has(k));
16072
16449
  }
16073
- function writeIteration(iteration, commit, _contentHash) {
16450
+ function writeIteration(iteration, commit, _contentHash, fingerprint) {
16074
16451
  (0, import_node_fs15.mkdirSync)(VERITY_DIR, { recursive: true });
16075
16452
  const ts = Math.floor(Date.now() / 1e3);
16076
- (0, import_node_fs15.writeFileSync)(ITERATION_FILE, `${iteration}:${commit}:${ts}`);
16453
+ const fp = fingerprint ? `:${fingerprint}` : "";
16454
+ (0, import_node_fs15.writeFileSync)(ITERATION_FILE, `${iteration}:${commit}:${ts}${fp}`);
16077
16455
  }
16078
16456
 
16079
16457
  // src/lib/static-analysis.ts
@@ -16322,7 +16700,7 @@ function resolveTaskContext(opts) {
16322
16700
  // src/lib/cli-version.ts
16323
16701
  function cliVersion() {
16324
16702
  try {
16325
- return true ? "0.28.1-experimental.e910abc" : "dev";
16703
+ return true ? "0.28.1-experimental.f9181dc" : "dev";
16326
16704
  } catch {
16327
16705
  return "dev";
16328
16706
  }
@@ -16547,6 +16925,16 @@ var KNOWN_RECORD_TYPES = /* @__PURE__ */ new Set([
16547
16925
  "subagent"
16548
16926
  ]);
16549
16927
  var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
16928
+ var DISPATCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Workflow"]);
16929
+ function hasUserText(record) {
16930
+ const message = record.message;
16931
+ const content = message?.content ?? record.content;
16932
+ if (typeof content === "string") return content.trim().length > 0;
16933
+ if (!Array.isArray(content)) return false;
16934
+ return content.some(
16935
+ (b) => b?.type === "text" && typeof b.text === "string" && b.text.trim().length > 0
16936
+ );
16937
+ }
16550
16938
  var COMMAND_CLASSES = [
16551
16939
  [/\b(npm|yarn|pnpm|bun)\s+(run\s+)?test\b|\bvitest\b|\bjest\b|\bpytest\b|\bgo test\b/, "test"],
16552
16940
  [/\b(npm|yarn|pnpm|bun)\s+(run\s+)?build\b|\btsc\b|\bwebpack\b|\bcargo build\b/, "build"],
@@ -16673,6 +17061,8 @@ function fold(transcriptPath, opts = {}) {
16673
17061
  totalRecords: 0,
16674
17062
  malformed: 0,
16675
17063
  subagentFiles: 0,
17064
+ dispatched: 0,
17065
+ userMessages: 0,
16676
17066
  subagentSkipped: 0,
16677
17067
  compactions: 0,
16678
17068
  complete: false
@@ -16699,7 +17089,8 @@ function fold(transcriptPath, opts = {}) {
16699
17089
  if (type === "system" && record.subtype === "compact_boundary") {
16700
17090
  result.coverage.compactions++;
16701
17091
  }
16702
- collectFromRecord(record, owner, byPath, commandStats, pendingByToolUse, opts.repoRoot);
17092
+ if (type === "user" && hasUserText(record)) result.coverage.userMessages++;
17093
+ collectFromRecord(record, owner, byPath, commandStats, pendingByToolUse, opts.repoRoot, result.coverage);
16703
17094
  }
16704
17095
  };
16705
17096
  try {
@@ -16771,7 +17162,7 @@ function classifyUnobserved(path) {
16771
17162
  if (/\.(png|jpg|jpeg|gif|pdf|zip|woff2?|ico|mp4)$/i.test(path)) return "binary";
16772
17163
  return "no_edit_record";
16773
17164
  }
16774
- function collectFromRecord(record, owner, byPath, commandStats, pendingByToolUse, repoRoot2) {
17165
+ function collectFromRecord(record, owner, byPath, commandStats, pendingByToolUse, repoRoot2, tally) {
16775
17166
  const message = record.message;
16776
17167
  const content = message?.content ?? record.content;
16777
17168
  const blocks = Array.isArray(content) ? content : content && typeof content === "object" ? [content] : [];
@@ -16793,6 +17184,9 @@ function collectFromRecord(record, owner, byPath, commandStats, pendingByToolUse
16793
17184
  byPath.set(path, entry);
16794
17185
  }
16795
17186
  }
17187
+ if (DISPATCH_TOOLS.has(name) && tally) {
17188
+ tally.dispatched += 1;
17189
+ }
16796
17190
  if (name === "Bash") {
16797
17191
  const cmd = typeof input.command === "string" ? input.command : "";
16798
17192
  if (cmd) {
@@ -16851,6 +17245,86 @@ function checkConservation(changedFiles, result, repoRoot2) {
16851
17245
  };
16852
17246
  }
16853
17247
 
17248
+ // src/lib/verdict.ts
17249
+ function reconcileCoverage(changed, coverage) {
17250
+ const changedSet = new Set(changed);
17251
+ const reviewed = coverage.reviewed.filter((p) => changedSet.has(p));
17252
+ const claimed = /* @__PURE__ */ new Set([...reviewed, ...coverage.notReviewed.map((n) => n.path)]);
17253
+ const unaccounted = [...changedSet].filter((p) => !claimed.has(p)).sort();
17254
+ const notReviewed = [
17255
+ ...coverage.notReviewed.filter((n) => changedSet.has(n.path)),
17256
+ ...unaccounted.map((path) => ({
17257
+ path,
17258
+ reason: "unaccounted",
17259
+ // Named so the eventual bug report writes itself: some stage removed this
17260
+ // path and did not say so.
17261
+ stage: "unknown-stage",
17262
+ // An undeclared drop is CAPACITY by default. A stage that cannot be
17263
+ // bothered to say why it dropped a file does not get the benefit of the
17264
+ // doubt — that default is what makes forgetting expensive.
17265
+ kind: "capacity"
17266
+ }))
17267
+ ];
17268
+ return {
17269
+ coverage: { reviewed: [...new Set(reviewed)].sort(), notReviewed },
17270
+ unaccounted,
17271
+ balances: unaccounted.length === 0
17272
+ };
17273
+ }
17274
+ function resolveVerdict(proposed, coverage) {
17275
+ if (proposed === "FAIL") return "FAIL";
17276
+ const blocking = coverage.notReviewed.filter((n) => (n.kind ?? "capacity") !== "policy");
17277
+ if (blocking.length === 0) return proposed;
17278
+ return "WARN";
17279
+ }
17280
+ function describeCoverage(coverage, maxPaths = 5) {
17281
+ const relevant = coverage.notReviewed.filter((n) => (n.kind ?? "capacity") !== "policy");
17282
+ if (relevant.length === 0) return null;
17283
+ const byReason = /* @__PURE__ */ new Map();
17284
+ for (const n of relevant) {
17285
+ const key = `${n.reason}`;
17286
+ const list = byReason.get(key) ?? [];
17287
+ list.push(n.path);
17288
+ byReason.set(key, list);
17289
+ }
17290
+ const lines = [];
17291
+ for (const [reason, paths] of [...byReason.entries()].sort()) {
17292
+ const shown = paths.slice(0, maxPaths).join(", ");
17293
+ const more = paths.length > maxPaths ? ` (+${paths.length - maxPaths} more)` : "";
17294
+ lines.push(` ${paths.length} not reviewed \u2014 ${reason}: ${shown}${more}`);
17295
+ }
17296
+ return `NOT A CLEAN REVIEW. ${relevant.length} changed file(s) never reached the reviewer, so this verdict does not cover them:
17297
+ ${lines.join("\n")}
17298
+ Treat those files as UNCHECKED, not as approved.`;
17299
+ }
17300
+ function openBlockingElsewhere(statements, reviewedNow, lineShaAt) {
17301
+ const reviewed = new Set(reviewedNow);
17302
+ const out = [];
17303
+ const seen = /* @__PURE__ */ new Set();
17304
+ for (const s of statements) {
17305
+ if (s.outcome !== "open") continue;
17306
+ if (s.register !== "BLOCK") continue;
17307
+ if (s.carried) continue;
17308
+ if (reviewed.has(s.file)) continue;
17309
+ if (!s.line_sha) continue;
17310
+ if (lineShaAt(s.file, s.line) !== s.line_sha) continue;
17311
+ const key = `${s.file}::${s.pattern_id}`;
17312
+ if (seen.has(key)) continue;
17313
+ seen.add(key);
17314
+ out.push({ file: s.file, line: s.line, pattern_id: s.pattern_id });
17315
+ }
17316
+ return out;
17317
+ }
17318
+ function describeOpenElsewhere(open) {
17319
+ if (open.length === 0) return null;
17320
+ const lines = open.slice(0, 5).map((o) => ` ${o.file}:${o.line} [${o.pattern_id}]`);
17321
+ const more = open.length > 5 ? `
17322
+ (+${open.length - 5} more)` : "";
17323
+ return `STILL OPEN ELSEWHERE. ${open.length} blocking finding(s) Verity raised earlier are still present in files this run did not review:
17324
+ ${lines.join("\n")}${more}
17325
+ This verdict covers the current change only. The tree is not clean.`;
17326
+ }
17327
+
16854
17328
  // src/lib/channel.ts
16855
17329
  var MAX_AGENT_CONTEXT_CHARS = 1500;
16856
17330
  var MAX_AGENT_ITEMS = 5;
@@ -16929,6 +17403,47 @@ function buildHookOutput(gateDecision, systemMessage, agentContext) {
16929
17403
  } : {}
16930
17404
  };
16931
17405
  }
17406
+ var IDLE_EPISODE_CAP = 3;
17407
+ function channelSilence(input) {
17408
+ const movedSomething = input.newUserPrompt || input.newAuthorship;
17409
+ if (movedSomething) return null;
17410
+ if (input.consecutiveIdle >= IDLE_EPISODE_CAP) return "idle-episode-cap";
17411
+ if (input.emittedLast) return "caused-by-our-own-emission";
17412
+ return null;
17413
+ }
17414
+
17415
+ // src/lib/emit.ts
17416
+ var YELLOW2 = "\x1B[33m";
17417
+ var NC2 = "\x1B[0m";
17418
+ function emitVerdict(input) {
17419
+ const exit = input.exit ?? ((code) => process.exit(code));
17420
+ const { coverage, unaccounted } = reconcileCoverage(input.changed, input.coverage);
17421
+ let verdict = resolveVerdict(input.proposed, coverage);
17422
+ const openElsewhere = input.openElsewhere ?? [];
17423
+ if (verdict === "PASS" && openElsewhere.length > 0) verdict = "WARN";
17424
+ const note = [describeCoverage(coverage), describeOpenElsewhere(openElsewhere)].filter(Boolean).join("\n\n") || null;
17425
+ if (unaccounted.length > 0) {
17426
+ process.stderr.write(
17427
+ `${YELLOW2}Verity: ${unaccounted.length} changed file(s) could not be attributed to any review stage \u2014 counted as unreviewed.${NC2}
17428
+ `
17429
+ );
17430
+ }
17431
+ if (verdict === "FAIL") {
17432
+ input.renderBlocking?.();
17433
+ if (input.agentContext) {
17434
+ process.stderr.write(`
17435
+ ${input.agentContext}
17436
+ `);
17437
+ }
17438
+ if (note && !input.silenced) process.stderr.write(`
17439
+ ${YELLOW2}${note}${NC2}
17440
+ `);
17441
+ return exit(2);
17442
+ }
17443
+ const agentBlock = input.silenced ? null : [input.agentContext, note].filter(Boolean).join("\n\n") || null;
17444
+ printJsonCompact(buildHookOutput(verdict, input.userSummary, agentBlock));
17445
+ return exit(0);
17446
+ }
16932
17447
 
16933
17448
  // src/lib/cache-cleanup.ts
16934
17449
  var import_node_fs21 = require("node:fs");
@@ -17109,46 +17624,6 @@ function shouldWarmRetryAnalyze(result) {
17109
17624
  return false;
17110
17625
  }
17111
17626
 
17112
- // src/lib/skip-detection.ts
17113
- function isBareAckPrompt(prompt) {
17114
- if (typeof prompt !== "string") return false;
17115
- const trimmed = prompt.trim();
17116
- if (trimmed.length === 0) return false;
17117
- if (trimmed.length > 20) return false;
17118
- const bareAckPattern = /^(\d{1,2}|y|n|yes|no|yep|nope|ok(ay)?|sure|skip|cancel|stop|done|noted|got\s+it|sounds\s+good|thanks|thank\s+you|thx)[.!?]*$/i;
17119
- return bareAckPattern.test(trimmed);
17120
- }
17121
- function isReflectionQuestion(response) {
17122
- if (!response || typeof response !== "string") return false;
17123
- const markers = [
17124
- /reflection\s+for\s+future\s+agents/i,
17125
- /what(?:'s|\s+is)\s+one\s+thing\s+you\s+learned/i,
17126
- /say\s+['"]?skip['"]?\s+to\s+skip/i,
17127
- /quick\s+reflection\s+question/i,
17128
- // Post-flip (VRT-21): the agent drafts the reflection itself and, when
17129
- // interactive, asks the user to confirm/correct before recording. That
17130
- // turn authors no code either, so it's still a reflection turn.
17131
- /reflection\s+draft/i,
17132
- /confirm,?\s+correct,?\s+or\s+add/i
17133
- ];
17134
- return markers.some((m) => m.test(response));
17135
- }
17136
- function isMetaTaskLabel(label2) {
17137
- if (label2 === null || label2 === void 0) return false;
17138
- if (typeof label2 !== "string") return false;
17139
- const trimmed = label2.trim();
17140
- if (trimmed.length === 0) return true;
17141
- const metaPatterns = [
17142
- /^verity\s+[\w-]+\s+response$/i,
17143
- // "Verity reflect response"
17144
- /^simple user response$/i,
17145
- /^verity\s+command$/i,
17146
- // "Verity command"
17147
- /^user\s+(question|reply|response|ack)$/i
17148
- ];
17149
- return metaPatterns.some((p) => p.test(trimmed));
17150
- }
17151
-
17152
17627
  // src/lib/transcript.ts
17153
17628
  var import_node_fs22 = require("node:fs");
17154
17629
  var MAX_READ_BYTES = 256 * 1024;
@@ -17312,6 +17787,13 @@ function buildSummary(lines) {
17312
17787
  files_read: capArray(filesRead, MAX_FILES_LIST),
17313
17788
  files_edited: capArray(filesEdited, MAX_FILES_LIST),
17314
17789
  files_created: capArray(filesCreated, MAX_CREATED_LIST),
17790
+ // The complement of the two caps that affect SCOPE. `files_read` is excluded
17791
+ // deliberately: reading a file is not authoring it, so a capped read list
17792
+ // narrows nothing.
17793
+ capped_out: [
17794
+ ...cappedOut(filesEdited, MAX_FILES_LIST),
17795
+ ...cappedOut(filesCreated, MAX_CREATED_LIST)
17796
+ ],
17315
17797
  searches,
17316
17798
  commands,
17317
17799
  subagents,
@@ -17362,6 +17844,9 @@ function sanitizeCommand(rawCmd) {
17362
17844
  function capArray(set, max) {
17363
17845
  return Array.from(set).slice(0, max);
17364
17846
  }
17847
+ function cappedOut(set, max) {
17848
+ return Array.from(set).slice(max);
17849
+ }
17365
17850
 
17366
17851
  // src/lib/run-mode.ts
17367
17852
  function parseAutonomousEnv(raw) {
@@ -17771,12 +18256,45 @@ function agentContextFor(response, intentRepeat = 0) {
17771
18256
  });
17772
18257
  }
17773
18258
  var beaconCtx = null;
17774
- async function passAndExit(reason, skip) {
18259
+ async function passAndExit(reason, skip, kindOverride) {
17775
18260
  const sent = await sendSkipBeacon(beaconCtx, skip);
17776
18261
  logEvent("skip", { reason: skip, beacon: sent });
17777
- printJsonCompact({ gate_decision: "PASS", systemMessage: `Verity: ${reason}` });
18262
+ const POLICY_SKIPS = /* @__PURE__ */ new Set([
18263
+ "no-analyzable-files",
18264
+ "verity-command",
18265
+ "bare-acknowledgment",
18266
+ "reflection-prompt",
18267
+ "skip-mode",
18268
+ "zero-increment",
18269
+ "debounce",
18270
+ "no-delta-since-last-review"
18271
+ ]);
18272
+ const skipKind = kindOverride ?? (POLICY_SKIPS.has(skip) ? "policy" : "capacity");
18273
+ const changed = skipCoverageChanged;
18274
+ const { coverage, unaccounted } = reconcileCoverage(changed, {
18275
+ reviewed: [],
18276
+ notReviewed: changed.map((path) => ({ path, reason: skip, stage: "pre-flight", kind: skipKind }))
18277
+ });
18278
+ const verdict = resolveVerdict("PASS", coverage);
18279
+ const note = describeCoverage(coverage);
18280
+ if (unaccounted.length > 0) {
18281
+ logEvent("coverage_unaccounted", { where: "passAndExit", skip, count: unaccounted.length });
18282
+ }
18283
+ const AGENT_SILENT_SKIPS = /* @__PURE__ */ new Set([]);
18284
+ const agentNote = AGENT_SILENT_SKIPS.has(skip) ? null : note;
18285
+ printJsonCompact(
18286
+ buildHookOutput(
18287
+ verdict,
18288
+ `Verity: ${reason}`,
18289
+ // The agent's ONLY input is additionalContext. Sixteen of the nineteen
18290
+ // terminating paths wrote `systemMessage` — the human's field — and told
18291
+ // the agent nothing at all.
18292
+ agentNote
18293
+ )
18294
+ );
17778
18295
  process.exit(0);
17779
18296
  }
18297
+ var skipCoverageChanged = [];
17780
18298
  var EMPTY_STATIC = {
17781
18299
  tool: "@codacy/analysis-cli",
17782
18300
  findings: [],
@@ -17791,7 +18309,7 @@ function runLocalStatic(analyzable, securityFiles, baseline, skipStatic) {
17791
18309
  }
17792
18310
  function localOnlyAndExit(staticResults) {
17793
18311
  printJsonCompact({
17794
- gate_decision: "PASS",
18312
+ gate_decision: "WARN",
17795
18313
  systemMessage: "Verity: not authenticated \u2014 ran a local static-only check (no deep review, no upload). Run `verity init` to authenticate and enable the full quality gate.",
17796
18314
  unauthenticated: true,
17797
18315
  static_results: staticResults
@@ -17851,6 +18369,7 @@ async function runAnalyze(opts, globals) {
17851
18369
  });
17852
18370
  }
17853
18371
  const { files: allChanged, hasRecentCommitFiles } = getChangedFiles();
18372
+ skipCoverageChanged = allChanged;
17854
18373
  const analyzable = filterAnalyzable(allChanged);
17855
18374
  const reviewable = filterReviewable(allChanged);
17856
18375
  const securityFiles = filterSecurity(allChanged);
@@ -17866,10 +18385,12 @@ async function runAnalyze(opts, globals) {
17866
18385
  if (/^\s*\/verity-/i.test(latestPrompt)) {
17867
18386
  await passAndExit("Verity command \u2014 skipping analysis", "verity-command");
17868
18387
  }
17869
- if (isBareAckPrompt(latestPrompt)) {
18388
+ const agentAuthoredCodeThisTurn = !!(actionSummary && (actionSummary.files_edited.length > 0 || actionSummary.files_created.length > 0));
18389
+ const turnAuthoredCode = agentAuthoredCodeThisTurn || !!baseline && allForReview.some((f) => changedSinceBaseline(f, baseline));
18390
+ const canSeeTurnAuthorship = !!actionSummary || !!baseline;
18391
+ if (shouldSkipForBareAck({ prompt: latestPrompt, turnAuthoredCode, canSeeTurnAuthorship })) {
17870
18392
  await passAndExit("Bare acknowledgment \u2014 skipping analysis", "bare-acknowledgment");
17871
18393
  }
17872
- const agentAuthoredCodeThisTurn = !!(actionSummary && (actionSummary.files_edited.length > 0 || actionSummary.files_created.length > 0));
17873
18394
  if (isReflectionQuestion(assistantResponse) && !agentAuthoredCodeThisTurn) {
17874
18395
  await passAndExit("Reflection-prompt turn \u2014 skipping analysis", "reflection-prompt");
17875
18396
  }
@@ -17926,7 +18447,8 @@ async function runAnalyze(opts, globals) {
17926
18447
  let codeDelta = {
17927
18448
  files: [],
17928
18449
  total_lines: 0,
17929
- total_files: 0
18450
+ total_files: 0,
18451
+ excluded: []
17930
18452
  };
17931
18453
  let snapshotResult = { has_snapshots: false, diffs: [] };
17932
18454
  let contentHash = null;
@@ -17991,7 +18513,11 @@ async function runAnalyze(opts, globals) {
17991
18513
  if (assistantResponse) {
17992
18514
  analysisMode = "plan";
17993
18515
  } else {
17994
- await passAndExit("No files within size limits to analyze", "size-limit");
18516
+ await passAndExit(
18517
+ "No files within size limits to analyze",
18518
+ "size-limit",
18519
+ codeDelta.excluded.length > 0 ? "capacity" : "policy"
18520
+ );
17995
18521
  }
17996
18522
  }
17997
18523
  }
@@ -18004,19 +18530,13 @@ async function runAnalyze(opts, globals) {
18004
18530
  snapshotResult = generateSnapshotDiffs(codeDelta.files);
18005
18531
  }
18006
18532
  currentCommit = getCurrentCommit();
18007
- const maxIterations = parseInt(opts.maxIterations, 10);
18008
- const iterResult = checkMaxIterations(currentCommit, maxIterations, contentHash ?? void 0);
18009
- if (iterResult.skip) await passAndExit(iterResult.skip, "iteration-cap");
18010
- iteration = iterResult.iteration;
18533
+ iteration = readIterationState(currentCommit).iteration;
18011
18534
  }
18012
18535
  }
18013
18536
  if (analysisMode === "plan") {
18014
18537
  recordAnalysisStart();
18015
18538
  currentCommit = getCurrentCommit();
18016
- const maxIterations = parseInt(opts.maxIterations, 10);
18017
- const iterResult = checkMaxIterations(currentCommit, maxIterations);
18018
- if (iterResult.skip) await passAndExit(iterResult.skip, "iteration-cap");
18019
- iteration = iterResult.iteration;
18539
+ iteration = readIterationState(currentCommit).iteration;
18020
18540
  }
18021
18541
  const contextFiles = gatherContextFiles(contextFilePaths, codeDelta.files);
18022
18542
  for (const f of codeDelta.files) {
@@ -18165,7 +18685,10 @@ async function runAnalyze(opts, globals) {
18165
18685
  priorState.capabilities
18166
18686
  );
18167
18687
  memory = recallMemory(memorySession.d, memorySession.identity, {
18168
- currentSessionKey: memorySession.identity.sessionKey
18688
+ currentSessionKey: memorySession.identity.sessionKey,
18689
+ // The independent witness. Only meaningful when a transcript was folded —
18690
+ // otherwise it stays undefined and capture coverage reads as UNKNOWN.
18691
+ ...foldResult && { userMessagesSeen: foldResult.coverage.userMessages }
18169
18692
  });
18170
18693
  if (memory && !memory.provenanceHolds) {
18171
18694
  process.stderr.write("Verity: working-memory provenance check failed \u2014 recall suppressed.\n");
@@ -18244,6 +18767,25 @@ async function runAnalyze(opts, globals) {
18244
18767
  projection: {
18245
18768
  v: 1,
18246
18769
  authored: foldResult?.authored ?? [],
18770
+ // ⚠ AUTHORED *SINCE THE LAST VERDICT* — a different question from `authored`.
18771
+ //
18772
+ // `authored` above is the fold of the session TRANSCRIPT, which is
18773
+ // cumulative: on turn 3 it still lists the files turn 2 edited. The
18774
+ // account's close-out reads it as "was this file edited since the
18775
+ // statement was raised", and those are not the same set.
18776
+ //
18777
+ // Measured 2026-08-03 (shirt-seller): four real vulnerabilities were
18778
+ // raised on the turn that wrote them, then marked `fixed` 36 seconds
18779
+ // later by a SUMMARY turn that edited nothing — `authored` still said 2,
18780
+ // the turn carried 0 findings, so "gone + file authored" resolved to
18781
+ // fixed. The vulnerabilities were still on disk. A silent false `fixed`
18782
+ // is worse than a false `open`: it retires the statement the Account
18783
+ // exists to keep.
18784
+ //
18785
+ // `hash_at_last_verdict` is frozen at each verdict and `hash_now` tracks
18786
+ // disk, so their inequality IS "changed since we last spoke" — already
18787
+ // computed, already maintained by the divergence machinery.
18788
+ authored_since_verdict: memorySession ? foldDossier(memorySession.d).authored_all.filter((a) => a.hash_now !== a.hash_at_last_verdict).map((a) => a.path) : [],
18247
18789
  unobserved: foldResult?.unobserved ?? [],
18248
18790
  commands: foldResult?.commands ?? [],
18249
18791
  unknown_types: foldResult?.unknownTypes ?? [],
@@ -18285,7 +18827,20 @@ async function runAnalyze(opts, globals) {
18285
18827
  const intentContext = {};
18286
18828
  if (conversation && conversation.prompts.length > 0) {
18287
18829
  const latest = conversation.prompts[conversation.prompts.length - 1];
18288
- intentContext.user_prompt = latest.prompt;
18830
+ const goalPrompt = resolveGoalPrompt(conversation.prompts) ?? { entry: latest, turnsBack: 0 };
18831
+ intentContext.user_prompt = goalPrompt.entry.prompt;
18832
+ if (isContinuationPrompt(intentContext.user_prompt)) {
18833
+ const carried = memory?.projection.goal?.text;
18834
+ if (carried && !isContinuationPrompt(carried)) {
18835
+ intentContext.continuation_prompt = latest.prompt;
18836
+ intentContext.user_prompt = carried;
18837
+ logEvent("goal_from_dossier", { chars: carried.length });
18838
+ }
18839
+ }
18840
+ if (goalPrompt.turnsBack > 0) {
18841
+ intentContext.continuation_prompt = latest.prompt;
18842
+ logEvent("goal_walked_back", { turns_back: goalPrompt.turnsBack });
18843
+ }
18289
18844
  intentContext.session_id = latest.session_id || void 0;
18290
18845
  intentContext.prompt_captured_at = latest.captured_at || void 0;
18291
18846
  if (conversation.prompts.length > 1) {
@@ -18361,6 +18916,8 @@ async function runAnalyze(opts, globals) {
18361
18916
  message = "Verity: your GitHub verification expired \u2014 run `verity login` to re-verify (local analysis this run)";
18362
18917
  } else if (result.error.startsWith("FORBIDDEN")) {
18363
18918
  message = "Verity: no access grant for this repository \u2014 run `verity login` to refresh your grants (local analysis this run)";
18919
+ } else if (result.error.startsWith("INVALID_TOKEN")) {
18920
+ message = "Verity: your login expired or was revoked \u2014 run `verity login` to sign in again (local analysis this run)";
18364
18921
  } else if (result.http_status && result.http_status >= 400 && result.http_status < 500) {
18365
18922
  message = `Verity: request rejected (HTTP ${result.http_status}) \u2014 ${result.error}`;
18366
18923
  } else if (result.http_status && result.http_status >= 500) {
@@ -18375,8 +18932,114 @@ async function runAnalyze(opts, globals) {
18375
18932
  const response = result.data;
18376
18933
  const decision = response.gate_decision ?? "(unrecognised)";
18377
18934
  const sentPaths = codeDelta.files.map((f) => f.path);
18935
+ let openElsewhere = [];
18936
+ if (memorySession) {
18937
+ try {
18938
+ const st = foldDossier(memorySession.d);
18939
+ openElsewhere = openBlockingElsewhere(st.statements, sentPaths, (file, line) => {
18940
+ try {
18941
+ const src = (0, import_node_fs24.readFileSync)((0, import_node_path20.join)(repoRoot(), file), "utf8").split("\n");
18942
+ const at = src[line - 1];
18943
+ return at === void 0 ? null : lineSha(at);
18944
+ } catch {
18945
+ return null;
18946
+ }
18947
+ });
18948
+ } catch {
18949
+ }
18950
+ }
18951
+ const reviewCoverage = {
18952
+ reviewed: sentPaths,
18953
+ // Declared drops from the stages that DO report themselves today. The other
18954
+ // stages surface via `unaccounted`, which is the tripwire, not the design.
18955
+ notReviewed: [
18956
+ // Every exit from the collection loop, each named. Six reasons where there
18957
+ // used to be two recorded and four silent — the silent ones including the
18958
+ // per-file size cap, which could drop a whole source file without leaving a
18959
+ // trace anywhere in the payload or the run row.
18960
+ ...codeDelta.excluded,
18961
+ // The server-side 300-line middle-out truncation. It only bites on the
18962
+ // full-file branch (a first analysis, before snapshots exist) because
18963
+ // analyze normally sends diffs — but on that branch the reviewer sees the
18964
+ // first and last 100 lines and nothing between, and until now said so to
18965
+ // nobody. CAPACITY: a partial look is not a look.
18966
+ ...(response.metadata?.truncated_files ?? []).map((path) => ({
18967
+ path,
18968
+ reason: "file-middle-truncated-300-lines",
18969
+ stage: "prompt-builder",
18970
+ kind: "capacity"
18971
+ })),
18972
+ // The 20-entry edit cap. CAPACITY, and the sharpest of the lot: it narrows
18973
+ // what is REVIEWED, not merely what is summarised — a session editing 25
18974
+ // files had five silently excluded from the reviewed set.
18975
+ ...(actionSummary?.capped_out ?? []).map((path) => ({
18976
+ path,
18977
+ reason: "edit-list-cap-20",
18978
+ stage: "extractActionSummary",
18979
+ kind: "capacity"
18980
+ })),
18981
+ // ⚠ BASELINE SCOPING — the biggest source of false NOT A CLEAN REVIEW.
18982
+ //
18983
+ // The universe is `allChanged`, git's whole dirty tree. The reviewed set is
18984
+ // scoped to what THIS SESSION authored (the VRT-26 contamination cure), so
18985
+ // every pre-existing dirty file is in the universe, absent from `reviewed`,
18986
+ // and — until now — declared by nobody. It fell through to `unaccounted`,
18987
+ // became capacity, and produced "NOT A CLEAN REVIEW: admin.js" over a file
18988
+ // that was never this session's to review.
18989
+ //
18990
+ // Measured 2026-08-04: three consecutive runs over an untouched tree gave
18991
+ // three different answers — .claude/settings.json, then admin.js, then six
18992
+ // files — because each run took a different path and each path had a
18993
+ // different idea of the universe. POLICY: not this session's work is not a
18994
+ // coverage gap, it is the cure working.
18995
+ ...allChanged.filter((p) => !sentPaths.includes(p) && !codeDelta.excluded.some((e) => e.path === p)).filter((p) => analyzable.includes(p) || reviewable.includes(p) || securityFiles.includes(p)).map((path) => ({
18996
+ path,
18997
+ reason: "not-authored-this-session",
18998
+ stage: "baseline-scoping",
18999
+ kind: "policy"
19000
+ })),
19001
+ // The extension allowlist, and it is POLICY rather than capacity: a changed
19002
+ // README was never going to be reviewed, and treating that as a coverage
19003
+ // gap would downgrade nearly every PASS to WARN until WARN meant nothing.
19004
+ // Recorded so the ledger balances and so "what did Verity ignore entirely"
19005
+ // is answerable — but it never touches the verdict.
19006
+ ...allChanged.filter((p) => !analyzable.includes(p) && !reviewable.includes(p) && !securityFiles.includes(p)).map((path) => ({
19007
+ path,
19008
+ reason: "not-a-reviewed-file-type",
19009
+ stage: "extension-allowlist",
19010
+ kind: "policy"
19011
+ }))
19012
+ ]
19013
+ };
18378
19014
  const watermarkHash = sentPaths.length > 0 ? computeContentHash(sentPaths) : contentHash;
18379
19015
  const watermarkIsPartial = !!codeDelta.truncated;
19016
+ let silenced = null;
19017
+ let turnIsIdleForChannel = true;
19018
+ if (memorySession) {
19019
+ try {
19020
+ const st = foldDossier(memorySession.d);
19021
+ turnIsIdleForChannel = st.authored_all.every((a) => a.hash_now === a.hash_at_last_verdict);
19022
+ silenced = channelSilence({
19023
+ // The BUFFER, not intentContext.user_prompt: the latter falls back to a
19024
+ // linked issue (VRT-53 W4) when no human spoke, and a fallback goal is
19025
+ // not a user utterance. Treating it as one would keep the loop alive on
19026
+ // exactly the autonomous cohort.
19027
+ newUserPrompt: (conversation?.prompts?.length ?? 0) > 0,
19028
+ newAuthorship: !turnIsIdleForChannel,
19029
+ emittedLast: st.meta.channel?.emittedLast === true,
19030
+ consecutiveIdle: st.meta.channel?.consecutiveIdle ?? 0
19031
+ });
19032
+ } catch {
19033
+ silenced = null;
19034
+ }
19035
+ }
19036
+ if (silenced) {
19037
+ logEvent("channel_silenced", {
19038
+ reason: silenced,
19039
+ run_id: response.run_id ?? turnId,
19040
+ decision
19041
+ });
19042
+ }
18380
19043
  let intentRepeatCount = 0;
18381
19044
  if (memorySession) {
18382
19045
  try {
@@ -18392,7 +19055,15 @@ async function runAnalyze(opts, globals) {
18392
19055
  title: f.title,
18393
19056
  severity: f.severity
18394
19057
  })) ?? [],
18395
- intent: response.intent_alignment ?? null
19058
+ intent: response.intent_alignment ?? null,
19059
+ // The same signal F1 introduced: bytes differing from the hash frozen at
19060
+ // the last verdict. A turn that moved nothing is the only kind that can
19061
+ // accumulate a repeat.
19062
+ idle: turnIsIdleForChannel,
19063
+ // What next turn reads as `emittedLast`. A suppressed turn did not
19064
+ // speak, so it cannot be the cause of the turn after it — which is what
19065
+ // keeps this from becoming a permanent gag.
19066
+ emitted: !silenced
18396
19067
  });
18397
19068
  intentRepeatCount = Math.max(0, (foldDossier(memorySession.d).meta.intent_repeat?.consecutive ?? 1) - 1);
18398
19069
  } catch {
@@ -18492,9 +19163,41 @@ async function runAnalyze(opts, globals) {
18492
19163
  reverify_by: response.reverify_by
18493
19164
  });
18494
19165
  const grantNudge = grantWarning ? ` \u26A0\uFE0F ${grantWarning}` : "";
18495
- switch (decision) {
19166
+ let capReleased = false;
19167
+ let effectiveDecision = decision;
19168
+ if (decision === "FAIL") {
19169
+ const blocking = (response.findings ?? []).filter((f) => {
19170
+ const sev = String(f.severity ?? "").toLowerCase();
19171
+ return sev === "critical" || sev === "high";
19172
+ });
19173
+ const fingerprint = findingsFingerprint(blocking);
19174
+ const prior = readIterationState(currentCommit);
19175
+ const sameProblem = isSameProblem(prior.fingerprint, fingerprint);
19176
+ const nextIteration = sameProblem ? prior.iteration + 1 : 1;
19177
+ const maxIterations = parseInt(opts.maxIterations, 10);
19178
+ writeIteration(nextIteration, currentCommit, contentHash ?? void 0, fingerprint);
19179
+ iteration = nextIteration;
19180
+ if (nextIteration > maxIterations) {
19181
+ capReleased = true;
19182
+ effectiveDecision = "WARN";
19183
+ logEvent("iteration_cap_released", { iteration: nextIteration, fingerprint });
19184
+ }
19185
+ }
19186
+ if (capReleased) {
19187
+ const findings = response.findings ?? [];
19188
+ const lines = findings.slice(0, 5).map((f) => ` [${String(f.severity ?? "?").toUpperCase()}] ${String(f.title ?? f.message ?? "")} (${String(f.file ?? "?")}:${String(f.line ?? "?")})`);
19189
+ emitVerdict({
19190
+ proposed: "WARN",
19191
+ changed: skipCoverageChanged,
19192
+ coverage: reviewCoverage,
19193
+ userSummary: `Verity: WARN \u2014 self-healing limit (${opts.maxIterations}) reached on the same finding. NO LONGER BLOCKING, but ${findings.length} finding(s) remain OPEN and were NOT fixed. Human review required before deploying.
19194
+ ${lines.join("\n")}`,
19195
+ agentContext: null,
19196
+ silenced: true
19197
+ });
19198
+ }
19199
+ switch (effectiveDecision) {
18496
19200
  case "FAIL": {
18497
- writeIteration(iteration + 1, currentCommit, contentHash ?? void 0);
18498
19201
  const assessment = response.assessment;
18499
19202
  const narrative = assessment?.narrative ?? "";
18500
19203
  const findings = response.findings ?? [];
@@ -18571,7 +19274,19 @@ ${YELLOW}${loginNudge.trim()}${NC}
18571
19274
  if (grantNudge) process.stderr.write(`
18572
19275
  ${YELLOW}${grantNudge.trim()}${NC}
18573
19276
  `);
18574
- process.exit(2);
19277
+ emitVerdict({
19278
+ proposed: "FAIL",
19279
+ changed: skipCoverageChanged,
19280
+ coverage: reviewCoverage,
19281
+ userSummary: "",
19282
+ // Subject to the SAME cycle cut as PASS/WARN. Suppressing here is safe:
19283
+ // the findings themselves are rendered above by the blocking renderer,
19284
+ // so what the cut removes is the repeated commentary, never the defect.
19285
+ agentContext: silenced ? null : agentContextFor(response, intentRepeatCount),
19286
+ // The coverage note is silenced with it — half a channel is still a channel.
19287
+ silenced: !!silenced,
19288
+ openElsewhere
19289
+ });
18575
19290
  break;
18576
19291
  }
18577
19292
  case "PASS": {
@@ -18583,10 +19298,16 @@ ${YELLOW}${grantNudge.trim()}${NC}
18583
19298
  if (viewUrl) userSummary += ` Report: ${viewUrl}`;
18584
19299
  if (autoSeedNotice) userSummary = `${autoSeedNotice} ${userSummary}`;
18585
19300
  userSummary += loginNudge + grantNudge;
18586
- printJsonCompact(
18587
- buildHookOutput("PASS", userSummary, agentContextFor(response, intentRepeatCount))
18588
- );
18589
- process.exit(0);
19301
+ emitVerdict({
19302
+ proposed: "PASS",
19303
+ changed: skipCoverageChanged,
19304
+ coverage: reviewCoverage,
19305
+ userSummary,
19306
+ agentContext: silenced ? null : agentContextFor(response, intentRepeatCount),
19307
+ // The coverage note is silenced with it — half a channel is still a channel.
19308
+ silenced: !!silenced,
19309
+ openElsewhere
19310
+ });
18590
19311
  break;
18591
19312
  }
18592
19313
  case "WARN": {
@@ -18597,10 +19318,16 @@ ${YELLOW}${grantNudge.trim()}${NC}
18597
19318
  if (viewUrl) userSummary += ` Report: ${viewUrl}`;
18598
19319
  if (autoSeedNotice) userSummary = `${autoSeedNotice} ${userSummary}`;
18599
19320
  userSummary += loginNudge + grantNudge;
18600
- printJsonCompact(
18601
- buildHookOutput("WARN", userSummary, agentContextFor(response, intentRepeatCount))
18602
- );
18603
- process.exit(0);
19321
+ emitVerdict({
19322
+ proposed: "WARN",
19323
+ changed: skipCoverageChanged,
19324
+ coverage: reviewCoverage,
19325
+ userSummary,
19326
+ agentContext: silenced ? null : agentContextFor(response, intentRepeatCount),
19327
+ // The coverage note is silenced with it — half a channel is still a channel.
19328
+ silenced: !!silenced,
19329
+ openElsewhere
19330
+ });
18604
19331
  break;
18605
19332
  }
18606
19333
  default: {
@@ -18713,8 +19440,8 @@ async function runReview(opts, globals) {
18713
19440
  for (const p of specPaths) {
18714
19441
  if (!(0, import_node_fs26.existsSync)(p)) continue;
18715
19442
  try {
18716
- const { readFileSync: readFileSync15 } = await import("node:fs");
18717
- const content = readFileSync15(p, "utf-8");
19443
+ const { readFileSync: readFileSync16 } = await import("node:fs");
19444
+ const content = readFileSync16(p, "utf-8");
18718
19445
  specs.push({ path: p, content: content.slice(0, 10240) });
18719
19446
  } catch {
18720
19447
  }
@@ -19016,7 +19743,7 @@ async function runGuard(opts, globals) {
19016
19743
  cmd: "guard"
19017
19744
  });
19018
19745
  if (!result.ok) {
19019
- const authRemedy = result.error.startsWith("STALE_VERIFICATION") ? " Your GitHub verification expired \u2014 run `verity login` to re-verify." : result.error.startsWith("FORBIDDEN") ? " No access grant for this repository \u2014 run `verity login` to refresh your grants." : "";
19746
+ const authRemedy = result.error.startsWith("STALE_VERIFICATION") ? " Your GitHub verification expired \u2014 run `verity login` to re-verify." : result.error.startsWith("FORBIDDEN") ? " No access grant for this repository \u2014 run `verity login` to refresh your grants." : result.error.startsWith("INVALID_TOKEN") ? " Your Verity login expired or was revoked \u2014 run `verity login` to sign in again." : "";
19020
19747
  emitAllowNotice(
19021
19748
  `\u26A0 Verity ${moment}: ${authRemedy ? "not authorized" : "service offline"} \u2014 ${verb}ed WITHOUT review${authRemedy}`,
19022
19749
  `Verity ${moment}: ${authRemedy ? "not authorized" : "service unavailable"} (${result.error}); the ${verb} was allowed WITHOUT a Verity review.${authRemedy}`
@@ -20400,7 +21127,7 @@ function registerTelemetryCommands(program2) {
20400
21127
  }
20401
21128
 
20402
21129
  // src/cli.ts
20403
- program.name("verity").description("CLI for Verity quality gate service").version("0.28.1-experimental.e910abc").option("--token <token>", "Override authentication token").option("--service-url <url>", "Override service URL").option("--verbose", "Log HTTP requests/responses to stderr").hook("preAction", async () => {
21130
+ program.name("verity").description("CLI for Verity quality gate service").version("0.28.1-experimental.f9181dc").option("--token <token>", "Override authentication token").option("--service-url <url>", "Override service URL").option("--verbose", "Log HTTP requests/responses to stderr").hook("preAction", async () => {
20404
21131
  try {
20405
21132
  await foldLegacyLocalCredential();
20406
21133
  } catch {
@@ -20409,6 +21136,8 @@ program.name("verity").description("CLI for Verity quality gate service").versio
20409
21136
  registerAuthCommands(program);
20410
21137
  registerLoginCommand(program);
20411
21138
  registerTokenCommand(program);
21139
+ registerSessionsCommands(program);
21140
+ registerLogoutCommand(program);
20412
21141
  registerHooksCommands(program);
20413
21142
  registerIntentCommands(program);
20414
21143
  registerLifecycleCommands(program);