@codedology/code-forge 0.15.2 → 0.15.3
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/CHANGELOG.md +56 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/worker/queue.mjs
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
32
32
|
import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
-
import {
|
|
34
|
+
import { reviewExcludeRule } from '../config/validate.mjs';
|
|
35
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
36
|
+
import { runRecordPath, StateError } from '../state/paths.mjs';
|
|
35
37
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
36
38
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
37
39
|
import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } from './ticket.mjs';
|
|
@@ -76,7 +78,7 @@ function readJSON(file) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/**
|
|
79
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, acceptance_revision?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
|
|
81
|
+
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, queued_at?: string | null, started_at?: string | null, finished_at?: string | null, duration_ms?: number | null, tree?: string, acceptance_revision?: string, reopened_at?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
|
|
80
82
|
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
81
83
|
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
82
84
|
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
@@ -86,16 +88,25 @@ function readJSON(file) {
|
|
|
86
88
|
* The review ticket for `file`'s CURRENT content: the one rule `enqueue` and `clearDone` (B72)
|
|
87
89
|
* share — the repo-relative path, the realpath'd tree (or null), the content hash taken under the
|
|
88
90
|
* tree when given (else under `repoRoot`) and the ticket id over all of them.
|
|
89
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
|
|
91
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, after?: string}} opts
|
|
90
92
|
* @returns {{rel: string, root: string | null, content_hash: string, ticket: string}}
|
|
91
93
|
* @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
|
|
92
94
|
*/
|
|
93
|
-
export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
95
|
+
export function resolveTicket({ repoRoot, run, block, file, tree = null, after }) {
|
|
94
96
|
const rel = assertRowPath(file);
|
|
95
97
|
const root = tree === null ? null : realTree(tree);
|
|
96
98
|
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
return resolveHashTicket({ repoRoot, run, block, file: rel, tree: root, after, content_hash });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve already measured content through the same acceptance/reopen identity.
|
|
103
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, after?: string}} opts
|
|
104
|
+
*/
|
|
105
|
+
export function resolveHashTicket({ repoRoot, run, block, file, content_hash, tree = null, after }) {
|
|
106
|
+
const rel = assertRowPath(file);
|
|
107
|
+
const root = tree === null ? null : realTree(tree);
|
|
108
|
+
const { ticket } = reviewIdentity({ repoRoot, run, block, file: rel, content_hash, tree: root, after });
|
|
109
|
+
return { rel, root, content_hash, ticket };
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
/** The mirror lets sandboxed callers resolve the same ticket without the private record.
|
|
@@ -107,10 +118,39 @@ function acceptanceRevision(repoRoot, run, block) {
|
|
|
107
118
|
return typeof revision === 'string' ? revision : undefined;
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
/** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string}} parts */
|
|
121
|
+
/** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string, reopened_at?: string}} parts */
|
|
111
122
|
function reviewTicketId(parts) {
|
|
112
123
|
const id = ticketId(parts);
|
|
113
|
-
|
|
124
|
+
const revisionId = parts.acceptance_revision === undefined ? id : createHash('sha256').update(`${id}\0${parts.acceptance_revision}`).digest('hex').slice(0, 24);
|
|
125
|
+
return parts.reopened_at === undefined ? revisionId : createHash('sha256').update(`${revisionId}\0reopen\0${parts.reopened_at}`).digest('hex').slice(0, 24);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reopens get their own identity; ordinary enqueues keep using that attempt afterwards.
|
|
129
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree: string | null, after?: string}} opts
|
|
130
|
+
*/
|
|
131
|
+
function reviewIdentity({ repoRoot, run, block, file, content_hash, tree, after }) {
|
|
132
|
+
const acceptance_revision = acceptanceRevision(repoRoot, run, block);
|
|
133
|
+
let names;
|
|
134
|
+
try { names = readdirSync(queueDir(repoRoot)); } catch (err) {
|
|
135
|
+
if (err.code !== 'ENOENT') throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
|
|
136
|
+
names = [];
|
|
137
|
+
}
|
|
138
|
+
const matching = [];
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
if (!/^[0-9a-f]{24}\.json$/.test(name)) continue;
|
|
141
|
+
const t = readJSON(path.join(queueDir(repoRoot), name));
|
|
142
|
+
// Scope before validation: a stored tree from another ticket must never select this tree.
|
|
143
|
+
if (t && !t.kind && t.run === run && t.block === block && t.file === file && t.content_hash === content_hash && (t.tree ?? null) === tree && t.acceptance_revision === acceptance_revision) {
|
|
144
|
+
matching.push(readTicket(repoRoot, name.slice(0, 24)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
matching.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
148
|
+
const latest = matching.filter((t) => t.reopened_at !== undefined).at(-1);
|
|
149
|
+
const afterTime = Date.parse(after ?? '');
|
|
150
|
+
const fresh = matching.filter((t) => Date.parse(t.enqueued_at) > afterTime).at(-1);
|
|
151
|
+
const reopened_at = fresh ? fresh.reopened_at : Number.isFinite(afterTime) ? after : latest?.reopened_at;
|
|
152
|
+
const ticket = reviewTicketId({ run, block, file, content_hash, tree, acceptance_revision, reopened_at });
|
|
153
|
+
return { ticket, acceptance_revision, reopened_at, matching };
|
|
114
154
|
}
|
|
115
155
|
|
|
116
156
|
/** Reset only the commit-watch cursor; review evidence and results remain intact.
|
|
@@ -126,30 +166,44 @@ export function resetAcceptanceWatch(repoRoot, block) {
|
|
|
126
166
|
* content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
|
|
127
167
|
* under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
|
|
128
168
|
* the same bytes differ, and the answer names the tree given here, never a stored value.
|
|
129
|
-
*
|
|
130
|
-
*
|
|
169
|
+
* `after` creates a new identity for a reopen. Repeated calls for that row reuse the ticket,
|
|
170
|
+
* including its eventual completion; ordinary enqueues also reuse the latest reopened attempt.
|
|
171
|
+
* A manual review queued after the row already counts as fresh and keeps its identity.
|
|
172
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date, after?: string}} opts
|
|
173
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, content_hash?: never, tree?: never}}
|
|
131
174
|
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
132
175
|
*/
|
|
133
|
-
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
176
|
+
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date(), after }) {
|
|
177
|
+
const rule = reviewExclusionFor(repoRoot, run, assertRowPath(file));
|
|
178
|
+
if (rule !== null) return { status: 'excluded', file, rule };
|
|
134
179
|
const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
135
|
-
return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
|
|
180
|
+
return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now, after });
|
|
136
181
|
}
|
|
137
182
|
|
|
138
183
|
/** Queue proven git-object content through the same identity as a manual review.
|
|
139
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
|
|
140
|
-
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
184
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date, after?: string}} opts
|
|
185
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, content_hash?: never, tree?: never}}
|
|
141
186
|
*/
|
|
142
|
-
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
|
|
187
|
+
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date(), after }) {
|
|
143
188
|
const rel = assertRowPath(file);
|
|
189
|
+
const rule = reviewExclusionFor(repoRoot, run, rel);
|
|
190
|
+
if (rule !== null) return { status: 'excluded', file: rel, rule };
|
|
144
191
|
const root = tree === null ? null : realTree(tree);
|
|
145
|
-
const acceptance_revision =
|
|
146
|
-
const ticket = reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision });
|
|
192
|
+
const { ticket, acceptance_revision, reopened_at, matching } = reviewIdentity({ repoRoot, run, block, file: rel, content_hash, tree: root, after });
|
|
147
193
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
148
|
-
const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }) };
|
|
194
|
+
const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }), ...(reopened_at === undefined ? {} : { reopened_at }) };
|
|
195
|
+
const reopenedAt = Date.parse(reopened_at ?? '');
|
|
196
|
+
if (Number.isFinite(reopenedAt)) {
|
|
197
|
+
// clearDone may have made the old ticket pending. Retire it before publishing its successor.
|
|
198
|
+
for (const previous of matching) {
|
|
199
|
+
if (previous.ticket !== ticket && Date.parse(previous.enqueued_at) <= reopenedAt) supersedeTicket(repoRoot, previous.ticket);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
149
202
|
if (!existsSync(target)) {
|
|
203
|
+
const queued_at = new Date(Number.isFinite(reopenedAt) ? Math.max(now.getTime(), reopenedAt + 1) : now.getTime()).toISOString();
|
|
150
204
|
/** @type {Ticket} */
|
|
151
|
-
const body = { ticket, run, block, file: rel, content_hash, enqueued_at:
|
|
152
|
-
// Publish a complete
|
|
205
|
+
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: queued_at, queued_at, started_at: null, finished_at: null, duration_ms: null, ...named };
|
|
206
|
+
// Publish a complete ticket without replacing a concurrent coder's ticket.
|
|
153
207
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
154
208
|
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
155
209
|
try {
|
|
@@ -169,6 +223,23 @@ export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = n
|
|
|
169
223
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
170
224
|
}
|
|
171
225
|
|
|
226
|
+
/** Use the current run snapshot, including reloads, just as the watch and close do.
|
|
227
|
+
* @param {string} repoRoot @param {string} run @param {string} file @param {Record<string, any> | null} [fallback]
|
|
228
|
+
*/
|
|
229
|
+
export function reviewExclusionFor(repoRoot, run, file, fallback = null) {
|
|
230
|
+
const record = readJSON(runRecordPath(run)) ?? readJSON(path.join(repoRoot, '.code-forge', 'runs', `${run}.json`));
|
|
231
|
+
let cfg;
|
|
232
|
+
try {
|
|
233
|
+
cfg = record ? configInForce(record) : null;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (!(err instanceof StateError) || err.code !== 'config-snapshot') throw err;
|
|
236
|
+
// Keep the ticket queued so the worker records unavailable/config-snapshot. Never use
|
|
237
|
+
// exclusions from an untrusted snapshot or the fallback to skip that result.
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return reviewExcludeRule(cfg ?? fallback, file);
|
|
241
|
+
}
|
|
242
|
+
|
|
172
243
|
/** Finish only an unstarted, pending review. A superseded marker has no review result.
|
|
173
244
|
* @param {string} repoRoot @param {string} ticket
|
|
174
245
|
* @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
|
|
@@ -222,13 +293,17 @@ export function assertAnswers(answers) {
|
|
|
222
293
|
* repo-root-relative). Both files are hashed now (under `tree` when given); idempotent like
|
|
223
294
|
* {@link enqueue}. The caller has already refused the same file and the id list.
|
|
224
295
|
* @param {{repoRoot: string, run: string, block: string, file: string, byFile: string, answers: string[], tree?: string | null, now?: Date}} opts
|
|
225
|
-
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string}}
|
|
296
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, answers?: never}}
|
|
226
297
|
* @throws {WorkerError} `bad-path`, `usage`
|
|
227
298
|
*/
|
|
228
299
|
export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tree = null, now = new Date() }) {
|
|
229
300
|
const rel = assertRowPath(file);
|
|
230
301
|
const by_file = assertRowPath(byFile);
|
|
231
302
|
const ids = assertAnswers(answers);
|
|
303
|
+
for (const candidate of [rel, by_file]) {
|
|
304
|
+
const rule = reviewExclusionFor(repoRoot, run, candidate);
|
|
305
|
+
if (rule !== null) return { status: 'excluded', file: candidate, rule };
|
|
306
|
+
}
|
|
232
307
|
const root = tree === null ? null : realTree(tree);
|
|
233
308
|
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
234
309
|
const by_hash = contentHash(root ?? repoRoot, by_file);
|
|
@@ -237,7 +312,7 @@ export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tr
|
|
|
237
312
|
const named = root === null ? {} : { tree: root };
|
|
238
313
|
if (!existsSync(target)) {
|
|
239
314
|
/** @type {AnswersTicket} */
|
|
240
|
-
const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), ...named };
|
|
315
|
+
const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), queued_at: now.toISOString(), started_at: null, finished_at: null, duration_ms: null, ...named };
|
|
241
316
|
writeAtomic(target, `${JSON.stringify(body)}\n`);
|
|
242
317
|
}
|
|
243
318
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, by_file, by_hash, answers: ids, ...named };
|
|
@@ -532,6 +607,7 @@ export function readTicket(repoRoot, id) {
|
|
|
532
607
|
return /** @type {Ticket} */ (t);
|
|
533
608
|
}
|
|
534
609
|
if (t.acceptance_revision !== undefined && typeof t.acceptance_revision !== 'string') throw new WorkerError('bad-ticket', `ticket ${id} has an invalid acceptance revision`);
|
|
610
|
+
if (t.reopened_at !== undefined && (typeof t.reopened_at !== 'string' || !Number.isFinite(Date.parse(t.reopened_at)))) throw new WorkerError('bad-ticket', `ticket ${id} has an invalid reopen timestamp`);
|
|
535
611
|
if (reviewTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
536
612
|
return /** @type {Ticket} */ (t);
|
|
537
613
|
}
|
|
@@ -550,10 +626,17 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
|
|
|
550
626
|
const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
|
|
551
627
|
writeAtomic(file, `${JSON.stringify(signed)}\n`);
|
|
552
628
|
const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
|
|
553
|
-
|
|
629
|
+
const timings = Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms'].filter((field) => Object.hasOwn(signed, field)).map((field) => [field, signed[field]]));
|
|
630
|
+
writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...measured, ...timings, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
|
|
554
631
|
return signed;
|
|
555
632
|
}
|
|
556
633
|
|
|
634
|
+
/** Worker-owned lifecycle metadata; identity and enqueue ordering stay unchanged.
|
|
635
|
+
* @param {string} repoRoot @param {Ticket} ticket @param {Record<string, any>} timings */
|
|
636
|
+
export function writeTicketTimings(repoRoot, ticket, timings) {
|
|
637
|
+
writeAtomic(path.join(queueDir(repoRoot), `${assertTicketId(ticket.ticket)}.json`), `${JSON.stringify({ ...ticket, ...timings })}\n`);
|
|
638
|
+
}
|
|
639
|
+
|
|
557
640
|
/**
|
|
558
641
|
* Read a result without verifying it (the coder's `--wait` cannot read the run key).
|
|
559
642
|
* @param {string} repoRoot @param {string} runId @param {string} ticket
|
package/src/worker/watch.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { reviewFileLoop } from '../config/validate.mjs';
|
|
5
|
+
import { reviewExcludeRule, reviewFileLoop } from '../config/validate.mjs';
|
|
6
6
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
7
7
|
import { readRun } from '../state/run.mjs';
|
|
8
8
|
import { ownsAt } from '../review/gate-check.mjs';
|
|
@@ -86,7 +86,7 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
|
|
|
86
86
|
// Stage every hash before publishing anything: an unreadable object queues nothing.
|
|
87
87
|
const hashes = [];
|
|
88
88
|
let unreadable = false;
|
|
89
|
-
for (const raw of owned) {
|
|
89
|
+
for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
|
|
90
90
|
const file = assertRowPath(raw);
|
|
91
91
|
const listed = await git(['ls-tree', '-z', head, '--', file]);
|
|
92
92
|
if (listed.result !== 'ok') { unreadable = true; break; }
|