@ok-ggwp/git-gloss 0.1.0 → 0.3.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.
@@ -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 = []) {
package/dist/html/page.js CHANGED
@@ -6,7 +6,7 @@ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
6
6
  export function embedJson(data) {
7
7
  return JSON.stringify(data).replace(/</g, '\\u003c');
8
8
  }
9
- const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
9
+ export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
10
10
  export function renderPage(data) {
11
11
  // The page only runs our own inline script. If escaping ever failed, an
12
12
  // injected script would lack the nonce and the CSP would block it.
@@ -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.3.0",
4
4
  "description": "Annotate agent-written diffs with intent and necessity, stored in git notes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -25,6 +25,10 @@ Gloss changes **before they are committed**, unless the user asks you to commit
25
25
 
26
26
  To gloss a commit instead, use its revision (default `HEAD`) wherever these steps say `wip`.
27
27
 
28
+ ## Calling git-gloss
29
+
30
+ The commands below are written as `git gloss …`. That works only where git-gloss is on PATH. Check first with `command -v git-gloss`. If it isn't, look for a project install (`node_modules/.bin/git-gloss`, or a `package.json` that lists `@ok-ggwp/git-gloss`) and run the same commands as `pnpm exec git-gloss …`, `npx git-gloss …` or `node_modules/.bin/git-gloss …`. Use the package manager the repo uses. In a monorepo the install may sit in a subdirectory: run the commands from there, and give file paths to `why` relative to that directory. Don't install it globally just to run the skill.
31
+
28
32
  ## Steps
29
33
 
30
34
  1. Run `git gloss hunks wip` to snapshot the uncommitted work and see it split into hunks with ids `h1`, `h2`, …, with each hunk's changed (`+`/`-`) lines numbered from 1. If you change files afterwards, run it again: that takes a new snapshot, and a gloss must match the snapshot it was written for.
@@ -100,3 +104,7 @@ Where you can, set `evidence` to a test that exercises the change or the line of
100
104
  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
105
 
102
106
  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.
107
+
108
+ ## Reading someone else's branch
109
+
110
+ 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.