@rigour-labs/core 6.7.9 → 6.8.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.
Files changed (40) hide show
  1. package/dist/brief/briefing.d.ts +59 -0
  2. package/dist/brief/briefing.js +126 -0
  3. package/dist/brief/briefing.test.d.ts +1 -0
  4. package/dist/brief/briefing.test.js +107 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +2 -0
  7. package/dist/review/backtest-init.d.ts +8 -5
  8. package/dist/review/backtest-init.js +28 -10
  9. package/dist/review/backtest-init.test.js +39 -1
  10. package/dist/review/backtest-last.js +3 -1
  11. package/dist/review/backtest.d.ts +10 -0
  12. package/dist/review/backtest.js +11 -2
  13. package/dist/review/backtest.test.js +12 -0
  14. package/dist/review/reviewer/adapters.d.ts +4 -0
  15. package/dist/review/reviewer/adapters.js +23 -0
  16. package/dist/review/reviewer/adapters.test.js +40 -0
  17. package/dist/review/reviewer/inputs.d.ts +6 -0
  18. package/dist/review/reviewer/inputs.js +46 -6
  19. package/dist/review/reviewer/inputs.test.d.ts +1 -0
  20. package/dist/review/reviewer/inputs.test.js +52 -0
  21. package/dist/review/reviewer/prompt.js +8 -2
  22. package/dist/review/reviewer/record.d.ts +5 -0
  23. package/dist/review/reviewer/record.js +3 -3
  24. package/dist/review/reviewer/record.test.js +8 -0
  25. package/dist/review/reviewer/verdict.d.ts +40 -1
  26. package/dist/review/reviewer/verdict.js +150 -9
  27. package/dist/review/reviewer.d.ts +2 -0
  28. package/dist/review/reviewer.js +29 -9
  29. package/dist/review/reviewer.test.js +173 -8
  30. package/dist/review-learning/repo-rules.d.ts +14 -2
  31. package/dist/review-learning/repo-rules.js +59 -9
  32. package/dist/review-learning/repo-rules.test.js +39 -1
  33. package/dist/task/thread.d.ts +47 -0
  34. package/dist/task/thread.js +234 -0
  35. package/dist/task/thread.test.d.ts +1 -0
  36. package/dist/task/thread.test.js +133 -0
  37. package/dist/templates/universal-config.js +4 -0
  38. package/dist/types/index.d.ts +21 -0
  39. package/dist/types/index.js +7 -0
  40. package/package.json +6 -6
@@ -8,6 +8,7 @@
8
8
  * is better served by the few rules that name a file or identifier the
9
9
  * change actually touches.
10
10
  */
11
+ import { spawnSync } from 'child_process';
11
12
  import fs from 'fs';
12
13
  import path from 'path';
13
14
  import crypto from 'crypto';
@@ -20,12 +21,55 @@ const MAX_RULES = 5;
20
21
  const CONTINUES = /^\**\s*(why|how to apply|example|examples|evidence|exception|exceptions|fix|note)\b\s*:?\**\s*:?/i;
21
22
  /** Worded as a requirement: the team said must, never, always, only, every, do not. A rule without these is guidance. */
22
23
  const REQUIREMENT = /\b(must|never|always|only|every|do not|don't|forbidden|required|non-negotiable)\b/i;
24
+ /** The most rule files read, imports included: a loop or a sprawling import tree stops here. */
25
+ const MAX_RULE_SOURCES = 50;
26
+ /** Folders of someone else's code a repository carries: a rules file in one is that project's, not this team's. */
27
+ const VENDORED = /(^|\/)(vendor|vendors|third_party|third-party|node_modules|bower_components|external|externals)\//;
28
+ /**
29
+ * Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
30
+ * vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
31
+ * importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
32
+ * reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
33
+ */
23
34
  export function readRepoRules(cwd) {
24
- const files = [
25
- ...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
26
- ...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)),
35
+ const queue = [
36
+ ...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))).map(file => ({ file })),
37
+ ...nestedRuleFiles(cwd).map(file => ({ file, scope: `${path.posix.dirname(file)}/` })),
38
+ ...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)).map(file => ({ file })),
27
39
  ];
28
- return files.flatMap(file => splitRules(file, fs.readFileSync(path.join(cwd, file), 'utf8')));
40
+ const read = new Set();
41
+ const rules = [];
42
+ while (queue.length && read.size < MAX_RULE_SOURCES) {
43
+ const { file, scope } = queue.shift();
44
+ // The same file imported from two places is read once, with the scope it was first reached with: a root import first.
45
+ if (read.has(file))
46
+ continue;
47
+ read.add(file);
48
+ let text;
49
+ try {
50
+ text = fs.readFileSync(path.join(cwd, file), 'utf8');
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ rules.push(...splitRules(file, text).map(rule => (scope ? { ...rule, scope } : rule)));
56
+ for (const m of text.matchAll(/^@(\S+)\s*$/gm)) {
57
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), m[1].replace(/^\.\//, '')));
58
+ if (target.startsWith('..') || path.isAbsolute(m[1]) || m[1].startsWith('~') || !fs.existsSync(path.join(cwd, target)))
59
+ continue;
60
+ // Root imports go to the front, so a file both a root and a nested file import keeps the repository-wide scope.
61
+ if (scope)
62
+ queue.push({ file: target, scope });
63
+ else
64
+ queue.unshift({ file: target });
65
+ }
66
+ }
67
+ return rules;
68
+ }
69
+ /** AGENTS.md and CLAUDE.md in folders below the root, as git tracks them, outside vendored folders. */
70
+ function nestedRuleFiles(cwd) {
71
+ const listed = spawnSync('git', ['ls-files', '-z', '--', '*/AGENTS.md', '*/CLAUDE.md'], { cwd, encoding: 'utf8', timeout: 5000 });
72
+ return listed.status === 0 ? listed.stdout.split('\0').filter(f => f && !VENDORED.test(f)) : [];
29
73
  }
