@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 +28 -3
- package/dist/cli.js +2 -1
- package/dist/commands/adopt.js +3 -0
- package/dist/commands/carry.js +3 -0
- package/dist/commands/hunks.js +4 -0
- package/dist/commands/init.js +16 -7
- package/dist/commands/review.js +3 -1
- package/dist/commands/reviews.js +70 -0
- package/dist/commands/show.js +18 -0
- package/dist/commands/sync.js +53 -0
- package/dist/html/client.js +30 -1
- package/dist/html/data.js +13 -0
- package/dist/html/style.css +9 -0
- package/dist/reviews.js +205 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +4 -0
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
|
|
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' +
|
package/dist/commands/adopt.js
CHANGED
|
@@ -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() === '';
|
package/dist/commands/carry.js
CHANGED
|
@@ -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)
|
package/dist/commands/hunks.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
//
|
|
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 (
|
|
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
|
|
103
|
-
if (
|
|
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)
|
package/dist/commands/review.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/show.js
CHANGED
|
@@ -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));
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
}
|
package/dist/html/client.js
CHANGED
|
@@ -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/style.css
CHANGED
|
@@ -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); }
|
package/dist/reviews.js
ADDED
|
@@ -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
package/skills/gloss/SKILL.md
CHANGED
|
@@ -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.
|