@ok-ggwp/git-gloss 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ git gloss review [<commit>|<range>] print a review prompt: git gloss review ma
30
30
  --material ...only the gloss and code, for a reviewer with its own instructions
31
31
  (which must tell it the author's text is untrusted)
32
32
  git gloss versions [<commit>|wip] every gloss a commit has had, newest first (--show <n>, --restore <n>)
33
+ git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings
33
34
  git gloss schema print the annotation JSON Schema
34
35
  ```
35
36
 
@@ -43,9 +44,16 @@ Requires Node 20.12+ and git.
43
44
 
44
45
  ```sh
45
46
  npm install -g @ok-ggwp/git-gloss # puts git-gloss on PATH, so `git gloss` works
46
- ln -s "$(npm root -g)/@ok-ggwp/git-gloss/skills/gloss" ~/.claude/skills/gloss # teach Claude Code to write glosses
47
47
  ```
48
48
 
49
+ Then teach Claude Code to write glosses by installing the `gloss` skill as a plugin, at the prompt of a Claude Code session:
50
+
51
+ ```
52
+ /plugin install git-gloss --marketplace ok-ggwp/git-gloss
53
+ ```
54
+
55
+ Answer `y` to add the marketplace and choose the user scope. From a shell, the same is `claude plugin install git-gloss --marketplace ok-ggwp/git-gloss`. `/plugin update` picks up new versions of the skill. (Without the plugin, a symlink works too: `ln -s "$(npm root -g)/@ok-ggwp/git-gloss/skills/gloss" ~/.claude/skills/gloss`.)
56
+
49
57
  git finds `git-gloss` on PATH, so `git gloss` works wherever npm's global folder is on PATH, as it is after a normal Node install. On Windows this should work the same way (npm installs a shell launcher git can run), but it hasn't been tested yet.
50
58
 
51
59
  For a team, add it to the project instead, so everyone gets the same version with `npm install`:
@@ -73,7 +81,7 @@ In each repo you use it in:
73
81
  Don't commit unless asked.
74
82
  ```
75
83
 
76
- Claude Code finds the procedure in the `gloss` skill (installed above). Other agents won't have the skill, so for them add a pointer in `AGENTS.md` to a copy of `skills/gloss/SKILL.md`.
84
+ Claude Code finds the procedure in the `gloss` skill (the plugin installed above). Other agents won't have the skill, so for them add a pointer in `AGENTS.md` to a copy of `skills/gloss/SKILL.md`.
77
85
 
78
86
  Put the line in the repo rather than in your user-level `CLAUDE.md`: it only makes sense where `git gloss init` has been run.
79
87
 
@@ -114,7 +122,24 @@ Nothing is deleted. Replacing a gloss, a group dropped from a wip gloss, or the
114
122
 
115
123
  `git gloss review | claude -p` gives a reviewer the gloss and the code, and asks it whether the gloss is honest: does each group do what it says, is its necessity graded fairly, what could be cut.
116
124
 
117
- To have agents review in a sandbox, where they can run the code and verify what they find, use [git-gud](https://github.com/ok-ggwp/git-gud). `git gud review [<commit>|<range>|wip]` runs this honesty review, a blind bug review that doesn't see the gloss, and an improvements review informed by it, with Claude Code, Codex or Cursor as the reviewer. git-gloss itself never runs an agent.
125
+ To have agents review in a disposable clone, where they can run the code and verify what they find, use [git-gud](https://github.com/ok-ggwp/git-gud). `git gud review [<commit>|<range>|wip]` runs this honesty review, a blind bug review that doesn't see the gloss, and an improvements review informed by it, with Claude Code, Codex or Cursor as the reviewer. git-gloss itself never runs an agent.
126
+
127
+ ### Recorded reviews
128
+
129
+ git-gud records each review beside the glosses (`refs/notes/gloss-reviews`), so whoever reads a branch later can see whether its glosses were checked and what the checks found:
130
+
131
+ ```
132
+ $ git gloss show main..feature
133
+ main..feature: 4 commits, 4 glossed, 3 honesty-reviewed (1 with issues)
134
+ ...
135
+ reviews: honesty: no issues (claude, 2026-10-18) · bugs: defects found (claude, 2026-10-18)
136
+ ```
137
+
138
+ `git gloss reviews <commit>` prints the findings, and the HTML view shows each commit's reviews, with their reports a click away. A review records what it saw: a fingerprint of each changed line and of each gloss group. So it keeps counting through rebases, squashes and a wip gloss moved onto commits. If the code or the gloss changed after the review, the line says so ("gloss changed since review") instead of vouching for it.
139
+
140
+ A review says that something was checked, not that it passed. Each review ends with a verdict (`VERDICT: CLEAN` or `VERDICT: ISSUES`). For each part of the commit, the latest review that saw it decides, and a clean result that followed one with issues says so.
141
+
142
+ Like glosses, reviews follow rebased commits in repos set up with `git gloss init`. Reviews are shared by `git gloss sync` (and so by every push with the hooks) like glosses, for commits the remote has. They never conflict: everyone's reviews are kept. Other tools can record reviews too: `git gloss reviews --stamp <rev>` before the review, then `git gloss reviews --record` with the stamp and the report as JSON on stdin.
118
143
 
119
144
  ## HTML view
120
145
 
package/dist/cli.js CHANGED
@@ -6,13 +6,14 @@ import * as html from './commands/html.js';
6
6
  import * as hunks from './commands/hunks.js';
7
7
  import * as init from './commands/init.js';
8
8
  import * as review from './commands/review.js';
9
+ import * as reviews from './commands/reviews.js';
9
10
  import * as schema from './commands/schema.js';
10
11
  import * as show from './commands/show.js';
11
12
  import * as sync from './commands/sync.js';
12
13
  import * as versions from './commands/versions.js';
13
14
  import * as why from './commands/why.js';
14
15
  const COMMANDS = {
15
- init, hunks, add, adopt, carry, show, html, why, review, sync, versions, schema,
16
+ init, hunks, add, adopt, carry, show, html, why, review, reviews, sync, versions, schema,
16
17
  };
17
18
  function help() {
18
19
  return 'usage: git gloss <command> [options]\n\n' +
@@ -6,6 +6,7 @@ import { location, parseDiff } from '../diff.js';
6
6
  import { noteFormat } from '../schema.js';
7
7
  import { refLabel } from '../selection.js';
8
8
  import { carriedErrors } from '../validate.js';
9
+ import { copyReviews } from '../reviews.js';
9
10
  export const usage = 'adopt [<commit>] [--force] move the gloss of uncommitted work (wip) onto a commit made from it';
10
11
  // Carry a wip snapshot's gloss onto a real commit made from it (see
11
12
  // carryGloss): a commit takes the groups whose hunks it contains, so the work
@@ -70,6 +71,8 @@ export function run(argv) {
70
71
  annotation,
71
72
  hunks: current.map(location),
72
73
  });
74
+ // Reviews of the uncommitted work cover the parts of it this commit holds.
75
+ copyReviews([wip], sha);
73
76
  // Once nothing uncommitted is left, the snapshot has served its purpose.
74
77
  const head = gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim();
75
78
  const done = head === sha && git(['status', '--porcelain']).trim() === '';
@@ -8,6 +8,7 @@ import { mergeGlosses } from '../merge.js';
8
8
  import { noteFormat } from '../schema.js';
9
9
  import { refLabel } from '../selection.js';
10
10
  import { carriedErrors } from '../validate.js';
11
+ import { copyReviews } from '../reviews.js';
11
12
  export const usage = 'carry <source>... [--to <commit>] [--force] | carry --find [--squash] [<commit>...] gloss a commit from the glosses of the commits its changes came from (a squash, cherry-pick or rebase); --find works out which commits those are';
12
13
  // The target's current gloss: none, one written by hand, one carried, or one
13
14
  // that can't be read (such as the joined notes a squash leaves). Carrying may
@@ -68,6 +69,8 @@ export function carry(sourceShas, target) {
68
69
  hunks: current.map(location),
69
70
  sources: merged.used,
70
71
  });
72
+ // The sources' reviews come along; their keys decide what of this commit they cover.
73
+ copyReviews(merged.used, target);
71
74
  const lines = [`carried ${merged.annotation.groups.length} group${merged.annotation.groups.length === 1 ? '' : 's'} from ` +
72
75
  `${merged.used.map((s) => s.slice(0, 7)).join(', ')} onto ${short}`];
73
76
  if (merged.dropped.length)
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { commitDiff, dropWipNote, readNote, resolveCommit, snapshotWorkingTree, WIP_NOTES_REF, writeNote } from '../git.js';
3
3
  import { carryGloss } from '../carry.js';
4
+ import { copyReviews } from '../reviews.js';
4
5
  import { location, parseDiff } from '../diff.js';
5
6
  import { noteFormat } from '../schema.js';
6
7
  import { colorDiff, hunkLabel, page, style } from '../render.js';
@@ -40,6 +41,9 @@ export function run(argv) {
40
41
  // them, since their text is discarded with the old snapshot's gloss.
41
42
  if (carried.dropped.length)
42
43
  console.error(`note: not kept (their changes were edited or committed): ${carried.dropped.join(', ')}`);
44
+ // Reviews of the old snapshot come along; their fingerprints say what
45
+ // of the new one they still cover.
46
+ copyReviews([snap.replaced], sha);
43
47
  dropWipNote(snap.replaced);
44
48
  }
45
49
  }
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
3
3
  import { join, resolve, sep } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { git, gitTry, NOTES_REF, REMOTE_NOTES_REF, WIP_REF } from '../git.js';
6
+ import { REVIEWS_REF } from '../reviews.js';
6
7
  export const usage = 'init [--hooks] set up glosses to follow amends/rebases and fetch from origin; --hooks: sync on push, adopt wip glosses on commit';
7
8
  const HOOK_MARKER = '# installed by git-gloss';
8
9
  // Runs before every `git push`. It syncs glosses with the remote being pushed
@@ -84,23 +85,31 @@ function installHook(name, body, done, manual) {
84
85
  }
85
86
  export function run(argv = []) {
86
87
  const { values } = parseArgs({ args: argv, options: { hooks: { type: 'boolean' } } });
88
+ // Reviews follow commits too; a squash joins their lines, which is what they need.
87
89
  const rewriteRefs = gitTry(['config', '--get-all', 'notes.rewriteRef'])?.split('\n') ?? [];
88
- if (!rewriteRefs.includes(NOTES_REF))
89
- git(['config', '--add', 'notes.rewriteRef', NOTES_REF]);
90
+ for (const ref of [NOTES_REF, REVIEWS_REF])
91
+ if (!rewriteRefs.includes(ref))
92
+ git(['config', '--add', 'notes.rewriteRef', ref]);
90
93
  console.log(`✓ glosses follow commits through amend and rebase (notes.rewriteRef=${NOTES_REF})`);
91
94
  console.log(' if the commit\'s diff changes, `show` and `review` will flag the gloss as stale');
92
95
  if (gitTry(['remote', 'get-url', 'origin'])) {
93
96
  // Fetch into a separate ref and merge explicitly. Fetching straight into
94
97
  // NOTES_REF with a forced refspec would overwrite unpushed local glosses.
95
- const refspec = `+${NOTES_REF}:${REMOTE_NOTES_REF}`;
96
- // Earlier versions of init added exactly that destructive refspec.
98
+ // One glob for the glosses and their reviews: a glob doesn't make
99
+ // `git fetch` fail while origin has none yet, as a plain refspec does.
100
+ const refspec = `+${NOTES_REF}*:${REMOTE_NOTES_REF}*`;
101
+ const fetches = () => gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n') ?? [];
102
+ // Earlier versions of init added exactly that destructive refspec, and
103
+ // later ones a plain one that the glob replaces.
97
104
  const legacy = `+${NOTES_REF}:${NOTES_REF}`;
98
- if (gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n').includes(legacy)) {
105
+ if (fetches().includes(legacy)) {
99
106
  git(['config', '--unset-all', '--fixed-value', 'remote.origin.fetch', legacy]);
100
107
  console.log(`✓ removed the old refspec ${legacy}, which overwrote local glosses on fetch`);
101
108
  }
102
- const fetches = gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n') ?? [];
103
- if (!fetches.includes(refspec))
109
+ const plain = `+${NOTES_REF}:${REMOTE_NOTES_REF}`;
110
+ if (fetches().includes(plain))
111
+ git(['config', '--unset-all', '--fixed-value', 'remote.origin.fetch', plain]);
112
+ if (!fetches().includes(refspec))
104
113
  git(['config', '--add', 'remote.origin.fetch', refspec]);
105
114
  console.log(`✓ \`git fetch\` stores origin's glosses in ${REMOTE_NOTES_REF}`);
106
115
  if (!values.hooks)
@@ -107,7 +107,9 @@ Report, most important first:
107
107
  5. **Missing risks**: risks the author did not list.${range ? `
108
108
  6. **Across the branch**: later commits that undo, fix or rework earlier ones; the same intent spread over several commits; unglossed commits; whether the branch as a whole is lean for what it sets out to do.` : ''}
109
109
 
110
- For each finding, cite the ${range ? 'commit and ' : ''}group id and explain briefly. If the annotation is accurate and the change is lean, say so.`,
110
+ For each finding, cite the ${range ? 'commit and ' : ''}group id and explain briefly. If the annotation is accurate and the change is lean, say so.
111
+
112
+ End your report with one line on its own: \`VERDICT: CLEAN\` if you found no mismatches, misgrading or missing risks worth acting on, otherwise \`VERDICT: ISSUES\`. Tools record this line with the review.`,
111
113
  '---\n\n# Material under review',
112
114
  ...material,
113
115
  ];
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
5
+ import { addReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
6
+ import { page, style } from '../render.js';
7
+ export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings; --stamp and --record are for review tools';
8
+ export function run(argv) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ allowPositionals: true,
12
+ options: { stamp: { type: 'boolean' }, record: { type: 'boolean' } },
13
+ });
14
+ if (values.record) {
15
+ const input = JSON.parse(readFileSync(0, 'utf8'));
16
+ for (const k of ['stage', 'agent', 'target', 'findings']) {
17
+ if (typeof input[k] !== 'string' || !input[k])
18
+ throw new GlossError(`--record: "${k}" is missing`);
19
+ }
20
+ if (!input.stamp || typeof input.stamp !== 'object')
21
+ throw new GlossError('--record: "stamp" is missing (take it with --stamp before the review)');
22
+ // Every commit the stamp names must exist before anything is written, so
23
+ // a bad stamp records nothing rather than part of the review.
24
+ for (const [sha, seen] of Object.entries(input.stamp)) {
25
+ if (!/^[0-9a-f]{40,64}$/.test(sha) || gitTry(['cat-file', '-e', `${sha}^{commit}`]) === null) {
26
+ throw new GlossError(`--record: the stamp names ${sha.slice(0, 12)}, which isn't a commit here`);
27
+ }
28
+ if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
29
+ throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
30
+ }
31
+ const id = randomUUID();
32
+ const at = new Date().toISOString();
33
+ for (const [sha, seen] of Object.entries(input.stamp)) {
34
+ const record = {
35
+ review: 1, id, at, stage: input.stage, agent: input.agent,
36
+ ...(input.model ? { model: input.model } : {}), ...(input.mode ? { mode: input.mode } : {}),
37
+ target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
38
+ ...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
39
+ };
40
+ addReviews(sha, [record]);
41
+ }
42
+ const n = Object.keys(input.stamp).length;
43
+ console.log(`recorded the ${input.stage} review on ${n} commit${n === 1 ? '' : 's'}`);
44
+ return 0;
45
+ }
46
+ const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
47
+ if (values.stamp) {
48
+ console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
49
+ return 0;
50
+ }
51
+ const out = [];
52
+ for (const sha of shas) {
53
+ const statuses = reviewStatus(sha);
54
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`);
55
+ if (!statuses.length) {
56
+ out.push(style('dim', ' no reviews recorded'), '');
57
+ continue;
58
+ }
59
+ for (const s of statuses) {
60
+ out.push(` ${statusPhrase(s)}`);
61
+ for (const r of s.reviews) {
62
+ out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}`));
63
+ out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
64
+ }
65
+ out.push('');
66
+ }
67
+ }
68
+ page(out.join('\n'));
69
+ return 0;
70
+ }
@@ -3,6 +3,7 @@ import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
3
3
  import { refLabel, sliceStats, sliceText } from '../selection.js';
