tickmarkr 2.1.4 → 2.1.6

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.
@@ -0,0 +1,21 @@
1
+ export declare const STATS_COLUMNS: readonly ["author", "reviewer", "dispatches", "deliveries", "delivery rate", "attempts to green", "real reds", "infra reds", "within-task rescues"];
2
+ export interface ChannelStats {
3
+ author: string;
4
+ reviewers: string[];
5
+ dispatches: number;
6
+ deliveries: number;
7
+ deliveryRate: number;
8
+ /** Mean journal-recorded attempts among this channel's delivered tasks; null when it delivered none. */
9
+ attemptsToGreen: number | null;
10
+ realReds: number;
11
+ infraReds: number;
12
+ rescues: string[];
13
+ }
14
+ export interface StatsReport {
15
+ runs: number;
16
+ channels: ChannelStats[];
17
+ }
18
+ /** Reduce every readable run journal under the repository's state directory. */
19
+ export declare function collectChannelStats(cwd?: string): StatsReport;
20
+ export declare function renderStats(report: StatsReport): string;
21
+ export declare function stats(argv: string[], cwd?: string): Promise<string>;
@@ -0,0 +1,210 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { classifyFailureOutput } from "../../gates/baseline.js";
4
+ import { stateDirName } from "../../graph/graph.js";
5
+ import { Journal } from "../../run/journal.js";
6
+ // The operator-facing schema is deliberately closed. Adding a fact to the table therefore requires
7
+ // adding it here as a conscious compatibility change, rather than letting incidental journal fields
8
+ // leak into an analytics surface.
9
+ export const STATS_COLUMNS = [
10
+ "author",
11
+ "reviewer",
12
+ "dispatches",
13
+ "deliveries",
14
+ "delivery rate",
15
+ "attempts to green",
16
+ "real reds",
17
+ "infra reds",
18
+ "within-task rescues",
19
+ ];
20
+ const assignmentAuthor = (data) => {
21
+ const assignment = data.assignment;
22
+ if (!assignment || typeof assignment !== "object")
23
+ return undefined;
24
+ const { adapter, model } = assignment;
25
+ return typeof adapter === "string" && typeof model === "string"
26
+ ? `${adapter}:${model}`
27
+ : undefined;
28
+ };
29
+ // Current journals write the reviewer into review prose; the structured field is also accepted so
30
+ // journals produced through gateResultJournalData retain their stronger identity representation.
31
+ const reviewerFrom = (data) => {
32
+ if (typeof data.reviewer === "string" && data.reviewer.trim())
33
+ return data.reviewer.trim();
34
+ if (typeof data.details !== "string")
35
+ return undefined;
36
+ return /\breviewer(?:\s+|:\s*)([\w@./+-]+:[\w@./+-]+)/iu.exec(data.details)?.[1];
37
+ };
38
+ const redEvidence = (data) => {
39
+ const fingerprints = Array.isArray(data.fingerprints)
40
+ ? data.fingerprints.filter((value) => typeof value === "string")
41
+ : [];
42
+ const prose = [data.details, data.error, data.reason]
43
+ .filter((value) => typeof value === "string");
44
+ return [...fingerprints, ...prose].join("\n");
45
+ };
46
+ const historyFor = (tasks, taskId) => {
47
+ let history = tasks.get(taskId);
48
+ if (!history) {
49
+ history = { dispatches: [], deliveries: [] };
50
+ tasks.set(taskId, history);
51
+ }
52
+ return history;
53
+ };
54
+ const dispatchedAuthorFor = (history, data) => {
55
+ const attempt = typeof data.attempt === "number" && Number.isInteger(data.attempt)
56
+ ? data.attempt
57
+ : undefined;
58
+ if (attempt !== undefined) {
59
+ const matched = [...history.dispatches].reverse().find((dispatch) => dispatch.attempt === attempt);
60
+ if (matched)
61
+ return matched.author;
62
+ }
63
+ return history.dispatches.at(-1)?.author;
64
+ };
65
+ const runIdsWithJournals = (cwd) => {
66
+ const runsDir = join(cwd, stateDirName(cwd), "runs");
67
+ if (!existsSync(runsDir))
68
+ return [];
69
+ return readdirSync(runsDir, { withFileTypes: true })
70
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-")
71
+ && existsSync(join(runsDir, entry.name, "journal.jsonl")))
72
+ .map((entry) => entry.name)
73
+ .sort();
74
+ };
75
+ /** Reduce every readable run journal under the repository's state directory. */
76
+ export function collectChannelStats(cwd = process.cwd()) {
77
+ const runIds = runIdsWithJournals(cwd);
78
+ const channels = new Map();
79
+ const channelFor = (author) => {
80
+ let channel = channels.get(author);
81
+ if (!channel) {
82
+ channel = {
83
+ author, reviewers: new Set(), dispatches: 0, deliveries: 0,
84
+ attemptsToGreen: 0, realReds: 0, infraReds: 0, rescues: new Set(),
85
+ };
86
+ channels.set(author, channel);
87
+ }
88
+ return channel;
89
+ };
90
+ for (const runId of runIds) {
91
+ const events = Journal.open(cwd, runId).read();
92
+ const tasks = new Map();
93
+ for (const [eventIndex, event] of events.entries()) {
94
+ if (!event.taskId)
95
+ continue;
96
+ const history = historyFor(tasks, event.taskId);
97
+ if (event.event === "task-dispatch") {
98
+ const author = assignmentAuthor(event.data);
99
+ if (!author)
100
+ continue;
101
+ channelFor(author).dispatches += 1;
102
+ history.dispatches.push({
103
+ author,
104
+ ...(typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt)
105
+ ? { attempt: event.data.attempt }
106
+ : {}),
107
+ eventIndex,
108
+ });
109
+ continue;
110
+ }
111
+ if (event.event === "task-done") {
112
+ const author = assignmentAuthor(event.data) ?? history.dispatches.at(-1)?.author;
113
+ if (!author)
114
+ continue;
115
+ const channel = channelFor(author);
116
+ channel.deliveries += 1;
117
+ const recordedAttempts = typeof event.data.attempts === "number"
118
+ && Number.isInteger(event.data.attempts) && event.data.attempts >= 0
119
+ ? event.data.attempts
120
+ : history.dispatches.filter((dispatch) => dispatch.eventIndex < eventIndex).length;
121
+ channel.attemptsToGreen += recordedAttempts;
122
+ history.deliveries.push({ author, eventIndex });
123
+ continue;
124
+ }
125
+ if (event.event === "review-retry") {
126
+ const author = dispatchedAuthorFor(history, event.data);
127
+ if (!author)
128
+ continue;
129
+ for (const reviewer of [event.data.flaked, event.data.retried]) {
130
+ if (typeof reviewer === "string" && reviewer.trim())
131
+ channelFor(author).reviewers.add(reviewer.trim());
132
+ }
133
+ continue;
134
+ }
135
+ if (event.event !== "gate-result")
136
+ continue;
137
+ const author = dispatchedAuthorFor(history, event.data);
138
+ if (!author)
139
+ continue;
140
+ const channel = channelFor(author);
141
+ if (event.data.gate === "review") {
142
+ const reviewer = reviewerFrom(event.data);
143
+ if (reviewer)
144
+ channel.reviewers.add(reviewer);
145
+ }
146
+ if (event.data.pass !== false)
147
+ continue;
148
+ const infra = event.data.infra === true || classifyFailureOutput(redEvidence(event.data)) === "infra";
149
+ if (infra)
150
+ channel.infraReds += 1;
151
+ else
152
+ channel.realReds += 1;
153
+ }
154
+ // A rescue is task-matched, not attempt-matched: one edge says the failed author and delivering
155
+ // author faced the same task in the same run. Repeated attempts on the failed author do not make
156
+ // the task easier and therefore do not manufacture extra controlled comparisons.
157
+ for (const [taskId, history] of tasks) {
158
+ for (const delivery of history.deliveries) {
159
+ const failedAuthors = new Set(history.dispatches
160
+ .filter((dispatch) => dispatch.eventIndex < delivery.eventIndex && dispatch.author !== delivery.author)
161
+ .map((dispatch) => dispatch.author));
162
+ for (const failedAuthor of failedAuthors) {
163
+ channelFor(delivery.author).rescues.add(`${failedAuthor} → ${delivery.author} (${runId}/${taskId})`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return {
169
+ runs: runIds.length,
170
+ channels: [...channels.values()]
171
+ .sort((a, b) => a.author.localeCompare(b.author, "en"))
172
+ .map((channel) => ({
173
+ author: channel.author,
174
+ reviewers: [...channel.reviewers].sort((a, b) => a.localeCompare(b, "en")),
175
+ dispatches: channel.dispatches,
176
+ deliveries: channel.deliveries,
177
+ deliveryRate: channel.dispatches === 0 ? 0 : channel.deliveries / channel.dispatches,
178
+ attemptsToGreen: channel.deliveries === 0 ? null : channel.attemptsToGreen / channel.deliveries,
179
+ realReds: channel.realReds,
180
+ infraReds: channel.infraReds,
181
+ rescues: [...channel.rescues].sort((a, b) => a.localeCompare(b, "en")),
182
+ })),
183
+ };
184
+ }
185
+ const percent = (rate) => `${Number((rate * 100).toFixed(1))}%`;
186
+ export function renderStats(report) {
187
+ const lines = [`tickmarkr stats — ${report.runs} run${report.runs === 1 ? "" : "s"}`];
188
+ if (report.channels.length === 0)
189
+ return [...lines, "no channels"].join("\n");
190
+ lines.push(STATS_COLUMNS.join(" | "));
191
+ for (const channel of report.channels) {
192
+ lines.push([
193
+ channel.author,
194
+ channel.reviewers.join(", ") || "—",
195
+ channel.dispatches,
196
+ channel.deliveries,
197
+ percent(channel.deliveryRate),
198
+ channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
199
+ channel.realReds,
200
+ channel.infraReds,
201
+ channel.rescues.join("; ") || "—",
202
+ ].join(" | "));
203
+ }
204
+ return lines.join("\n");
205
+ }
206
+ export async function stats(argv, cwd = process.cwd()) {
207
+ if (argv.length > 0)
208
+ throw new Error("stats takes no run id — it reads every run in the state directory");
209
+ return renderStats(collectChannelStats(cwd));
210
+ }
@@ -7,7 +7,7 @@ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
7
7
  import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
8
8
  import { GATE_NAMES } from "../../graph/schema.js";
9
9
  import { foldActivity } from "../../run/activity.js";
10
- import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
10
+ import { Journal, engagementComparable, isQualityFailureParkKind, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
11
11
  import { isPidLive } from "../../run/lock.js";
12
12
  import { normalizeGateOutcome } from "../../run/outcome.js";
13
13
  import { desiredPanes } from "../../run/reconcile.js";
@@ -979,6 +979,21 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
979
979
  if (!taskIds.has(taskId))
980
980
  phases.delete(taskId);
981
981
  const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
982
+ // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
983
+ // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
984
+ // restore boundary; current resume-restore rows additionally carry the same task for narration.
985
+ const preservedRefs = preservedRefsByTask(events);
986
+ const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
987
+ && upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
988
+ ? [event.taskId]
989
+ : []));
990
+ const recoveryLinesForTask = (taskId) => [
991
+ ...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
992
+ `preserved worktree — ${taskId} — ${ref}`,
993
+ ` ${diffCommand}`,
994
+ ]),
995
+ ...(restoredUpheldFeedback.has(taskId) ? [`upheld feedback restored for ${taskId}`] : []),
996
+ ].map(sanitizeTaskText);
982
997
  const cells = renderedTasks.map((t) => {
983
998
  const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
984
999
  const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
@@ -1011,7 +1026,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1011
1026
  if (!unicode) {
1012
1027
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
1013
1028
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
1014
- const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
1029
+ const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
1015
1030
  const chain = gateChain(states, false);
1016
1031
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
1017
1032
  const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
@@ -1020,7 +1035,10 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1020
1035
  // can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
1021
1036
  // 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
1022
1037
  // (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
1023
- return `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`;
1038
+ return [
1039
+ `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`,
1040
+ ...recoveryLinesForTask(t.id).map((line) => ` ${line}`),
1041
+ ];
1024
1042
  });
1025
1043
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
1026
1044
  const header = runId
@@ -1210,6 +1228,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1210
1228
  return [legend(headerRow), dim(ruleRow), ...rows];
1211
1229
  };
1212
1230
  const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
1231
+ const recoveryLines = cells.flatMap((cell) => recoveryLinesForTask(cell.t.id));
1232
+ const recoverySection = recoveryLines.length === 0 ? [] : [
1233
+ "",
1234
+ ` ${ok("▌")} ${title("RECOVERY")}`,
1235
+ ...recoveryLines.map((line) => legend(` ${line}`)),
1236
+ ];
1213
1237
  const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
1214
1238
  return {
1215
1239
  content: boardRows([
@@ -1220,6 +1244,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1220
1244
  taskSection,
1221
1245
  "",
1222
1246
  ...tableRows(),
1247
+ ...recoverySection,
1223
1248
  gatesLegend,
1224
1249
  "",
1225
1250
  rule(boardColumns),
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -14,6 +14,7 @@ import { report } from "./commands/report.js";
14
14
  import { resume } from "./commands/resume.js";
15
15
  import { run } from "./commands/run.js";
16
16
  import { scope } from "./commands/scope.js";
17
+ import { stats } from "./commands/stats.js";
17
18
  import { status } from "./commands/status.js";
18
19
  import { ui } from "./commands/ui.js";
19
20
  import { unlock } from "./commands/unlock.js";
@@ -21,7 +22,7 @@ import { verify } from "./commands/verify.js";
21
22
  import { version } from "./commands/version.js";
22
23
  const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
23
24
  export const COMMANDS = {
24
- init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
25
+ init, doctor, fleet, compile, scope, plan, run, status, stats, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
25
26
  };
26
27
  const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
27
28
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
@@ -37,6 +38,7 @@ usage: tickmarkr <command>
37
38
  eval run checked-in fixtures against every channel in isolated temp repos
38
39
  run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)
39
40
  status live run state
41
+ stats all-run channel delivery, red, rescue, author and reviewer statistics
40
42
  verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
41
43
  resume <id> continue a run from its journal
42
44
  report <id> cost/quality report (--md for committable execution record)
@@ -30,6 +30,22 @@ export declare function classifyScopeOffenders(taskId: string, hard: ReadonlyArr
30
30
  * Each line names the task id and at least one missing collateral test path.
31
31
  */
32
32
  export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
33
+ /**
34
+ * Does every mention of `target` have an explicit, target-local READ relation?
35
+ *
36
+ * A `context:` entry grants READ authority and nothing else, so both blocking paths have to ask this
37
+ * before honouring one. Fail-closed by construction: every occurrence must match one of the narrow
38
+ * read forms below. Absence from a write-verb list is never evidence. Thus `ownedTable returns two`
39
+ * and the unlisted `ownedTable sorts the rows it already publishes` are refused, while an unrelated
40
+ * write in `the consumer adds a cache while reading ownedTable` does not revoke ownedTable's read
41
+ * authority. A target mentioned twice, once for reading and once ambiguously, is refused.
42
+ *
43
+ * This is intentionally a lexical representation rather than prose understanding. Keyed on NAMES:
44
+ * only the literal target is classified, so a dependency described conceptually stays invisible and
45
+ * an unfamiliar read phrasing stays refused. That closes the false-positive direction only; widen
46
+ * the affirmative grammar only with a control that goes red first.
47
+ */
48
+ export declare function criterionReadsOnly(text: string, target: string): boolean;
33
49
  export declare function newDirectoryLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
34
50
  /**
35
51
  * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
@@ -104,7 +120,7 @@ export declare function goalDensityErrors(tasks: ReadonlyArray<Pick<Task, "id" |
104
120
  * must be COMPLETE: any code path that cannot be read is its own error — the rule fails closed
105
121
  * rather than trust a partial scan.
106
122
  */
107
- export declare function symbolOwnershipErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): string[];
123
+ export declare function symbolOwnershipErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance"> & Partial<Pick<Task, "context">>>, repoRoot: string): string[];
108
124
  /**
109
125
  * The participation half of the config — everything the review-policy rules read. `byShape` rides
110
126
  * along because `gates.byShape.<shape>.review: false` is a second, NON-monotone participation switch:
@@ -128,6 +144,11 @@ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task
128
144
  * symbol-ownership lint and the participation config, and defaults to the invocation directory —
129
145
  * correct for the CLI/daemon, which compile from inside the target repo.
130
146
  *
147
+ * The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
148
+ * aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
149
+ * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
150
+ * field existed, because an absent declaration grants no authority at all.
151
+ *
131
152
  * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
132
153
  * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
133
154
  * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
@@ -138,4 +159,4 @@ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task
138
159
  * critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
139
160
  * than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
140
161
  */
141
- export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape">>, repoRoot?: string, review?: ReviewParticipation): string[];
162
+ export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape"> & Partial<Pick<Task, "context">>>, repoRoot?: string, review?: ReviewParticipation): string[];
@@ -171,15 +171,95 @@ export function collateralLints(tasks, repoRoot) {
171
171
  // v1.53 T4 (OBS-76): needles are code-shaped tokens only (camelCase / snake_case) — plain prose
172
172
  // words never match, so prose-only criteria yield zero needles instead of alarm-fatigue noise.
173
173
  // ponytail: token heuristic, not AST symbol resolution — promote after a version of precision data.
174
- function criteriaSymbols(acceptance) {
175
- const out = new Set();
174
+ function criteriaSymbolTexts(acceptance) {
175
+ const out = new Map();
176
176
  for (const item of acceptance) {
177
- for (const tok of renderAcceptanceItem(item).match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []) {
178
- if (tok.includes("_") || /[a-z][A-Z]/.test(tok))
179
- out.add(tok);
177
+ const text = renderAcceptanceItem(item);
178
+ for (const tok of text.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []) {
179
+ if (!tok.includes("_") && !/[a-z][A-Z]/.test(tok))
180
+ continue;
181
+ const texts = out.get(tok) ?? [];
182
+ if (!texts.includes(text))
183
+ texts.push(text);
184
+ out.set(tok, texts);
180
185
  }
181
186
  }
182
- return [...out].sort();
187
+ return out;
188
+ }
189
+ function criteriaSymbols(acceptance) {
190
+ return [...criteriaSymbolTexts(acceptance).keys()].sort();
191
+ }
192
+ // These are affirmative READ relations, not a list of things that are not writes. The distinction
193
+ // is the fail-closed boundary: an unknown predicate never earns context[] authority. A target is
194
+ // readable when it is the object of an explicit observation (`reading ownedTable`) or the subject
195
+ // of an explicitly pre-existing state (`ownedTable already publishes`). An unqualified declarative
196
+ // predicate (`ownedTable returns two rows`) is deliberately absent because it can demand a change.
197
+ const DIRECT_READ_BEFORE = /\b(?:consult|consults|consulting|inspect|inspects|inspecting|read|reads|reading|reference|references|referencing)\s+(?:(?:the|an?)\s+)?(?:(?:current|existing|unchanged)\s+)?$/i;
198
+ const PREEXISTING_STATE_AFTER = /^\s*(?:(?:and|or)\s+\S+\s+)*(?:(?:,\s*)?which\s+)?(?:already|currently)\s+(?:carries|carry|contains?|declares?|defines?|exposes?|holds?|owns?|provides?|publish|publishes|returns?|supplies|supply|yields?)\b/i;
199
+ const PASSIVE_READ_AFTER = /^\s+(?:is|remains)\s+(?:read|referenced|unchanged|untouched|as[- ]is)\b/i;
200
+ // Once a target-local read has been established, a same-clause continuation can revoke it. These
201
+ // vetoes are structural rather than an attempted exhaustive list of write verbs: an action followed
202
+ // by `it` / `them` / `the same ...` is target-directed but ambiguous, and a coordinated passive
203
+ // participle inherits the target as its subject. Both therefore fail closed. The scan stops at an
204
+ // actual sentence/clause boundary; a dot inside a later path is not one.
205
+ const TARGET_ANAPHOR_AFTER_COORDINATOR = /\b(?:and|or|then|before|after|while)\b[^,;.!?\n]{0,80}\b(?:it|them|those|the\s+same(?:\s+[A-Za-z][A-Za-z0-9_-]*)?)\b/i;
206
+ const TARGET_PASSIVE_AFTER_SUBJECT_READ = /\b(?:and|or|then|before|after)\s+(?:then\s+)?(?:(?:is|gets?|becomes?|being)\s+)?[A-Za-z]+(?:ed|en)\b/i;
207
+ function sameClauseAfterTarget(after) {
208
+ const boundary = after.search(/[;!?\n]|\.(?=\s|$)/);
209
+ return boundary === -1 ? after : after.slice(0, boundary);
210
+ }
211
+ function targetOccurrences(text, target) {
212
+ if (!target)
213
+ return [];
214
+ const out = [];
215
+ const wordAtStart = /[A-Za-z0-9_]/.test(target[0]);
216
+ const wordAtEnd = /[A-Za-z0-9_]/.test(target[target.length - 1]);
217
+ for (let at = text.indexOf(target); at !== -1; at = text.indexOf(target, at + target.length)) {
218
+ const before = text[at - 1];
219
+ const after = text[at + target.length];
220
+ if (wordAtStart && before !== undefined && /[A-Za-z0-9_]/.test(before))
221
+ continue;
222
+ if (wordAtEnd && after !== undefined && /[A-Za-z0-9_]/.test(after))
223
+ continue;
224
+ out.push(at);
225
+ }
226
+ return out;
227
+ }
228
+ /**
229
+ * Does every mention of `target` have an explicit, target-local READ relation?
230
+ *
231
+ * A `context:` entry grants READ authority and nothing else, so both blocking paths have to ask this
232
+ * before honouring one. Fail-closed by construction: every occurrence must match one of the narrow
233
+ * read forms below. Absence from a write-verb list is never evidence. Thus `ownedTable returns two`
234
+ * and the unlisted `ownedTable sorts the rows it already publishes` are refused, while an unrelated
235
+ * write in `the consumer adds a cache while reading ownedTable` does not revoke ownedTable's read
236
+ * authority. A target mentioned twice, once for reading and once ambiguously, is refused.
237
+ *
238
+ * This is intentionally a lexical representation rather than prose understanding. Keyed on NAMES:
239
+ * only the literal target is classified, so a dependency described conceptually stays invisible and
240
+ * an unfamiliar read phrasing stays refused. That closes the false-positive direction only; widen
241
+ * the affirmative grammar only with a control that goes red first.
242
+ */
243
+ export function criterionReadsOnly(text, target) {
244
+ const occurrences = targetOccurrences(text, target);
245
+ return occurrences.length > 0 && occurrences.every((at) => {
246
+ // Backticks quote the target, not the relation, so discard only the adjacent delimiters.
247
+ const before = text.slice(Math.max(0, at - 96), at).replace(/`$/, "");
248
+ const after = text.slice(at + target.length).replace(/^`/, "");
249
+ const preexisting = PREEXISTING_STATE_AFTER.exec(after);
250
+ const passive = PASSIVE_READ_AFTER.exec(after);
251
+ if (!DIRECT_READ_BEFORE.test(before) && !preexisting && !passive)
252
+ return false;
253
+ const clause = sameClauseAfterTarget(after);
254
+ if (TARGET_ANAPHOR_AFTER_COORDINATOR.test(clause))
255
+ return false;
256
+ // A subject-position read (`target is read` / `target already publishes`) leaves the target as
257
+ // the inherited subject of a coordinated passive: `... and then rewritten by the consumer` is
258
+ // a change demand, not a read. Object-position reads need the anaphor veto above instead.
259
+ const subjectRead = preexisting ?? passive;
260
+ return !subjectRead
261
+ || !TARGET_PASSIVE_AFTER_SUBJECT_READ.test(clause.slice(subjectRead[0].length));
262
+ });
183
263
  }
184
264
  const ARCH_PAGES = ["docs/codebase/ARCHITECTURE.md", "docs/codebase/STRUCTURE.md"];
185
265
  function topLevelSrcDir(file) {
@@ -519,8 +599,8 @@ function walkAllCode(repoRoot) {
519
599
  */
520
600
  export function symbolOwnershipErrors(tasks, repoRoot) {
521
601
  const perTask = tasks
522
- .map((t) => ({ t, symbols: criteriaSymbols(t.acceptance ?? []) }))
523
- .filter((x) => x.symbols.length);
602
+ .map((t) => ({ t, bySymbol: criteriaSymbolTexts(t.acceptance ?? []) }))
603
+ .filter((x) => x.bySymbol.size);
524
604
  if (!perTask.length)
525
605
  return [];
526
606
  const { files: codeFiles, unreadable } = walkAllCode(repoRoot);
@@ -531,7 +611,7 @@ export function symbolOwnershipErrors(tasks, repoRoot) {
531
611
  for (const u of unreadable)
532
612
  errors.push(incomplete(u));
533
613
  // resolve each symbol to its definition site(s) once, shared across tasks
534
- const allSymbols = [...new Set(perTask.flatMap((x) => x.symbols))];
614
+ const allSymbols = [...new Set(perTask.flatMap((x) => [...x.bySymbol.keys()]))];
535
615
  const res = new Map(allSymbols.map((s) => [s, definitionRe(s)]));
536
616
  const sites = new Map(allSymbols.map((s) => [s, []]));
537
617
  for (const cf of codeFiles) {
@@ -548,16 +628,46 @@ export function symbolOwnershipErrors(tasks, repoRoot) {
548
628
  sites.get(sym).push(cf);
549
629
  }
550
630
  }
551
- for (const { t, symbols } of perTask) {
631
+ for (const { t, bySymbol } of perTask) {
552
632
  // OBS-22: scopeGate accepts picomatch globs; ownership must agree.
553
633
  const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
554
- for (const sym of symbols) {
634
+ // THE RULE AT THIS SITE: a `context:`
635
+ // entry declares a path the worker may READ, so it answers "can the worker satisfy a criterion
636
+ // that only reads this symbol" (yes) and never "may the worker change it" (no — that authority
637
+ // comes from files[] alone). Hence the exemption is conditional on the criteria that actually
638
+ // name the symbol, and it is asked of the SYMBOL, not of the sentence: `criterionReadsOnly` fails
639
+ // closed, so a criterion demanding the symbol CHANGE — and any phrasing that does not prove it
640
+ // reads the symbol — is still refused, and still told to widen files[] where the change is what
641
+ // it asks for. An unrelated in-scope write does not change that per-symbol answer. Keyed on NAMES:
642
+ // only a path literally written in context[] is seen here, so a
643
+ // read dependency an author described in prose remains invisible to this lint. That closes the
644
+ // false-positive direction only — nothing here narrows what the rule refuses.
645
+ const declaredContext = (t.context ?? []).map((f) => f.replace(/^\.\//, ""));
646
+ const readable = declaredContext.length ? filesGlob(declaredContext) : () => false;
647
+ for (const [sym, texts] of bySymbol) {
555
648
  const defs = sites.get(sym) ?? [];
556
649
  if (defs.length !== 1)
557
650
  continue; // unknown or ambiguous — silent by ruling
558
651
  const site = defs[0];
559
652
  if (scoped(site))
560
653
  continue; // defined inside the task's own write surface
654
+ const writes = !texts.every((text) => criterionReadsOnly(text, sym));
655
+ if (readable(site) && !writes)
656
+ continue; // read authority is declared and read authority is all it needs
657
+ if (readable(site)) {
658
+ errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is declared in `
659
+ + `context[] but not in files[] — a context: entry grants READ authority only, and this `
660
+ + `criterion requires changing "${sym}"; add ${site} to files[] or reword the criterion to `
661
+ + `require only reading it (OBS-248).`);
662
+ continue;
663
+ }
664
+ if (!writes) {
665
+ errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is in neither `
666
+ + `files[] nor context[] — the criterion only reads "${sym}", so declare ${site} in `
667
+ + `context[]; widening files[] would grant write authority this criterion never asks for `
668
+ + `(OBS-248).`);
669
+ continue;
670
+ }
561
671
  errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is not in files[] — `
562
672
  + `add ${site} to files[] or reword the criterion; a worker scoped to files[] cannot satisfy a `
563
673
  + `criterion whose enabling symbol lives outside it (OBS-248).`);
@@ -630,6 +740,11 @@ function activeReviewParticipation(repoRoot) {
630
740
  * symbol-ownership lint and the participation config, and defaults to the invocation directory —
631
741
  * correct for the CLI/daemon, which compile from inside the target repo.
632
742
  *
743
+ * The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
744
+ * aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
745
+ * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
746
+ * field existed, because an absent declaration grants no authority at all.
747
+ *
633
748
  * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
634
749
  * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
635
750
  * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
@@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { filesGlob } from "../graph/files-glob.js";
5
5
  import picomatch from "picomatch";
6
6
  import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
7
+ import { criterionReadsOnly } from "./collateral.js";
7
8
  import { CompileError, inferShape, sha256 } from "./common.js";
8
9
  // OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
9
10
  // Workers run in `git worktree add <baseRef>` (run/git.ts:169-176), which materialises the base
@@ -169,20 +170,51 @@ function renderedObservables(text) {
169
170
  function criterionScopeFinding(task, text, criterion, id, tests) {
170
171
  if (task.files.length === 0)
171
172
  return undefined; // empty files[] is deliberately unrestricted
172
- const inScope = filesGlob(task.files.map((entry) => entry.replace(/^\.\//, ""))); // Q120s shared matcher
173
+ const strip = (entry) => entry.replace(/^\.\//, ""); // Q120s shared matcher below
174
+ const inFiles = filesGlob(task.files.map(strip));
175
+ const context = (task.context ?? []).map(strip);
176
+ const inContext = context.length ? filesGlob(context) : () => false;
177
+ // THE RULE AT THIS SITE: `files:` clears a named path outright, because it is write authority and
178
+ // write authority covers reading too. `context:` clears one ONLY for a criterion that reads it and
179
+ // does not change it — a read declaration is not a second write surface, and unioning it in unconditionally
180
+ // would promote read authority to write authority for exactly the criterion that asks for the
181
+ // change. The question is asked per PATH — each occurrence of each declared path must carry its own
182
+ // affirmative read relation. A write elsewhere in the criterion therefore cannot revoke a named
183
+ // dependency's read exemption, and a read elsewhere cannot confer one on a path being changed. It
184
+ // is the same target-specific question the symbol-ownership rule asks (collateral.ts), and it fails
185
+ // closed: any phrasing that does not prove the criterion reads the path is refused, as it was before
186
+ // this check consulted the declaration at all. Keyed on NAMES: only a path literally written in files[]
187
+ // or context[] is matched, so a dependency the author described conceptually and never wrote out
188
+ // stays invisible to this check. That closes the false-positive direction only — a path in neither
189
+ // declaration is refused exactly as before.
190
+ const declared = (path) => inFiles(path) || (inContext(path) && criterionReadsOnly(text, path));
173
191
  const named = namedCriterionPaths(text, tests);
174
192
  const { exact, denominators } = renderedObservables(text);
175
193
  const asserting = tests.filter((test) => exact.some((token) => test.text.includes(token))
176
194
  || denominators.some((denominator) => new RegExp(`(?:^|\\D)\\d+/${denominator}(?:\\D|$)`).test(test.text))).map((test) => test.path);
177
- const missing = [...new Set([...named, ...asserting])].filter((path) => !inScope(path));
195
+ const missing = [...new Set([...named, ...asserting])].filter((path) => !declared(path));
178
196
  if (missing.length === 0)
179
197
  return undefined;
198
+ // The remedy names the authority the criterion actually needs. A criterion that only READS the
199
+ // producer is repaired by context[]; instructing its author to widen files[] is the product
200
+ // emitting the unsafe workaround itself. Only a criterion that requires CHANGING the producer is
201
+ // told to widen the write surface.
202
+ const readMissing = missing.filter((path) => criterionReadsOnly(text, path));
203
+ const writeMissing = missing.filter((path) => !criterionReadsOnly(text, path));
204
+ const remedy = [
205
+ readMissing.length === 0 ? "" : `the criterion only reads ${readMissing.length === 1 ? "it" : "them"} `
206
+ + `(${readMissing.join(", ")}), so add ${readMissing.length === 1 ? "it" : "them"} to context[] — `
207
+ + `files[] would grant write authority this criterion never asks for`,
208
+ writeMissing.length === 0 ? "" : `the criterion requires changing ${writeMissing.length === 1 ? "it" : "them"} `
209
+ + `(or does not establish a read-only relation) (${writeMissing.join(", ")}), so add `
210
+ + `${writeMissing.length === 1 ? "it" : "them"} to files[]`,
211
+ ].filter(Boolean).join("; ");
180
212
  return {
181
213
  code: "criterion-scope",
182
214
  fixtureId: id,
183
215
  taskId: task.id,
184
216
  criterion,
185
- detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[]: ${missing.join(", ")}`,
217
+ detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[] and context[]: ${missing.join(", ")} — ${remedy}`,
186
218
  };
187
219
  }
188
220
  function exportedIdentifier(root, files, identifier) {