30
74
  /** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
31
75
  export function splitRules(source, text) {
@@ -74,7 +118,9 @@ export function splitRules(source, text) {
74
118
  * not filtering: on a large change most rules share some words, so the judge decides applicability
75
119
  * rule by rule from the top `limit`.
76
120
  */
77
- function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
121
+ function rulesForChange(rules, files, symbols, limit = MAX_RULES, namedOnly = false) {
122
+ // A folder's own rules are served only when the change touches that folder: never checked, so never broken, elsewhere.
123
+ rules = rules.filter(rule => !rule.scope || files.some(f => f.startsWith(rule.scope)));
78
124
  const changeWords = new Set([...files.flatMap(f => f.split(/[/._-]+/)), ...symbols].flatMap(meaningfulWords));
79
125
  const scored = rules.map(rule => {
80
126
  const pathHits = rule.paths.filter(p => files.some(f => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`) || f.endsWith(`/${p}`))).length;
@@ -82,7 +128,7 @@ function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
82
128
  const shared = new Set(meaningfulWords(rule.text).filter(w => changeWords.has(w))).size;
83
129
  return { rule, named: 3 * pathHits + 2 * symbolHits, shared };
84
130
  });
85
- return scored.filter(s => s.named > 0 || s.shared >= 2).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
131
+ return scored.filter(s => s.named > 0 || (!namedOnly && s.shared >= 2)).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
86
132
  }
87
133
  export function rulesSection(rules) {
88
134
  if (rules.length === 0)
@@ -97,11 +143,15 @@ function listRuleFiles(cwd, dir) {
97
143
  return [];
98
144
  }
99
145
  }
100
- /** The rules that apply to a diff's changed files and added identifiers, the top `limit`. */
101
- export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES) {
146
+ /**
147
+ * The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
148
+ * name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
149
+ * merely shares words with a large change is noise there).
150
+ */
151
+ export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES, namedOnly = false) {
102
152
  if (!enabled)
103
153
  return [];
104
154
  const files = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map(m => m[1].trim());
105
155
  const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
106
- return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit);
156
+ return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit, namedOnly);
107
157
  }
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
- import { rulesForDiff, rulesSection, splitRules } from './repo-rules.js';
5
+ import { execFileSync } from 'child_process';
6
+ import { readRepoRules, rulesForDiff, rulesSection, splitRules } from './repo-rules.js';
6
7
  const AGENTS = `# Conventions
7
8
 
8
9
  @SHARED.md
@@ -53,3 +54,40 @@ describe('repository rules', () => {
53
54
  expect(rulesSection(rulesForDiff(repo, diff('src/a.ts', 'await fetchWithTimeout(url);'), true))).toContain('[AGENTS.md] Prefer `fetchWithTimeout`');
54
55
  });
55
56
  });
57
+ describe('the rule files a judge is given', () => {
58
+ it('follows @ imports from a rule file and reads AGENTS.md and CLAUDE.md in folders below the root, once each', () => {
59
+ fs.writeFileSync(path.join(repo, 'CLAUDE.md'), '@AGENTS.md\n@docs/conventions.md\n@../outside.md\n@/etc/hosts\n@docs/missing.md\n');
60
+ fs.mkdirSync(path.join(repo, 'docs'));
61
+ fs.writeFileSync(path.join(repo, 'docs/conventions.md'), '@../AGENTS.md\n\n- Never log `apiToken` from `src/auth/session.ts`, even partly.\n');
62
+ fs.writeFileSync(path.join(repo, 'SHARED.md'), '- Every job in `src/jobs/` takes `withLock()` before the first read.\n');
63
+ fs.mkdirSync(path.join(repo, 'services/billing'), { recursive: true });
64
+ fs.writeFileSync(path.join(repo, 'services/billing/AGENTS.md'), '- Amounts in `services/billing/` are integer cents via `toCents()`; never a float.\n');
65
+ execFileSync('git', ['-C', repo, 'init', '-q']);
66
+ execFileSync('git', ['-C', repo, 'add', '-A']);
67
+ const rules = readRepoRules(repo);
68
+ const by = (source) => rules.filter(r => r.source === source).map(r => r.text);
69
+ expect(by('docs/conventions.md')).toEqual(['Never log `apiToken` from `src/auth/session.ts`, even partly.']);
70
+ expect(by('SHARED.md')).toEqual(['Every job in `src/jobs/` takes `withLock()` before the first read.']); // imported by AGENTS.md
71
+ expect(by('services/billing/AGENTS.md')).toEqual(['Amounts in `services/billing/` are integer cents via `toCents()`; never a float.']);
72
+ expect(rules.filter(r => r.source === 'AGENTS.md')).toHaveLength(3); // imported twice, read once
73
+ expect(rules.some(r => /hosts|outside/.test(r.source))).toBe(false); // nothing outside the repository
74
+ });
75
+ it("applies a folder's own rules, and the rules it imports, only to changes in that folder; vendored folders add none", () => {
76
+ fs.mkdirSync(path.join(repo, 'services/billing'), { recursive: true });
77
+ fs.writeFileSync(path.join(repo, 'services/billing/AGENTS.md'), '@money.md\n\n- Never call `stripe.charges.create` directly from `services/billing/`; go through `ledgerClient`.\n');
78
+ fs.writeFileSync(path.join(repo, 'services/billing/money.md'), '- Every amount in `services/billing/` is integer cents via `toCents()`; never a float.\n');
79
+ fs.mkdirSync(path.join(repo, 'vendor/somelib'), { recursive: true });
80
+ fs.writeFileSync(path.join(repo, 'vendor/somelib/AGENTS.md'), '- Always run `make vendor-test` in `vendor/somelib/` before every commit.\n');
81
+ execFileSync('git', ['-C', repo, 'init', '-q']);
82
+ execFileSync('git', ['-C', repo, 'add', '-A']);
83
+ const all = readRepoRules(repo);
84
+ expect(all.filter(r => r.scope === 'services/billing/').map(r => r.source)).toEqual(['services/billing/AGENTS.md', 'services/billing/money.md']);
85
+ expect(all.some(r => r.source.startsWith('vendor/'))).toBe(false);
86
+ expect(all.filter(r => r.source === 'AGENTS.md').every(r => r.scope === undefined)).toBe(true);
87
+ // A change only in web/ that even names the billing words: the billing rules are not served, so never checked or broken.
88
+ const web = rulesForDiff(repo, diff('web/src/Price.tsx', 'const total = stripe.charges.create(toCents(amount));'), true, 15);
89
+ expect(web.some(r => r.scope)).toBe(false);
90
+ const billing = rulesForDiff(repo, diff('services/billing/charge.ts', 'const total = stripe.charges.create(toCents(amount));'), true, 15);
91
+ expect(billing.filter(r => r.scope).map(r => r.source).sort()).toEqual(['services/billing/AGENTS.md', 'services/billing/money.md']);
92
+ });
93
+ });
@@ -0,0 +1,47 @@
1
+ /** Under the repository's git folder (the common one, shared by every worktree). */
2
+ export declare const THREADS_DIR = "rigour/threads";
3
+ export type TaskEventKind = 'edit-check' | 'stop-review' | 'push' | 'review' | 'brief';
4
+ export interface TaskEvent {
5
+ kind: TaskEventKind;
6
+ /** The agent session it happened in, when a hook knows it. */
7
+ session?: string;
8
+ /** The agent (claude, cursor, codex, ...) or `person`, when known. */
9
+ agent?: string;
10
+ /** The pull request, once a review of it ran. */
11
+ pr?: number;
12
+ /** What happened, in a few fields; each kind documents its own. */
13
+ [field: string]: unknown;
14
+ }
15
+ export interface ThreadEvent extends TaskEvent {
16
+ at: string;
17
+ task: string;
18
+ branch: string;
19
+ head?: string;
20
+ }
21
+ /**
22
+ * The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
23
+ * it too, else the branch; undefined on a detached head.
24
+ */
25
+ export declare function taskOf(cwd: string): {
26
+ key: string;
27
+ branch: string;
28
+ head?: string;
29
+ } | undefined;
30
+ /** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
31
+ export declare function appendTaskEvent(cwd: string, event: TaskEvent): ThreadEvent | undefined;
32
+ /**
33
+ * The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
34
+ * event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
35
+ * checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
36
+ */
37
+ export declare function readThread(cwd: string, key?: string): {
38
+ task: string;
39
+ events: ThreadEvent[];
40
+ } | undefined;
41
+ /** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
42
+ export declare function threadText(thread: {
43
+ task: string;
44
+ events: ThreadEvent[];
45
+ }): string[];
46
+ /** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
47
+ export declare function threadsDir(cwd: string): string | undefined;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The engineering task: one piece of work, whatever agents and people touch it, and its thread, everything that
3
+ * happened to it in order. Events are kept per branch, one file each (`<git dir>/rigour/threads/<branch>-<hash>.jsonl`,
4
+ * the hash of the exact branch name, so no two branches share a file). The task is the ticket the branch names when
5
+ * the branch's own commit subjects, or the title of its pull request as a review recorded it, write it as a ticket
6
+ * (`PROJ-123`): a version token in a branch name (`pin-node-22`, `utf-8`) is not a ticket. Otherwise the task is the
7
+ * branch. The task is worked out once per commit and kept: the edit hook runs on every edit. Reading a ticket gathers every branch that worked on it;
8
+ * a pull request joins when a review of it runs. Writers only append, and never fail the hook or command they run in.
9
+ * The files live in the repository's common git folder, shared by its worktrees; nothing enters the working tree.
10
+ *
11
+ * What a thread holds is what Rigour saw: hooked agent sessions (the edit check, the stop review), the push gate, and
12
+ * reviews. Work done where no hook ran (an agent without hooks, a person in an editor) shows only through the commits
13
+ * and reviews that follow it.
14
+ */
15
+ import { spawnSync } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ /** Under the repository's git folder (the common one, shared by every worktree). */
20
+ export const THREADS_DIR = 'rigour/threads';
21
+ /** A ticket key in a branch name: `feat/proj-123-thing` → `PROJ-123`. */
22
+ const TICKET = /(?:^|[^A-Za-z0-9])([A-Za-z][A-Za-z0-9]{1,9}-\d{1,7})(?=$|[^0-9])/;
23
+ /**
24
+ * The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
25
+ * it too, else the branch; undefined on a detached head.
26
+ */
27
+ export function taskOf(cwd) {
28
+ const task = taskIn(cwd);
29
+ return task ? { key: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}) } : undefined;
30
+ }
31
+ /** The task and the folder its thread is in, with one lookup of each: the edit hook pays for this on every edit. */
32
+ function taskIn(cwd) {
33
+ const branch = git(cwd, ['symbolic-ref', '--short', '-q', 'HEAD']); // a branch with no commit yet has a task too
34
+ if (!branch)
35
+ return undefined;
36
+ const head = git(cwd, ['rev-parse', '-q', '--verify', 'HEAD']);
37
+ const dir = threadsDir(cwd);
38
+ const cache = dir ? path.join(dir, '..', 'task-cache.json') : undefined;
39
+ const cached = cache ? readJson(cache)[branch] : undefined;
40
+ if (cached && cached.head === (head ?? '') && typeof cached.key === 'string')
41
+ return { key: cached.key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
42
+ const ticket = TICKET.exec(branch)?.[1]?.toUpperCase();
43
+ // Written as a ticket (upper case, as trackers print it): `node-22` in a subject is a version, not a ticket.
44
+ const written = ticket ? new RegExp(`(^|[^A-Za-z0-9])${ticket.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![0-9])`) : undefined;
45
+ const titles = dir ? readEvents(path.join(dir, branchFile(branch))).map(e => e.pr_title).filter((t) => typeof t === 'string') : [];
46
+ const confirmed = !!written && [...titles, ...branchSubjects(cwd)].some(text => written.test(text));
47
+ const key = confirmed ? ticket : `branch:${branch}`;
48
+ if (cache)
49
+ writeCache(cache, branch, { head: head ?? '', key });
50
+ return { key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
51
+ }
52
+ function readJson(file) {
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ return parsed && typeof parsed === 'object' ? parsed : {};
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ /** The task worked out for a branch at a commit; `undefined` forgets it (a new pull request title may confirm a ticket). */
62
+ function writeCache(file, branch, entry) {
63
+ try {
64
+ const all = readJson(file);
65
+ if (entry)
66
+ all[branch] = entry;
67
+ else
68
+ delete all[branch];
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ fs.writeFileSync(file, JSON.stringify(all));
71
+ }
72
+ catch {
73
+ // a cache that cannot be written is worked out again next time
74
+ }
75
+ }
76
+ /** The subjects of the branch's own commits: since it left the main branch, or its last 50 when no main branch is found. */
77
+ function branchSubjects(cwd) {
78
+ const main = ['origin/main', 'main', 'origin/master', 'master'].find(ref => git(cwd, ['rev-parse', '--verify', '-q', ref]) !== undefined);
79
+ const base = main ? git(cwd, ['merge-base', 'HEAD', main]) : undefined;
80
+ const range = base && base !== git(cwd, ['rev-parse', 'HEAD']) ? [`${base}..HEAD`] : ['-50', 'HEAD'];
81
+ return (git(cwd, ['log', '--format=%s', ...range]) ?? '').split('\n').filter(Boolean);
82
+ }
83
+ /** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
84
+ export function appendTaskEvent(cwd, event) {
85
+ try {
86
+ const task = taskIn(cwd);
87
+ if (!task?.dir)
88
+ return undefined;
89
+ const dir = task.dir;
90
+ const line = { at: new Date().toISOString(), task: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}), ...event };
91
+ fs.mkdirSync(dir, { recursive: true });
92
+ fs.appendFileSync(path.join(dir, branchFile(task.branch)), JSON.stringify(line) + '\n');
93
+ // A pull request title can confirm the branch's ticket: work the task out again next time.
94
+ if (typeof event.pr_title === 'string' && task.key.startsWith('branch:'))
95
+ writeCache(path.join(dir, '..', 'task-cache.json'), task.branch, undefined);
96
+ return line;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /**
103
+ * The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
104
+ * event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
105
+ * checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
106
+ */
107
+ export function readThread(cwd, key) {
108
+ const dir = threadsDir(cwd);
109
+ if (!dir)
110
+ return undefined;
111
+ const wanted = key?.trim();
112
+ if (!wanted) {
113
+ const own = taskOf(cwd);
114
+ if (!own)
115
+ return undefined;
116
+ return own.key.startsWith('branch:') ? { task: own.key, events: readEvents(path.join(dir, branchFile(own.branch))).filter(e => e.branch === own.branch) } : byTask(dir, own.key) ?? { task: own.key, events: [] };
117
+ }
118
+ const pr = /^#?(\d+)$/.exec(wanted)?.[1];
119
+ if (pr) {
120
+ const found = gather(dir, `#${pr}`, events => events.some(e => e.pr === Number(pr)));
121
+ return found ? { task: found.events[found.events.length - 1].task, events: found.events } : undefined;
122
+ }
123
+ const branch = wanted.replace(/^branch:/, '');
124
+ const own = readEvents(path.join(dir, branchFile(branch))).filter(e => e.branch === branch);
125
+ if (own.length)
126
+ return { task: own[own.length - 1].task, events: own };
127
+ return byTask(dir, wanted.toUpperCase());
128
+ }
129
+ /** Every branch whose events name the task, whole, as one thread. */
130
+ function byTask(dir, task) {
131
+ return gather(dir, task, events => events.some(e => e.task === task));
132
+ }
133
+ function gather(dir, task, wanted) {
134
+ const events = listThreads(dir).map(file => readEvents(path.join(dir, file))).filter(wanted).flat().sort(byTime);
135
+ return events.length ? { task, events } : undefined;
136
+ }
137
+ /** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
138
+ export function threadText(thread) {
139
+ const { task, events } = thread;
140
+ if (events.length === 0)
141
+ return [`${task}: nothing recorded yet`];
142
+ const sessions = new Set(events.map(e => e.session).filter(Boolean));
143
+ const agents = [...new Set(events.map(e => e.agent).filter((a) => typeof a === 'string'))];
144
+ const prs = [...new Set(events.map(e => e.pr).filter((p) => typeof p === 'number'))];
145
+ const branches = [...new Set(events.map(e => e.branch))];
146
+ const checks = events.filter(e => e.kind === 'edit-check');
147
+ const caught = checks.reduce((n, e) => n + num(e.findings), 0);
148
+ const lines = [
149
+ `${task} · ${branches.join(', ')}${prs.length ? ` · PR ${prs.map(p => `#${p}`).join(', ')}` : ''}`,
150
+ `${events[0].at} → ${events[events.length - 1].at} · ${sessions.size} agent session(s)${agents.length ? ` (${agents.join(', ')})` : ''}`,
151
+ `edit checks: ${checks.length}, findings caught while writing: ${caught}${caught ? `, files clean again after a finding: ${cleanedAfter(checks)}` : ''}`,
152
+ ];
153
+ const stops = events.filter(e => e.kind === 'stop-review');
154
+ if (stops.length)
155
+ lines.push(`stop reviews: ${stops.length}, blocked: ${stops.filter(e => e.blocked).length}`);
156
+ const pushes = events.filter(e => e.kind === 'push');
157
+ if (pushes.length)
158
+ lines.push(`pushes: ${pushes.length}, blocked: ${pushes.filter(e => e.passed === false).length}`);
159
+ const reviews = events.filter(e => e.kind === 'review');
160
+ if (reviews.length)
161
+ lines.push(`reviews: ${reviews.length}, last: ${String(reviews[reviews.length - 1].outcome)} with ${num(reviews[reviews.length - 1].blocking)} blocking${typeof reviews[reviews.length - 1].integrity === 'string' ? ` (record ${String(reviews[reviews.length - 1].integrity).slice(0, 16)})` : ''}`);
162
+ lines.push('');
163
+ for (const e of events)
164
+ lines.push(` ${e.at.slice(0, 19).replace('T', ' ')} ${e.kind.padEnd(11)} ${describe(e)}`);
165
+ return lines;
166
+ }
167
+ /** Files that had a finding at one edit check and none at a later one: caught while writing, fixed before push. */
168
+ function cleanedAfter(checks) {
169
+ const flagged = new Set();
170
+ const cleaned = new Set();
171
+ for (const e of checks) {
172
+ const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === 'string') : [];
173
+ if (num(e.findings) > 0)
174
+ files.forEach(f => flagged.add(f));
175
+ else
176
+ files.filter(f => flagged.has(f)).forEach(f => cleaned.add(f));
177
+ }
178
+ return cleaned.size;
179
+ }
180
+ function describe(e) {
181
+ const who = [e.agent, e.session ? `session ${String(e.session).slice(0, 8)}` : undefined].filter(Boolean).join(', ');
182
+ const files = Array.isArray(e.files) ? `${e.files.length} file(s)` : '';
183
+ const what = e.kind === 'edit-check' ? `${files}, ${num(e.findings)} finding(s)`
184
+ : e.kind === 'stop-review' ? (e.blocked ? `blocked: ${num(e.blocking)} to fix` : 'passed')
185
+ : e.kind === 'push' ? (e.passed === false ? `blocked: ${num(e.failed)} check(s) failed` : 'passed')
186
+ : e.kind === 'review' ? `${String(e.outcome)}, ${num(e.blocking)} blocking, ${num(e.should_fix)} should-fix${e.pr ? ` on #${e.pr}` : ''}`
187
+ : e.kind === 'brief' ? `${num(e.items)} item(s) briefed` : '';
188
+ return [what, who ? `(${who})` : '', e.head ? `@${String(e.head).slice(0, 9)}` : ''].filter(Boolean).join(' ');
189
+ }
190
+ function readEvents(file) {
191
+ let text;
192
+ try {
193
+ text = fs.readFileSync(file, 'utf8');
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ return text.split('\n').filter(Boolean).flatMap(line => {
199
+ try {
200
+ const e = JSON.parse(line);
201
+ return e && typeof e.at === 'string' && typeof e.task === 'string' && typeof e.kind === 'string' ? [e] : [];
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }).sort(byTime);
207
+ }
208
+ /** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
209
+ export function threadsDir(cwd) {
210
+ const common = git(cwd, ['rev-parse', '--git-common-dir']);
211
+ return common ? path.join(path.resolve(cwd, common), THREADS_DIR) : undefined;
212
+ }
213
+ function listThreads(dir) {
214
+ try {
215
+ return fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
216
+ }
217
+ catch {
218
+ return [];
219
+ }
220
+ }
221
+ /** A branch's thread file: a readable form of its name, and a hash of the exact name, so `a/b` and `a_b` never share one. */
222
+ function branchFile(branch) {
223
+ return `${branch.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)}-${createHash('sha1').update(branch).digest('hex').slice(0, 8)}.jsonl`;
224
+ }
225
+ function byTime(a, b) {
226
+ return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
227
+ }
228
+ function num(v) {
229
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
230
+ }
231
+ function git(cwd, args) {
232
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
233
+ return result.status === 0 ? result.stdout.trim() : undefined;
234
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { appendTaskEvent, readThread, taskOf, threadsDir, threadText } from './thread.js';
7
+ let repo;
8
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
9
+ beforeEach(() => {
10
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'thread-'));
11
+ git('init', '-q', '-b', 'main');
12
+ git('config', 'user.email', 't@example.com');
13
+ git('config', 'user.name', 't');
14
+ git('config', 'commit.gpgsign', 'false');
15
+ fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
16
+ git('add', '-A');
17
+ git('commit', '-qm', 'init');
18
+ });
19
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
20
+ describe('the engineering task', () => {
21
+ const commit = (subject) => {
22
+ fs.appendFileSync(path.join(repo, 'a.ts'), `// ${subject}\n`);
23
+ git('commit', '-qam', subject);
24
+ };
25
+ it('is the ticket the branch names only when its own commits name it too; a version token is never a ticket', () => {
26
+ for (const branch of ['chore/pin-node-22', 'fix/utf-8-decoding', 'feat/http-2-push', 'release-1.4', 'bump/python-3-12']) {
27
+ git('checkout', '-q', 'main');
28
+ git('checkout', '-qb', branch);
29
+ commit(`${branch.split('/').pop()}: bump it`); // the version token in the subject, as people write it
30
+ expect(taskOf(repo)?.key).toBe(`branch:${branch}`);
31
+ }
32
+ git('checkout', '-q', 'main');
33
+ git('checkout', '-qb', 'feat/proj-123-resume-emails');
34
+ expect(taskOf(repo)?.key).toBe('branch:feat/proj-123-resume-emails'); // named by the branch, not yet by a commit
35
+ commit('proj-123 resume emails');
36
+ expect(taskOf(repo)?.key).toBe('branch:feat/proj-123-resume-emails'); // lower case is how versions are written, not tickets
37
+ commit('PROJ-123: resume emails');
38
+ expect(taskOf(repo)).toMatchObject({ key: 'PROJ-123', branch: 'feat/proj-123-resume-emails' });
39
+ git('checkout', '-qb', 'fix-the-thing');
40
+ expect(taskOf(repo)?.key).toBe('branch:fix-the-thing'); // a branch with no ticket in its name
41
+ git('checkout', '-q', '--detach');
42
+ expect(taskOf(repo)).toBeUndefined();
43
+ expect(appendTaskEvent(repo, { kind: 'push', passed: true })).toBeUndefined();
44
+ });
45
+ it("takes the ticket from the pull request's title once a review recorded it, when the commits use another scope", () => {
46
+ git('checkout', '-qb', 'feat/PROJ-7-retry');
47
+ commit('fix(retry): back off on 429');
48
+ appendTaskEvent(repo, { kind: 'edit-check', files: ['src/retry.ts'], findings: 0 });
49
+ expect(taskOf(repo)?.key).toBe('branch:feat/PROJ-7-retry');
50
+ appendTaskEvent(repo, { kind: 'review', pr: 7, pr_title: 'feat(PROJ-7): retry with backoff', outcome: 'passed', blocking: 0 });
51
+ expect(taskOf(repo)?.key).toBe('PROJ-7'); // the same commit: the title confirmed it, and the kept task was dropped
52
+ appendTaskEvent(repo, { kind: 'push', passed: true });
53
+ expect(readThread(repo, 'PROJ-7')?.events.map(e => [e.kind, e.task])).toEqual([['edit-check', 'branch:feat/PROJ-7-retry'], ['review', 'branch:feat/PROJ-7-retry'], ['push', 'PROJ-7']]);
54
+ });
55
+ it('works the task out once per commit and keeps it, so the edit hook does not read the branch history on every edit', () => {
56
+ git('checkout', '-qb', 'feat/PROJ-9-cache');
57
+ commit('PROJ-9: start');
58
+ expect(taskOf(repo)?.key).toBe('PROJ-9');
59
+ const cache = path.join(repo, '.git', 'rigour', 'task-cache.json');
60
+ expect(JSON.parse(fs.readFileSync(cache, 'utf8'))['feat/PROJ-9-cache']).toEqual({ head: git('rev-parse', 'HEAD'), key: 'PROJ-9' });
61
+ fs.writeFileSync(cache, JSON.stringify({ 'feat/PROJ-9-cache': { head: git('rev-parse', 'HEAD'), key: 'KEPT-1' } }));
62
+ expect(taskOf(repo)?.key).toBe('KEPT-1'); // read from what was kept, not worked out again
63
+ commit('PROJ-9: more');
64
+ expect(taskOf(repo)?.key).toBe('PROJ-9'); // a new commit: worked out again
65
+ });
66
+ it('keeps one file per exact branch: a/b and a_b never share a thread', () => {
67
+ git('checkout', '-qb', 'feat/a/b');
68
+ appendTaskEvent(repo, { kind: 'push', passed: true });
69
+ git('checkout', '-q', 'main');
70
+ git('checkout', '-qb', 'feat/a_b');
71
+ appendTaskEvent(repo, { kind: 'push', passed: false, failed: 2 });
72
+ expect(readThread(repo, 'feat/a/b')?.events.map(e => [e.branch, e.passed])).toEqual([['feat/a/b', true]]);
73
+ expect(readThread(repo, 'feat/a_b')?.events.map(e => [e.branch, e.passed])).toEqual([['feat/a_b', false]]);
74
+ expect(fs.readdirSync(threadsDir(repo))).toHaveLength(2);
75
+ });
76
+ it('gathers a ticket across the branches and worktrees that worked on it, from their first event', () => {
77
+ git('checkout', '-qb', 'feat/PROJ-5-api');
78
+ appendTaskEvent(repo, { kind: 'edit-check', files: ['src/api.ts'], findings: 1 }); // before the first commit names the ticket
79
+ commit('PROJ-5: the api');
80
+ appendTaskEvent(repo, { kind: 'push', passed: true });
81
+ const other = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'thread-wt-')), 'wt');
82
+ git('worktree', 'add', '-q', '-b', 'feat/PROJ-5-ui', other, 'main');
83
+ fs.appendFileSync(path.join(other, 'a.ts'), '// ui\n');
84
+ execFileSync('git', ['-C', other, 'commit', '-qam', 'PROJ-5 the ui'], { encoding: 'utf8' });
85
+ appendTaskEvent(other, { kind: 'push', passed: true }); // written from the other worktree, into the same folder
86
+ const thread = readThread(repo, 'proj-5');
87
+ expect(thread?.task).toBe('PROJ-5');
88
+ expect(thread?.events.map(e => [e.kind, e.branch])).toEqual([['edit-check', 'feat/PROJ-5-api'], ['push', 'feat/PROJ-5-api'], ['push', 'feat/PROJ-5-ui']]);
89
+ expect(readThread(repo, 'feat/PROJ-5-ui')?.events).toHaveLength(1);
90
+ git('worktree', 'remove', '--force', other);
91
+ });
92
+ it('keeps an append-only thread, read by ticket, branch, pull request or the checkout, oldest first, skipping a broken line', () => {
93
+ git('checkout', '-qb', 'feat/PROJ-7-retry');
94
+ commit('PROJ-7 retry');
95
+ appendTaskEvent(repo, { kind: 'edit-check', session: 'sess-aaaa1111', agent: 'claude', files: ['src/job.ts'], findings: 2 });
96
+ appendTaskEvent(repo, { kind: 'edit-check', session: 'sess-aaaa1111', agent: 'claude', files: ['src/job.ts'], findings: 0 });
97
+ appendTaskEvent(repo, { kind: 'stop-review', session: 'sess-aaaa1111', agent: 'claude', blocked: true, blocking: 1 });
98
+ appendTaskEvent(repo, { kind: 'push', passed: false, failed: 1 });
99
+ appendTaskEvent(repo, { kind: 'push', passed: true, failed: 0 });
100
+ appendTaskEvent(repo, { kind: 'review', pr: 42, outcome: 'passed', blocking: 0, should_fix: 1, integrity: 'abcdef0123456789abcdef' });
101
+ const [name] = fs.readdirSync(threadsDir(repo));
102
+ const file = path.join(threadsDir(repo), name);
103
+ expect(name).toMatch(/^feat_PROJ-7-retry-[0-9a-f]{8}\.jsonl$/);
104
+ expect(fs.realpathSync(file)).toBe(fs.realpathSync(path.join(repo, '.git', 'rigour', 'threads', name)));
105
+ expect(git('status', '--porcelain')).toBe(''); // nothing in the working tree
106
+ fs.appendFileSync(file, 'not json\n{"kind":"push"}\n');
107
+ const byTicket = readThread(repo, 'proj-7');
108
+ expect(byTicket?.task).toBe('PROJ-7');
109
+ expect(byTicket?.events.map(e => e.kind)).toEqual(['edit-check', 'edit-check', 'stop-review', 'push', 'push', 'review']);
110
+ expect(readThread(repo, 'feat/PROJ-7-retry')?.events).toHaveLength(6);
111
+ expect(readThread(repo, '#42')?.task).toBe('PROJ-7');
112
+ expect(readThread(repo, '42')?.task).toBe('PROJ-7');
113
+ expect(readThread(repo)?.events).toHaveLength(6);
114
+ expect(readThread(repo, '#43')).toBeUndefined();
115
+ expect(readThread(repo, 'OTHER-1')).toBeUndefined();
116
+ expect(byTicket.events[0]).toMatchObject({ task: 'PROJ-7', branch: 'feat/PROJ-7-retry', head: git('rev-parse', 'HEAD') });
117
+ const text = threadText(byTicket).join('\n');
118
+ expect(text).toContain('PROJ-7 · feat/PROJ-7-retry · PR #42');
119
+ expect(text).toContain('1 agent session(s) (claude)');
120
+ expect(text).toContain('findings caught while writing: 2, files clean again after a finding: 1');
121
+ expect(text).toContain('stop reviews: 1, blocked: 1');
122
+ expect(text).toContain('pushes: 2, blocked: 1');
123
+ expect(text).toContain('reviews: 1, last: passed with 0 blocking (record abcdef0123456789)');
124
+ expect(text).toContain('blocked: 1 check(s) failed');
125
+ });
126
+ it('never fails the hook it runs in: an unwritable thread folder is skipped', () => {
127
+ git('checkout', '-qb', 'feat/PROJ-8');
128
+ fs.writeFileSync(path.join(repo, '.git', 'rigour'), 'a file where the folder should be');
129
+ expect(appendTaskEvent(repo, { kind: 'push', passed: true })).toBeUndefined();
130
+ expect(readThread(repo)?.events ?? []).toEqual([]);
131
+ expect(threadText({ task: 'PROJ-8', events: [] })).toEqual(['PROJ-8: nothing recorded yet']);
132
+ });
133
+ });
@@ -275,6 +275,10 @@ export const UNIVERSAL_CONFIG = {
275
275
  output: {
276
276
  report_path: 'rigour-report.json',
277
277
  },
278
+ brief: {
279
+ enabled: true,
280
+ max_items: 10,
281
+ },
278
282
  review: {
279
283
  include_heuristics: false,
280
284
  show_preexisting: false,
@@ -2399,6 +2399,19 @@ export declare const ConfigSchema: z.ZodObject<{
2399
2399
  }, {
2400
2400
  report_path?: string | undefined;
2401
2401
  }>>>;
2402
+ /** The briefing an agent gets before it writes (rigour brief, the prompt hook, rigour_brief). */
2403
+ brief: z.ZodDefault<z.ZodOptional<z.ZodObject<{
2404
+ /** The kill switch: false stops every briefing, including an installed hook. The hook itself is installed only with `rigour hooks init --brief`. */
2405
+ enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
2406
+ /** At most this many items, never more than 10. */
2407
+ max_items: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
2408
+ }, "strip", z.ZodTypeAny, {
2409
+ enabled: boolean;
2410
+ max_items: number;
2411
+ }, {
2412
+ enabled?: boolean | undefined;
2413
+ max_items?: number | undefined;
2414
+ }>>>;
2402
2415
  /** rigour review / rigour_review / the PR bot / the stop hook. */
2403
2416
  review: z.ZodDefault<z.ZodOptional<z.ZodObject<{
2404
2417
  /** Let heuristic gates decide the verdict too; by default only findings that prove a defect do (quiet.ts). */
@@ -2873,6 +2886,10 @@ export declare const ConfigSchema: z.ZodObject<{
2873
2886
  output: {
2874
2887
  report_path: string;
2875
2888
  };
2889
+ brief: {
2890
+ enabled: boolean;
2891
+ max_items: number;
2892
+ };
2876
2893
  review: {
2877
2894
  include_heuristics: boolean;
2878
2895
  show_preexisting: boolean;
@@ -3165,6 +3182,10 @@ export declare const ConfigSchema: z.ZodObject<{
3165
3182
  output?: {
3166
3183
  report_path?: string | undefined;
3167
3184
  } | undefined;
3185
+ brief?: {
3186
+ enabled?: boolean | undefined;
3187
+ max_items?: number | undefined;
3188
+ } | undefined;
3168
3189
  review?: {
3169
3190
  include_heuristics?: boolean | undefined;
3170
3191
  show_preexisting?: boolean | undefined;
@@ -365,6 +365,13 @@ export const ConfigSchema = z.object({
365
365
  output: z.object({
366
366
  report_path: z.string().default('rigour-report.json'),
367
367
  }).optional().default({}),
368
+ /** The briefing an agent gets before it writes (rigour brief, the prompt hook, rigour_brief). */
369
+ brief: z.object({
370
+ /** The kill switch: false stops every briefing, including an installed hook. The hook itself is installed only with `rigour hooks init --brief`. */
371
+ enabled: z.boolean().optional().default(true),
372
+ /** At most this many items, never more than 10. */
373
+ max_items: z.number().int().min(1).max(10).optional().default(10),
374
+ }).optional().default({}),
368
375
  /** rigour review / rigour_review / the PR bot / the stop hook. */
369
376
  review: z.object({
370
377
  /** Let heuristic gates decide the verdict too; by default only findings that prove a defect do (quiet.ts). */