4
4
  import { loadGloss } from '../loader.js';
5
5
  import { NECESSITY } from '../schema.js';
6
+ import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
6
7
  import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
7
8
  export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
8
9
  // Counts over the lines each part selects, not whole hunks, so groups that
@@ -77,8 +78,15 @@ export function run(argv) {
77
78
  const glosses = shas.map((sha) => loadGloss(sha));
78
79
  const glossed = glosses.filter(Boolean).length;
79
80
  const stale = glosses.filter((g) => g?.stale).length;
81
+ // How many glossed commits had their gloss checked: an honesty review that
82
+ // saw this code and this gloss.
83
+ const honesty = shas.map((sha, i) => (glosses[i] ? reviewStatus(sha).find((r) => r.stage === 'honesty' && current(r)) : undefined));
84
+ const vetted = honesty.filter(Boolean).length;
85
+ const flagged = honesty.filter((r) => r && verdict(r) === 'issues').length;
80
86
  const out = [
81
87
  style('bold', `${range}: ${shas.length} commit${shas.length === 1 ? '' : 's'}, ${glossed} glossed`) +
88
+ (glossed ? style(vetted === glossed ? 'green' : 'yellow', `, ${vetted} honesty-reviewed`) : '') +
89
+ (flagged ? style('yellow', ` (${flagged} with issues)`) : '') +
82
90
  (stale ? style('red', ` (${stale} stale)`) : '') +
83
91
  (merges.length ? style('dim', ` (${merges.length} merge${merges.length === 1 ? '' : 's'} not shown)`) : ''),
84
92
  ];
@@ -98,6 +106,15 @@ export function run(argv) {
98
106
  page(out.join('\n'));
99
107
  return 0;
100
108
  }
109
+ // One line on whether the commit as it is now was reviewed, per stage.
110
+ function reviewsLine(sha) {
111
+ const statuses = reviewStatus(sha);
112
+ if (!statuses.length)
113
+ return style('dim', 'reviews: none recorded');
114
+ const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
115
+ return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
116
+ style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
117
+ }
101
118
  // One commit's view, or null if no group matches -g/--only. `hints` adds the
102
119
  // footer on how to expand, which a range prints once instead.
103
120
  function renderCommit(sha, gloss, values, only, hints) {
@@ -109,6 +126,7 @@ function renderCommit(sha, gloss, values, only, hints) {
109
126
  out.push(...field('summary:', a.summary, 0));
110
127
  for (const w of gloss.warnings)
111
128
  out.push(...field('⚠', w, 0).map((l) => style('red', l)));
129
+ out.push(reviewsLine(sha));
112
130
  const groups = gloss.groups
113
131
  .filter((g) => !only || only.includes(g.group.necessity))
114
132
  .filter((g) => !values.group || values.group.includes(g.group.id));
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { git, gitTry, GitError, NOTES_REF } from '../git.js';
4
4
  import { carryNew } from '../origins.js';
5
+ import { REVIEWS_REF } from '../reviews.js';
5
6
  import { carry } from './carry.js';
6
7
  export const usage = 'sync [<remote>] fetch, merge and push glosses (default remote: origin)';
7
8
  const MAX_ATTEMPTS = 3;
@@ -150,6 +151,51 @@ function round(remote, pushing, carried) {
150
151
  }
151
152
  return { fetched, pushed: toPush.length, withheld, conflicts };
152
153
  }
154
+ const REVIEWS_PUSH_REF = 'refs/notes/gloss-reviews-sync-tmp';
155
+ // Reviews are shared like glosses, but never conflict: each review is a line,
156
+ // and both sides' lines are kept (git's cat_sort_uniq). Only reviews of
157
+ // commits the remote may see are sent. Returns null if the push lost a race.
158
+ function syncReviews(remote, pushing) {
159
+ const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
160
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
161
+ if (remoteHas)
162
+ git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
163
+ const before = noteList(REVIEWS_REF);
164
+ const theirs = remoteHas ? noteList(tracking) : new Map();
165
+ if (remoteHas) {
166
+ if (gitTry(['rev-parse', '-q', '--verify', REVIEWS_REF]) === null)
167
+ git(['update-ref', REVIEWS_REF, tracking]);
168
+ else
169
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', tracking]);
170
+ }
171
+ const merged = noteList(REVIEWS_REF);
172
+ const fetched = [...merged].filter(([object, blob]) => before.get(object) !== blob).length;
173
+ const allowed = shareable(remote, pushing);
174
+ const toPush = [...merged].filter(([object, blob]) => allowed.has(object) && theirs.get(object) !== blob);
175
+ if (!toPush.length)
176
+ return { fetched, pushed: 0 };
177
+ try {
178
+ if (remoteHas)
179
+ git(['update-ref', REVIEWS_PUSH_REF, tracking]);
180
+ else
181
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
182
+ for (const [object, blob] of toPush)
183
+ git(['notes', '--ref', REVIEWS_PUSH_REF, 'add', '-f', '-C', blob, object]);
184
+ try {
185
+ git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`]);
186
+ }
187
+ catch (err) {
188
+ if (/\(fetch first\)|non-fast-forward/.test(err.message))
189
+ return null;
190
+ throw err;
191
+ }
192
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', REVIEWS_PUSH_REF]);
193
+ }
194
+ finally {
195
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
196
+ }
197
+ return { fetched, pushed: toPush.length };
198
+ }
153
199
  export function run(argv) {
154
200
  // --pushing (from the pre-push hook): read the refs being pushed from stdin,
155
201
  // as git gives them to the hook, so glosses of those commits go too.
@@ -183,6 +229,13 @@ export function run(argv) {
183
229
  }
184
230
  for (const c of carried)
185
231
  console.log(c);
232
+ let reviews = null;
233
+ for (let tries = 0; tries < MAX_ATTEMPTS && !reviews; tries++)
234
+ reviews = syncReviews(remote, pushing);
235
+ if (!reviews)
236
+ console.log(` reviews not synced: others kept pushing reviews to ${remote}; run git gloss sync again`);
237
+ else if (reviews.fetched || reviews.pushed)
238
+ console.log(` reviews: fetched ${reviews.fetched}, pushed ${reviews.pushed}`);
186
239
  if (outcome.withheld) {
187
240
  console.log(` kept ${outcome.withheld} gloss${outcome.withheld === 1 ? '' : 'es'} local: their commits aren't on ${remote} yet`);
188
241
  }
@@ -188,11 +188,31 @@
188
188
  var allGroups = []
189
189
  data.commits.forEach(function (c) { allGroups = allGroups.concat(c.groups) })
190
190
 
191
+ // Whether each commit as it is now was reviewed, per stage; each stage's
192
+ // reports open on demand. Findings are shown as text.
193
+ function reviewsBlock(c, target) {
194
+ var box = el('div', 'reviews')
195
+ if (!c.reviews.length) { add(target, add(box, el('span', 'review-none', 'No reviews recorded'))); return }
196
+ c.reviews.forEach(function (r) {
197
+ var d = el('details', 'review')
198
+ add(d, add(el('summary'), el('span', r.state === 'seen' && r.verdict !== 'issues' ? (r.verdict === 'clean' ? 'review-ok' : 'review-none') : 'stale', r.phrase)))
199
+ r.entries.forEach(function (e) {
200
+ add(d, el('div', 'note', r.stage + ' by ' + e.agent + (e.model ? ':' + e.model : '') + (e.mode ? ' (' + e.mode + ')' : '') +
201
+ ', ' + e.at.slice(0, 16).replace('T', ' ') + ', of ' + e.target), el('pre', 'findings', e.findings))
202
+ })
203
+ add(box, d)
204
+ })
205
+ add(target, box)
206
+ }
207
+
191
208
  // Header: the commit itself, or the range and its commits.
192
209
  if (multi) {
193
210
  var glossed = data.commits.filter(function (c) { return c.glossed }).length
211
+ var vetted = data.commits.filter(function (c) {
212
+ return c.glossed && c.reviews.some(function (r) { return r.stage === 'honesty' && r.state === 'seen' })
213
+ }).length
194
214
  add(main, add(el('div', 'commit'), el('span', 'sha', data.range),
195
- el('span', null, plural(data.commits.length, 'commit') + ', ' + glossed + ' glossed')))
215
+ el('span', null, plural(data.commits.length, 'commit') + ', ' + glossed + ' glossed' + (glossed ? ', ' + vetted + ' honesty-reviewed' : ''))))
196
216
  add(main, el('h1', null, 'Branch ' + data.range))
197
217
  var list = el('ol', 'commit-list')
198
218
  data.commits.forEach(function (c) {
@@ -201,6 +221,13 @@
201
221
  var li = add(el('li'), a)
202
222
  if (!c.glossed) add(li, el('span', 'stale', 'no gloss'))
203
223
  else if (c.warnings.length) add(li, el('span', 'stale', 'stale gloss'))
224
+ if (c.glossed) {
225
+ var honesty = c.reviews.filter(function (r) { return r.stage === 'honesty' })[0]
226
+ add(li, !honesty ? el('span', 'stale', 'not reviewed')
227
+ : honesty.state !== 'seen' ? el('span', 'stale', 'changed since review')
228
+ : honesty.verdict === 'issues' ? el('span', 'stale', 'honesty: issues found')
229
+ : el('span', 'review-ok', honesty.verdict === 'clean' ? 'honesty: no issues' : 'honesty-reviewed'))
230
+ }
204
231
  list.appendChild(li)
205
232
  })
206
233
  data.merges.forEach(function (m) {
@@ -214,6 +241,7 @@
214
241
  statement(main, 'Task', only.task)
215
242
  statement(main, 'Summary', only.summary)
216
243
  only.warnings.forEach(function (w) { add(main, el('div', 'warning', '⚠ ' + w)) })
244
+ reviewsBlock(only, main)
217
245
  }
218
246
 
219
247
  // Necessity overview across every commit: bar weighted by changed lines,
@@ -266,6 +294,7 @@
266
294
  statement(target, 'Task', c.task)
267
295
  statement(target, 'Summary', c.summary)
268
296
  c.warnings.forEach(function (w) { add(target, el('div', 'warning', '⚠ ' + w)) })
297
+ reviewsBlock(c, target)
269
298
  add(main, target)
270
299
  }
271
300
  var tocList = commitBody(c, target)
package/dist/html/data.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import { commitDiff, commitSubject, git } from '../git.js';
2
+ import { reviewStatus, statusPhrase, verdict } from '../reviews.js';
2
3
  import { parseDiff } from '../diff.js';
3
4
  import { loadGloss } from '../loader.js';
4
5
  import { bodyLines, changedCount, gapText, isGap, refLabel, slice, sliceStats } from '../selection.js';
5
6
  import { NECESSITY, NECESSITY_HELP, servesOf } from '../schema.js';
7
+ function pageReviews(sha) {
8
+ return reviewStatus(sha).map((s) => ({
9
+ stage: s.stage,
10
+ state: s.code === 'changed' ? 'code-changed'
11
+ : (s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed' ? 'gloss-changed' : 'seen',
12
+ verdict: verdict(s),
13
+ phrase: statusPhrase(s),
14
+ entries: s.reviews.map((r) => ({ agent: r.agent, model: r.model, mode: r.mode, at: r.at, target: r.target, findings: r.findings })),
15
+ }));
16
+ }
6
17
  // The lines to show for a selection: the @@ header, then the slice, with
7
18
  // changed lines that belong to other groups collapsed into gap rows.
8
19
  export function pageLines(h, selected) {
@@ -102,6 +113,7 @@ export function commitData(gloss) {
102
113
  unannotated: gloss.unannotated.map((u) => pageHunk(u.hunk, u.lines, 'unannotated', [])),
103
114
  alternatives: a.alternatives ?? [],
104
115
  risks: a.risks ?? [],
116
+ reviews: pageReviews(gloss.sha),
105
117
  };
106
118
  }
107
119
  function unglossedData(sha) {
@@ -118,6 +130,7 @@ function unglossedData(sha) {
118
130
  unannotated: parseDiff(commitDiff(sha)).map((h) => pageHunk(h, null, 'unannotated', [])),
119
131
  alternatives: [],
120
132
  risks: [],
133
+ reviews: pageReviews(sha),
121
134
  };
122
135
  }
123
136
  export function buildPageData(shas, range = null, merges = []) {
@@ -3,6 +3,7 @@
3
3
  --surface: #ffffff;
4
4
  --surface-2: #f1f1ee;
5
5
  --border: #e2e2dc;
6
+ --ok: #1a7f37;
6
7
  --text: #1d1d1b;
7
8
  --muted: #6b6b66;
8
9
  --accent: #3b5bdb;
@@ -34,6 +35,7 @@
34
35
  --surface: #1e1e1c;
35
36
  --surface-2: #262624;
36
37
  --border: #34342f;
38
+ --ok: #57c47a;
37
39
  --text: #e8e8e3;
38
40
  --muted: #9a9a92;
39
41
  --accent: #8ea2ff;
@@ -218,3 +220,10 @@ kbd { font-family: var(--mono); font-size: 11.5px; border: 1px solid var(--borde
218
220
  .commit-section h2 { font-size: 20px; margin: 8px 0 12px; }
219
221
  .commit-section .section h3 { font-size: 16px; margin: 0 0 12px; }
220
222
  .toc h2.commit-head { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
223
+
224
+ /* Reviews recorded for a commit */
225
+ .reviews { margin: 8px 0 12px; display: flex; flex-direction: column; gap: 4px; }
226
+ .review summary { cursor: pointer; }
227
+ .review-ok { color: var(--ok); font-weight: 600; }
228
+ .review-none { color: var(--muted); font-size: 0.9em; }
229
+ .findings { white-space: pre-wrap; font-size: 0.85em; max-height: 32em; overflow: auto; padding: 8px; border-left: 3px solid var(--border); }
@@ -0,0 +1,205 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { commitDiff, git, gitTry, GlossError, readNote } from './git.js';
3
+ import { parseDiff } from './diff.js';
4
+ import { bodyLines } from './selection.js';
5
+ // Reviews of commits (by git-gud, or anything else that records them), kept
6
+ // beside the glosses so a reader can see whether a gloss was checked. One JSON
7
+ // record per line: notes from different people and machines combine by taking
8
+ // every line (git's cat_sort_uniq), with no conflicts.
9
+ export const REVIEWS_REF = 'refs/notes/gloss-reviews';
10
+ // The report's VERDICT line, which has to be a line of its own among the
11
+ // last three, so a verdict quoted in the body can't stand in for it.
12
+ export function verdictOf(findings) {
13
+ const tail = findings.trim().split('\n').filter((l) => l.trim()).slice(-3).reverse();
14
+ for (const line of tail) {
15
+ // The whole line, give or take markdown: not a sentence that starts so.
16
+ const m = /^\W*VERDICT:\W*(CLEAN|ISSUES)\W*$/i.exec(line.trim());
17
+ if (m)
18
+ return m[1].toLowerCase();
19
+ }
20
+ return undefined;
21
+ }
22
+ const key = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
23
+ // One key per changed line, repeated for repeated lines (a second "}" is a
24
+ // second key), so adding another copy of a reviewed line isn't "seen". A
25
+ // change with no lines (a binary file, a mode change, a pure rename) is keyed
26
+ // by its file and content hash.
27
+ export const lineKeys = (hunks) => hunks.flatMap((h) => {
28
+ const lines = bodyLines(h).filter((l) => l.idx !== null);
29
+ return lines.length ? lines.map((l) => key(`${h.file}\0${l.raw}`)) : [key(`meta\0${h.file}\0${h.hash}`)];
30
+ }).sort();
31
+ // A group's meaning, without its id or hunk references (which change when a
32
+ // gloss is carried or adopted) and without the notes carrying adds.
33
+ export function groupKey(g) {
34
+ const notes = (g.notes ?? '').replace(/\s*\((?:from [0-9a-f]{7,}|also served [^)]*)\)/g, '').trim();
35
+ const serves = (Array.isArray(g.serves) ? g.serves : [g.serves]).slice().sort();
36
+ return key(JSON.stringify([g.intent, g.necessity, serves, g.removal_impact, g.evidence ?? '', notes]));
37
+ }
38
+ // The gloss's parts a reviewer checks: each group, each risk and each
39
+ // rejected alternative (keyed one by one, so a squash that combines several
40
+ // glosses' risks still matches the reviews of each). The task and summary
41
+ // are left out: carrying joins them.
42
+ export function glossKeys(a) {
43
+ return [
44
+ ...a.groups.map(groupKey),
45
+ ...(a.risks ?? []).map((r) => key(`risk\0${r}`)),
46
+ ...(a.alternatives ?? []).map((x) => key(`alternative\0${x.option}\0${x.rejected_because}`)),
47
+ ];
48
+ }
49
+ // What a reviewer of `sha` sees now: its changed lines and its gloss's groups.
50
+ export function stamp(sha) {
51
+ let groups = [];
52
+ try {
53
+ const note = readNote(sha);
54
+ groups = note ? glossKeys(note.annotation) : [];
55
+ }
56
+ catch { /* an unreadable gloss: nothing to stamp */ }
57
+ return { lines: lineKeys(parseDiff(commitDiff(sha))), groups };
58
+ }
59
+ export function readReviews(sha) {
60
+ const raw = gitTry(['notes', '--ref', REVIEWS_REF, 'show', sha]);
61
+ if (!raw)
62
+ return [];
63
+ // A rebase can join a note onto one with the same lines; each counts once.
64
+ return [...new Set(raw.split('\n'))].flatMap((line) => {
65
+ try {
66
+ const r = JSON.parse(line);
67
+ // Records arrive from other machines; skip any that aren't whole.
68
+ const ok = r?.review === 1 && Array.isArray(r.lines) && Array.isArray(r.groups) &&
69
+ ['id', 'at', 'stage', 'agent', 'target', 'findings'].every((k) => typeof r[k] === 'string') &&
70
+ ['model', 'mode'].every((k) => r[k] === undefined || typeof r[k] === 'string') &&
71
+ (r.verdict === undefined || r.verdict === 'clean' || r.verdict === 'issues');
72
+ return ok ? [r] : [];
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ });
78
+ }
79
+ // Adds records to a commit's reviews, keeping each line once. Several
80
+ // reviews can finish at once, so the write is a compare-and-swap: the new
81
+ // notes commit is built under a private ref, and the reviews ref moves to it
82
+ // only if nobody else moved it meanwhile; otherwise it starts again.
83
+ export function addReviews(sha, records) {
84
+ const mine = records.map((r) => JSON.stringify(r));
85
+ // git notes needs it under refs/notes; named so the refs/notes/gloss*
86
+ // fetch glob never matches it.
87
+ const tmp = `refs/notes/tmp-gloss-reviews-${process.pid}-${randomUUID().slice(0, 8)}`;
88
+ try {
89
+ for (let attempt = 0; attempt < 50; attempt++) {
90
+ const old = gitTry(['rev-parse', '-q', '--verify', REVIEWS_REF])?.trim() ?? null;
91
+ if (old)
92
+ git(['update-ref', tmp, old]);
93
+ else
94
+ gitTry(['update-ref', '-d', tmp]);
95
+ const existing = (gitTry(['notes', '--ref', tmp, 'show', sha]) ?? '').split('\n').filter(Boolean);
96
+ const lines = [...new Set([...existing, ...mine])].sort();
97
+ git(['notes', '--ref', tmp, 'add', '-f', '-F', '-', sha], lines.join('\n') + '\n');
98
+ const next = git(['rev-parse', tmp]).trim();
99
+ // An empty old value means "must not exist yet".
100
+ if (gitTry(['update-ref', REVIEWS_REF, next, old ?? '']) !== null)
101
+ return;
102
+ }
103
+ throw new GlossError(`couldn't record the review on ${sha.slice(0, 10)}: other processes kept writing reviews at the same time`);
104
+ }
105
+ finally {
106
+ gitTry(['update-ref', '-d', tmp]);
107
+ }
108
+ }
109
+ // Reviews follow a gloss onto commits made from the reviewed ones (adopt,
110
+ // carry); their keys decide what they still cover there.
111
+ export function copyReviews(from, to) {
112
+ const records = from.flatMap(readReviews);
113
+ if (records.length)
114
+ addReviews(to, records);
115
+ }
116
+ // Whether reviews saw every changed line, counting repeats: each distinct
117
+ // stamp (reviews of different commits, as for a squash) contributes its
118
+ // lines; reviews of the same stamp count once.
119
+ function covers(reviews, lines) {
120
+ const seen = new Map();
121
+ const stamps = new Set();
122
+ for (const r of reviews) {
123
+ const id = r.lines.join(',');
124
+ if (stamps.has(id))
125
+ continue;
126
+ stamps.add(id);
127
+ const counts = new Map();
128
+ for (const l of r.lines)
129
+ counts.set(l, (counts.get(l) ?? 0) + 1);
130
+ for (const [l, n] of counts)
131
+ seen.set(l, (seen.get(l) ?? 0) + n);
132
+ }
133
+ const need = new Map();
134
+ for (const l of lines)
135
+ need.set(l, (need.get(l) ?? 0) + 1);
136
+ return [...need].every(([l, n]) => (seen.get(l) ?? 0) >= n);
137
+ }
138
+ // Per stage: whether the commit as it is now was reviewed. A commit squashed
139
+ // from several reviewed ones is covered when their reviews together saw all
140
+ // of it.
141
+ export function reviewStatus(sha, current = stamp(sha)) {
142
+ const byStage = new Map();
143
+ for (const r of readReviews(sha))
144
+ byStage.set(r.stage, [...(byStage.get(r.stage) ?? []), r]);
145
+ return [...byStage].map(([stage, reviews]) => {
146
+ const groups = new Set(reviews.flatMap((r) => r.groups));
147
+ return {
148
+ stage,
149
+ now: current,
150
+ reviews: reviews.sort((a, b) => b.at.localeCompare(a.at)),
151
+ code: covers(reviews, current.lines) ? 'seen' : 'changed',
152
+ gloss: current.groups.length ? (current.groups.every((g) => groups.has(g)) ? 'seen' : 'changed') : null,
153
+ };
154
+ });
155
+ }
156
+ // Whether this stage's reviews still apply to the commit as it is now.
157
+ export const current = (s) => s.code === 'seen' && !((s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed');
158
+ // What the reviews concluded about the commit as it is now. Each part of it
159
+ // (a gloss group for honesty and improve, else a changed line) takes the
160
+ // verdict of the latest review that saw it, so fixing a gloss and having it
161
+ // reviewed again replaces the earlier verdict for the parts it saw. Issues
162
+ // if any part's latest review found some; clean if every part's said clean.
163
+ export function verdict(s) {
164
+ const v = latestVerdicts(s);
165
+ return v.includes('issues') ? 'issues' : v.length && v.every((x) => x === 'clean') ? 'clean' : 'unknown';
166
+ }
167
+ function latestVerdicts(s) {
168
+ const byGroups = (s.stage === 'honesty' || s.stage === 'improve') && s.now.groups.length;
169
+ const parts = byGroups ? s.now.groups : s.now.lines;
170
+ // s.reviews is newest first.
171
+ const latest = new Set();
172
+ for (const part of parts) {
173
+ // A review without a verdict line says nothing either way, so it doesn't
174
+ // replace an earlier one's.
175
+ const r = s.reviews.find((r) => r.verdict && (byGroups ? r.groups : r.lines).includes(part));
176
+ if (r)
177
+ latest.add(r);
178
+ }
179
+ return [...latest].map((r) => r.verdict);
180
+ }
181
+ // Whether a review older than the ones deciding the verdict found issues:
182
+ // a clean result is then shown as following an earlier one with issues.
183
+ export const earlierIssues = (s) => verdict(s) !== 'issues' && s.reviews.some((r) => r.verdict === 'issues');
184
+ // What a verdict means for each stage: improve's "issues" are suggestions.
185
+ const WORDS = {
186
+ bugs: { clean: 'no defects found', issues: 'defects found' },
187
+ improve: { clean: 'nothing to improve', issues: 'suggestions' },
188
+ default: { clean: 'no issues', issues: 'issues found' },
189
+ };
190
+ // One short phrase per stage, for summaries: "honesty: issues found (claude,
191
+ // 2026-10-18)". A review says the commit was checked, not that it passed.
192
+ export function statusPhrase(s) {
193
+ const latest = s.reviews[0];
194
+ const who = `${latest.agent}${latest.model ? `:${latest.model}` : ''}, ${latest.at.slice(0, 10)}${s.reviews.length > 1 ? `, ${s.reviews.length} reviews` : ''}`;
195
+ if (s.code === 'changed')
196
+ return `${s.stage}: code changed since review (${who})`;
197
+ if (!current(s)) {
198
+ const never = s.reviews.every((r) => !r.groups.length);
199
+ return `${s.stage}: ${never ? 'reviewed before it was glossed' : 'gloss changed since review'} (${who})`;
200
+ }
201
+ const v = verdict(s);
202
+ const words = WORDS[s.stage] ?? WORDS.default;
203
+ return `${s.stage}: ${v === 'clean' ? words.clean : v === 'issues' ? words.issues : 'reviewed'} (${who}` +
204
+ `${earlierIssues(s) ? '; an earlier review found issues' : ''})`;
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ok-ggwp/git-gloss",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Annotate agent-written diffs with intent and necessity, stored in git notes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -100,3 +100,7 @@ Where you can, set `evidence` to a test that exercises the change or the line of
100
100
  You don't need to look up intent before ordinary edits. Use `git gloss why` when you are genuinely unsure why existing code is the way it is, especially before removing or rewriting something that looks unnecessary, defensive or wrong. That is when an unexplained check gets deleted and a fixed bug comes back.
101
101
 
102
102
  Ask about the block you're changing, not each line: `git gloss why <file>:<start>-<end> --json`. You get one explanation per commit that last changed those lines: the group's intent, `removal_impact`, `notes`, and the commit's `risks` and rejected `alternatives`. Check the alternatives before proposing an approach someone already tried and turned down. If the latest change to the lines was only `mechanical` (a rename, a reformat), `why` looks back past it to the real explanation; `--history` shows every change.
103
+
104
+ ## Reading someone else's branch
105
+
106
+ When the user asks you to help review a branch or pull request, start from its glosses: `git fetch` (or `git gloss sync`), then `git gloss show <base>..<branch>`. You get each commit's task, groups and grades, and whether its gloss was honesty-reviewed. Lead with what the glosses grade `speculative`, `incidental` or `defensive`, and with commits that have no gloss or whose review found issues. `git gloss reviews <base>..<branch>` prints the recorded findings. A review vouches only for the gloss and code it saw: "changed since review" means it no longer does.