@rigour-labs/core 6.7.10 → 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.
- package/dist/brief/briefing.d.ts +59 -0
- package/dist/brief/briefing.js +126 -0
- package/dist/brief/briefing.test.d.ts +1 -0
- package/dist/brief/briefing.test.js +107 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/review/backtest-init.d.ts +8 -5
- package/dist/review/backtest-init.js +27 -9
- package/dist/review/backtest-init.test.js +39 -1
- package/dist/review/backtest-last.js +3 -1
- package/dist/review/reviewer/adapters.d.ts +4 -0
- package/dist/review/reviewer/adapters.js +23 -0
- package/dist/review/reviewer/adapters.test.js +40 -0
- package/dist/review/reviewer/inputs.d.ts +4 -1
- package/dist/review/reviewer/inputs.js +36 -2
- package/dist/review/reviewer/inputs.test.js +26 -0
- package/dist/review/reviewer/record.d.ts +5 -0
- package/dist/review/reviewer/record.js +3 -3
- package/dist/review/reviewer/record.test.js +8 -0
- package/dist/review/reviewer/verdict.d.ts +14 -0
- package/dist/review/reviewer/verdict.js +46 -6
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +24 -5
- package/dist/review/reviewer.test.js +93 -6
- package/dist/review-learning/repo-rules.d.ts +14 -2
- package/dist/review-learning/repo-rules.js +59 -9
- package/dist/review-learning/repo-rules.test.js +39 -1
- package/dist/task/thread.d.ts +47 -0
- package/dist/task/thread.js +234 -0
- package/dist/task/thread.test.d.ts +1 -0
- package/dist/task/thread.test.js +133 -0
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/index.js +7 -0
- 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
|
|
25
|
-
...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
|
|
26
|
-
...
|
|
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
|
-
|
|
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
|
-
/**
|
|
101
|
-
|
|
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 {
|
|
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
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/index.js
CHANGED
|
@@ -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). */
|