@basou/cli 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/program.js CHANGED
@@ -3253,8 +3253,8 @@ function buildNoteEvent(input) {
3253
3253
  };
3254
3254
  }
3255
3255
  function buildAdHocLabel2(body) {
3256
- const oneLine = body.replace(/\s+/g, " ").trim();
3257
- const truncated = oneLine.length > LABEL_BODY_MAX ? `${oneLine.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine;
3256
+ const oneLine2 = body.replace(/\s+/g, " ").trim();
3257
+ const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
3258
3258
  return `Ad-hoc note: ${truncated}`;
3259
3259
  }
3260
3260
  function parseBody(raw) {
@@ -3415,6 +3415,8 @@ async function runImport(adapter, fn) {
3415
3415
  skippedNoAction: readCount(json.skipped_no_action),
3416
3416
  skippedAlreadyImported: readCount(json.skipped_already_imported),
3417
3417
  skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3418
+ skippedDecreased: readCount(json.skipped_decreased),
3419
+ skippedDuplicate: readCount(json.skipped_duplicate),
3418
3420
  skippedUnverifiable: readCount(json.skipped_unverifiable),
3419
3421
  eventTotal: readCount(json.event_total),
3420
3422
  dryRun: json.dry_run === true
@@ -7596,6 +7598,11 @@ function describeImport(outcome) {
7596
7598
  if (outcome.skippedAlreadyImported > 0)
7597
7599
  parts.push(`${outcome.skippedAlreadyImported} already imported`);
7598
7600
  if (outcome.skippedLegacyUntracked > 0) parts.push(`${outcome.skippedLegacyUntracked} legacy`);
7601
+ if (outcome.skippedNoAction > 0) parts.push(`${outcome.skippedNoAction} with no actions`);
7602
+ if (outcome.skippedDecreased > 0) parts.push(`${outcome.skippedDecreased} shrank`);
7603
+ if (outcome.skippedDuplicate > 0) parts.push(`${outcome.skippedDuplicate} duplicated`);
7604
+ if (outcome.skippedUnverifiable > 0)
7605
+ parts.push(`${outcome.skippedUnverifiable} unverifiable (run 'basou verify')`);
7599
7606
  return `${outcome.adapter}: ${verb} ${parts.join(", ")}`;
7600
7607
  }
7601
7608
  function printRefreshSummary(result) {
@@ -7728,8 +7735,10 @@ import {
7728
7735
  buildReviewRecordLabel,
7729
7736
  createAdHocSessionWithEvent as createAdHocSessionWithEvent3,
7730
7737
  findErrorCode as findErrorCode11,
7738
+ findUnbindableRepos,
7731
7739
  parseReviewRecordInput,
7732
7740
  readManifest as readManifest7,
7741
+ resolveRepoRoot,
7733
7742
  sanitizePath as sanitizePath2
7734
7743
  } from "@basou/core";
7735
7744
  function registerReviewCommand(program) {
@@ -7745,6 +7754,8 @@ Input format (a single JSON object describing one review):
7745
7754
  {
7746
7755
  "reviewer": "codex",
7747
7756
  "target": "working-tree",
7757
+ "repos": ["~/projects/basou"],
7758
+ "commits": ["a1b2c3d"],
7748
7759
  "verdict": "needs-attention",
7749
7760
  "findings": [
7750
7761
  { "title": "Off-by-one in pager", "severity": "medium", "location": "src/page.ts:42", "summary": "..." }
@@ -7754,17 +7765,37 @@ Input format (a single JSON object describing one review):
7754
7765
  ]
7755
7766
  }
7756
7767
 
7757
- Only "reviewer" and "target" are required; verdict / findings / blocked are
7758
- optional. Record blocked findings (spec-deviation / design-reversal) here so the
7759
- adversarial-review protocol's "always report what you blocked" becomes a durable
7760
- trail artifact -- an explicit empty "blocked": [] is encouraged to record that
7761
- you blocked nothing. The review is written into one ad-hoc session timestamped
7762
- now. Run from a workspace-view directory and it resolves to the planning repo,
7763
- like 'basou decision capture' / 'basou note'.
7768
+ Only "reviewer" and "target" are required; the rest are optional. Record blocked
7769
+ findings (spec-deviation / design-reversal) here so the adversarial-review
7770
+ protocol's "always report what you blocked" becomes a durable trail artifact --
7771
+ an explicit empty "blocked": [] is encouraged to record that you blocked
7772
+ nothing. The review is written into one ad-hoc session timestamped now. Run from
7773
+ a workspace-view directory and it resolves to the planning repo, like
7774
+ 'basou decision capture' / 'basou note'.
7775
+
7776
+ Name the repositories you reviewed in "repos". The record lands in the planning
7777
+ repo, so that field is the only thing tying it to the repo under review: without
7778
+ it, 'basou review-gaps' cannot surface this record against the work it covered.
7779
+ Each entry must be an absolute path (or ~/...) to a repository ROOT; a relative
7780
+ path, a path that is not there, or a subdirectory is rejected outright, because
7781
+ it would store a record that can never appear against any work. "commits" is
7782
+ kept as your claim about coverage and is never used to bind.
7783
+
7784
+ A record is paired with work only while that repository is still present on the
7785
+ machine. If it moves or goes away, 'basou review-gaps' reports the record as
7786
+ unverifiable rather than pairing it on a matching path string -- it cannot
7787
+ confirm the two name the same repository, and a guess about whether a review
7788
+ happened is worse than saying so.
7789
+
7790
+ A recorded review is a self-report -- review-gaps labels the unit but does not
7791
+ change what it is, because nothing corroborates the claim: a unit that was a gap
7792
+ is still a gap, and one that already had a review trace is still a candidate.
7793
+ Recording after the commit is fine: the record is still shown, marked as written
7794
+ after the fact.
7764
7795
 
7765
7796
  Example (heredoc on stdin):
7766
7797
  basou review record <<'JSON'
7767
- { "reviewer": "codex", "target": "working-tree", "verdict": "pass", "blocked": [] }
7798
+ { "reviewer": "codex", "target": "working-tree", "repos": ["~/projects/basou"], "verdict": "pass", "blocked": [] }
7768
7799
  JSON
7769
7800
  `;
7770
7801
  async function runReviewRecord(options, ctx = {}) {
@@ -7785,6 +7816,8 @@ async function doRunReviewRecord(options, ctx) {
7785
7816
  await assertWorkspaceInitialized10(paths.root);
7786
7817
  const raw = await readReviewInput(options, ctx);
7787
7818
  const review = parseReviewRecordInput(raw);
7819
+ assertReposCanBind(review);
7820
+ const reposResolved = (review.repos ?? []).map((r) => resolveRepoRoot(r)).filter((r) => r !== null);
7788
7821
  if (options.dryRun === true) {
7789
7822
  printReviewPreview(options, review);
7790
7823
  return;
@@ -7808,7 +7841,7 @@ async function doRunReviewRecord(options, ctx) {
7808
7841
  workingDirectory: repositoryRoot,
7809
7842
  invocation: { command: "basou review record", args: invocationArgs },
7810
7843
  targetEventBuilders: [
7811
- (sessionId, eventId) => buildReviewRecordedEvent({ eventId, sessionId, occurredAt, review })
7844
+ (sessionId, eventId) => buildReviewRecordedEvent({ eventId, sessionId, occurredAt, review, reposResolved })
7812
7845
  ]
7813
7846
  });
7814
7847
  printReviewResult(options, {
@@ -7817,6 +7850,22 @@ async function doRunReviewRecord(options, ctx) {
7817
7850
  review
7818
7851
  });
7819
7852
  }
7853
+ var REPO_PROBLEM_HINT = {
7854
+ relative: "use an absolute path (or ~/...) to the repository root",
7855
+ absent: "no such path on this machine",
7856
+ not_a_repo_root: "that path is not a repository root"
7857
+ };
7858
+ function assertReposCanBind(review) {
7859
+ const entries = review.repos ?? [];
7860
+ const unbindable = findUnbindableRepos(entries);
7861
+ if (unbindable.length === 0) return;
7862
+ const detail = unbindable.map(({ index, problem }) => ` repos[${index}] \u2014 ${REPO_PROBLEM_HINT[problem]}`).join("\n");
7863
+ throw new Error(
7864
+ `${unbindable.length} of ${review.repos?.length} 'repos' entr${unbindable.length === 1 ? "y" : "ies"} cannot be bound to a repository:
7865
+ ${detail}
7866
+ 'repos' is what ties this record to the repository it reviewed, so an entry that resolves to nothing would leave the record stored but invisible.`
7867
+ );
7868
+ }
7820
7869
  async function readReviewInput(options, ctx) {
7821
7870
  if (options.file !== void 0) {
7822
7871
  try {
@@ -7849,6 +7898,8 @@ function reviewToPayload(review) {
7849
7898
  reviewer: review.reviewer,
7850
7899
  target: review.target
7851
7900
  };
7901
+ if (review.repos !== void 0) payload.repos = review.repos;
7902
+ if (review.commits !== void 0) payload.commits = review.commits;
7852
7903
  if (review.verdict !== void 0) payload.verdict = review.verdict;
7853
7904
  if (review.findings !== void 0) payload.findings = review.findings;
7854
7905
  if (review.blocked !== void 0) payload.blocked = review.blocked;
@@ -7857,6 +7908,9 @@ function reviewToPayload(review) {
7857
7908
  function reviewSummaryLine(review) {
7858
7909
  const parts = [];
7859
7910
  if (review.verdict !== void 0) parts.push(`verdict: ${review.verdict}`);
7911
+ if (review.repos !== void 0) {
7912
+ parts.push(`${review.repos.length} repo${review.repos.length === 1 ? "" : "s"}`);
7913
+ }
7860
7914
  if (review.findings !== void 0) {
7861
7915
  parts.push(`${review.findings.length} finding${review.findings.length === 1 ? "" : "s"}`);
7862
7916
  }
@@ -7973,19 +8027,45 @@ function relAge(iso, now) {
7973
8027
  if (hours >= 1) return `${hours}h ago`;
7974
8028
  return `${Math.max(1, Math.floor(ms / 6e4))}m ago`;
7975
8029
  }
8030
+ function flatten(value) {
8031
+ return value.replace(/\s+/gu, " ").trim();
8032
+ }
8033
+ var GRAPHEMES = new Intl.Segmenter(void 0, { granularity: "grapheme" });
8034
+ function graphemes(value) {
8035
+ return [...GRAPHEMES.segment(value)].map((s) => s.segment);
8036
+ }
8037
+ function oneLine(value, max) {
8038
+ const flat = graphemes(flatten(value));
8039
+ return flat.length > max ? `${flat.slice(0, max - 1).join("")}\u2026` : flat.join("");
8040
+ }
8041
+ function claimedCommits(commits) {
8042
+ if (commits.length === 0) return "";
8043
+ const shown = commits.slice(0, 3).map((c) => graphemes(flatten(c)).slice(0, 8).join(""));
8044
+ return ` claiming ${shown.join(", ")}${commits.length > shown.length ? ", ..." : ""}`;
8045
+ }
8046
+ var SELF_REPORTS_SHOWN = 3;
8047
+ function selfReportSuffix(u, stillCounted) {
8048
+ if (u.selfReports.length === 0) return "";
8049
+ const parts = u.selfReports.slice(0, SELF_REPORTS_SHOWN).map(
8050
+ (r) => `${oneLine(r.reviewer, 40)}${claimedCommits(r.commits)}${r.recordedAfterCommit ? " (recorded after the commit)" : ""}`
8051
+ );
8052
+ const rest = u.selfReports.length - parts.length;
8053
+ if (rest > 0) parts.push(`+${rest} more`);
8054
+ return ` \xB7 self-reported by ${parts.join("; ")} \u2014 ${stillCounted ? "unverified, still counted" : "unverified"}`;
8055
+ }
7976
8056
  function unitLine(u, now) {
7977
8057
  const when = relAge(u.lastCommitAt, now);
7978
8058
  const head = `- ${u.repo} ${when} (${u.commitCount} commit${u.commitCount === 1 ? "" : "s"})`;
7979
8059
  if (u.verdict === "near_unbound") {
7980
8060
  const ids = u.reviews.map((r) => r.sessionId.slice(0, 14)).join(", ");
7981
- return `${head} \u2014 a nearby review exists, but the diff / changed files were not examined [${ids}]`;
8061
+ return `${head} \u2014 a nearby review exists, but the diff / changed files were not examined [${ids}]${selfReportSuffix(u, true)}`;
7982
8062
  }
7983
- return `${head} \u2014 no bound cross-model review`;
8063
+ return `${head} \u2014 no bound cross-model review${selfReportSuffix(u, true)}`;
7984
8064
  }
7985
8065
  function candidateLine(u, now) {
7986
8066
  const when = relAge(u.lastCommitAt, now);
7987
8067
  const cite = u.reviews.map((r) => `${r.sessionId.slice(0, 14)}${r.examinedDiff ? "(diff)" : ""}`).join(", ");
7988
- return `- ${u.repo} ${when} (${u.commitCount} commit${u.commitCount === 1 ? "" : "s"}) \u2014 review trace: ${cite}`;
8068
+ return `- ${u.repo} ${when} (${u.commitCount} commit${u.commitCount === 1 ? "" : "s"}) \u2014 review trace: ${cite}${selfReportSuffix(u, false)}`;
7989
8069
  }
7990
8070
  function renderReviewGaps(summary) {
7991
8071
  const now = new Date(summary.generatedAt);
@@ -7993,7 +8073,12 @@ function renderReviewGaps(summary) {
7993
8073
  const scope = summary.scope ? summary.scope.join(", ") : "all repositories";
7994
8074
  lines.push(`# Review-trail gaps (${scope})`);
7995
8075
  lines.push("");
7996
- if (summary.gaps.length === 0) {
8076
+ if (summary.gaps.length === 0 && summary.unknowns.length > 0) {
8077
+ const n = summary.unknowns.reduce((sum, u) => sum + u.commitCount, 0);
8078
+ lines.push(
8079
+ `\u26A0\uFE0F No unit of work was found without a review trail \u2014 but ${n} captured commit${n === 1 ? "" : "s"} could not be placed in a repository at all, so ${n === 1 ? "it was" : "they were"} never checked (see below).`
8080
+ );
8081
+ } else if (summary.gaps.length === 0) {
7997
8082
  lines.push("\u2705 Within the captured range, no unit of work landed without a review trail.");
7998
8083
  } else {
7999
8084
  lines.push(`\u26A0\uFE0F Units of work that landed without a review trail: ${summary.gaps.length}`);
@@ -8010,22 +8095,65 @@ function renderReviewGaps(summary) {
8010
8095
  if (summary.unknowns.length > 0) {
8011
8096
  const n = summary.unknowns.reduce((sum, u) => sum + u.commitCount, 0);
8012
8097
  lines.push(
8013
- `## Undeterminable (${summary.unknowns.length} unit${summary.unknowns.length === 1 ? "" : "s"} / ${n} commit${n === 1 ? "" : "s"}) \u2014 repo or timestamp could not be derived from capture; verdict withheld (not a clear)`
8098
+ `## Undeterminable (${summary.unknowns.length} unit${summary.unknowns.length === 1 ? "" : "s"} / ${n} commit${n === 1 ? "" : "s"}) \u2014 repo or timestamp could not be derived from capture; verdict withheld (not a clear). Belongs to no repository, so this is listed even under --repo`
8014
8099
  );
8100
+ for (const u of summary.unknowns) {
8101
+ lines.push(
8102
+ `- ${relAge(u.lastCommitAt, now)} (${u.commitCount} commit${u.commitCount === 1 ? "" : "s"}) [${u.sessionId}]`
8103
+ );
8104
+ }
8015
8105
  lines.push("");
8016
8106
  }
8017
8107
  lines.push("## By repository");
8018
8108
  for (const r of summary.repos) {
8019
8109
  lines.push(
8020
- `- ${r.repo}: ${r.units} unit${r.units === 1 ? "" : "s"} (no trail ${r.omissionUnits} / nearby only ${r.nearUnboundUnits} / to confirm ${r.candidateUnits}${r.unknownUnits > 0 ? ` / unknown ${r.unknownUnits}` : ""})`
8110
+ `- ${r.repo}: ${r.units} unit${r.units === 1 ? "" : "s"} (no trail ${r.omissionUnits} / nearby only ${r.nearUnboundUnits} / to confirm ${r.candidateUnits}${r.unknownUnits > 0 ? ` / unknown ${r.unknownUnits}` : ""}${r.selfReportedGapUnits > 0 ? ` / self-reported ${r.selfReportedGapUnits}` : ""})`
8021
8111
  );
8022
8112
  }
8023
8113
  lines.push("");
8024
8114
  lines.push(
8025
8115
  `Note: read-only advisory. Only captured commits are in scope (newest captured commit: ${summary.newestCommitAt === null ? "none" : relAge(summary.newestCommitAt, now)}). It never auto-judges that a review "happened", and temporal proximity alone is not a pass. It does not enforce.`
8026
8116
  );
8117
+ if (summary.gaps.some((u) => u.selfReports.length > 0)) {
8118
+ lines.push(
8119
+ 'Note: a "self-reported" unit has a `basou review record` naming this repo, but nothing corroborates it \u2014 it stays in the count above, because an empty record must not be a way to make the number go down.'
8120
+ );
8121
+ }
8122
+ lines.push(...unattachedLines(summary.unattachedSelfReports));
8123
+ if (summary.refusedPairings > 0) {
8124
+ lines.push(
8125
+ `Note: ${summary.refusedPairings} pairing${summary.refusedPairings === 1 ? "" : "s"} between a recorded review and captured work could not be checked, because that work's own repository path was never verified.`
8126
+ );
8127
+ }
8027
8128
  return lines.join("\n");
8028
8129
  }
8130
+ function unattachedLines(u) {
8131
+ if (u.total === 0) return [];
8132
+ const lines = [
8133
+ `Note: ${u.total} recorded review${u.total === 1 ? "" : "s"} changed nothing in this report (counted across all repositories, not just any --repo scope):`
8134
+ ];
8135
+ if (u.noRepos > 0) {
8136
+ lines.push(
8137
+ ` ${u.noRepos} named no repository \u2014 add \`repos\` to the record; without it the record's own location is the planning repo, not the repo reviewed`
8138
+ );
8139
+ }
8140
+ if (u.unresolvableRepo > 0) {
8141
+ lines.push(
8142
+ ` ${u.unresolvableRepo} named a path that is not a repository root on this machine \u2014 unverifiable, so not paired with any work`
8143
+ );
8144
+ }
8145
+ if (u.noMatchingUnit > 0) {
8146
+ lines.push(
8147
+ ` ${u.noMatchingUnit} named a repository, but no captured unit of work fell within the window of it`
8148
+ );
8149
+ }
8150
+ if (u.unverifiableUnit > 0) {
8151
+ lines.push(
8152
+ ` ${u.unverifiableUnit} fell within the window of captured work, but that work's own repository path could not be verified \u2014 the pairing could not be checked either way`
8153
+ );
8154
+ }
8155
+ return lines;
8156
+ }
8029
8157
 
8030
8158
  // src/commands/run.ts
8031
8159
  import { mkdir as mkdir2 } from "fs/promises";