tickmarkr 2.1.5 → 2.1.7
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/cli/commands/beat.js +37 -10
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/stats.d.ts +21 -0
- package/dist/cli/commands/stats.js +200 -0
- package/dist/cli/commands/status.js +51 -19
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +14 -5
- package/dist/compile/ownership.d.ts +25 -0
- package/dist/compile/ownership.js +142 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +7 -6
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +54 -14
- package/dist/gates/baseline.js +39 -18
- package/dist/run/daemon.js +257 -12
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +62 -7
- package/dist/run/outcome.js +8 -1
- package/dist/run/supervision.d.ts +15 -1
- package/dist/run/supervision.js +112 -14
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -1
- package/skills/tickmarkr-loop/SKILL.md +2 -1
- package/skills/tickmarkr-overseer/SKILL.md +21 -1
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +46 -32
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { tickmarkrDir } from "../../graph/graph.js";
|
|
4
|
-
import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, supervisionStandDownPath, } from "../../run/supervision.js";
|
|
4
|
+
import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, supervisionStandDownPath, } from "../../run/supervision.js";
|
|
5
5
|
// SUP-04: the writer side of supervision, as a VERB. `beatSupervision` and `SUPERVISION_BEAT_MS` shipped
|
|
6
6
|
// with exactly one in-repo caller — the daemon, on one tier — so `status` printed
|
|
7
7
|
// `orchestrator ARMED / overseer ABSENT / watch ABSENT` while a real overseer worked the run: two thirds
|
|
@@ -18,16 +18,22 @@ import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSuper
|
|
|
18
18
|
// reads UNREADABLE. Here the writes are unguarded: a failure leaves the process with a non-zero exit
|
|
19
19
|
// and a message instead of a claim. The LOOP belongs to the caller, which is what makes the beat
|
|
20
20
|
// evidence: stop calling it and the tier ages into STALE on its own.
|
|
21
|
+
const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
|
|
21
22
|
export async function beat(argv, cwd = process.cwd()) {
|
|
22
23
|
const standDown = argv.includes("--stand-down");
|
|
23
24
|
const seat = seatOf(argv);
|
|
24
|
-
const
|
|
25
|
+
const pct = percentageOf(argv, "--pct");
|
|
26
|
+
const thresholdPct = percentageOf(argv, "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
|
|
27
|
+
const armId = optionOf(argv, "--arm-id");
|
|
28
|
+
const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
|
|
25
29
|
if (!isTier(named)) {
|
|
26
|
-
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity>
|
|
30
|
+
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
|
|
31
|
+
`[--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
|
|
32
|
+
`got ${named ? `\`${named}\`` : "no tier"}`);
|
|
27
33
|
}
|
|
28
34
|
// SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
|
|
29
|
-
// the tier exactly as it found it. This verb is one-shot: the
|
|
30
|
-
// already exited by the time anyone reads the record, so tier +
|
|
35
|
+
// the tier exactly as it found it. This verb is one-shot: the exitedWriterPid it records has
|
|
36
|
+
// already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
|
|
31
37
|
// attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
|
|
32
38
|
// board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
|
|
33
39
|
// reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
|
|
@@ -42,16 +48,35 @@ export async function beat(argv, cwd = process.cwd()) {
|
|
|
42
48
|
// uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
|
|
43
49
|
// too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
|
|
44
50
|
rmSync(supervisionStandDownPath(cwd, named), { force: true, recursive: true });
|
|
45
|
-
beatSupervision(cwd, named, seat);
|
|
51
|
+
beatSupervision(cwd, named, seat, pct === undefined ? undefined : { armId: armId ?? seat, pct, thresholdPct });
|
|
46
52
|
return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
|
|
47
53
|
}
|
|
48
54
|
/** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
|
|
49
55
|
function seatOf(argv) {
|
|
50
|
-
const
|
|
51
|
-
const spaced = argv[argv.indexOf("--seat") + 1];
|
|
52
|
-
const seat = (inline ?? (argv.includes("--seat") ? spaced : undefined))?.trim();
|
|
56
|
+
const seat = optionOf(argv, "--seat")?.trim();
|
|
53
57
|
return seat && !seat.startsWith("--") ? seat : undefined;
|
|
54
58
|
}
|
|
59
|
+
/** A `--name value` or `--name=value` option, excluding a missing value or the next flag. */
|
|
60
|
+
function optionOf(argv, name) {
|
|
61
|
+
const inline = argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1).trim();
|
|
62
|
+
const spaced = argv[argv.indexOf(name) + 1]?.trim();
|
|
63
|
+
const value = inline ?? (argv.includes(name) ? spaced : undefined);
|
|
64
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
function percentageOf(argv, name) {
|
|
67
|
+
const raw = optionOf(argv, name);
|
|
68
|
+
if (raw === undefined) {
|
|
69
|
+
if (argv.includes(name) || argv.some((a) => a.startsWith(`${name}=`))) {
|
|
70
|
+
throw new Error(`${name} needs a number from 0 through 100`);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const value = Number(raw);
|
|
75
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
|
76
|
+
throw new Error(`${name} must be a number from 0 through 100`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
55
80
|
// Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
|
|
56
81
|
// so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
|
|
57
82
|
// renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
|
|
@@ -63,7 +88,9 @@ function standDownTier(repoRoot, tier, seat) {
|
|
|
63
88
|
mkdirSync(dirname(p), { recursive: true });
|
|
64
89
|
// The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
|
|
65
90
|
// a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
|
|
66
|
-
writeFileSync(tmp, JSON.stringify({
|
|
91
|
+
writeFileSync(tmp, JSON.stringify({
|
|
92
|
+
tier, seat, exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
|
|
93
|
+
}) + "\n");
|
|
67
94
|
renameSync(tmp, p);
|
|
68
95
|
return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
|
|
69
96
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -167,7 +167,7 @@ const railTone = (event, fallback) => {
|
|
|
167
167
|
// `gate-result` is the one event whose data IS a gate result; every other row carrying a `gate`
|
|
168
168
|
// names a gate without reporting one (a start, a reuse, a retry) and keeps its declared tone.
|
|
169
169
|
if (event.event === "gate-result")
|
|
170
|
-
return GATE_OUTCOME_TONES[normalizeGateOutcome(event.data).kind]
|
|
170
|
+
return GATE_OUTCOME_TONES[normalizeGateOutcome(event.data).kind];
|
|
171
171
|
const verdict = event.data.pass ?? event.data.ok;
|
|
172
172
|
return verdict === true ? "pass" : verdict === false ? "fail" : fallback;
|
|
173
173
|
};
|
|
@@ -226,7 +226,24 @@ const railSalient = (data) => RAIL_SALIENT.flatMap(({ key, render }) => {
|
|
|
226
226
|
* This is one detail vocabulary stated twice, so `brand-surfaces.test.ts` pins the two against each
|
|
227
227
|
* other over the whole event corpus: the pipe's bytes must equal event/taskId/THIS detail put through
|
|
228
228
|
* the formatter's legacy squeeze-and-slice. A ladder that drifts fails there rather than in a tab.
|
|
229
|
+
* Gate-result verdict words are the deliberate exception: the pipe is byte-frozen, while the TTY rail
|
|
230
|
+
* names the normalized outcome so a held selected-test screen is not narrated as a pass.
|
|
229
231
|
*/
|
|
232
|
+
const gateResultDetail = (data) => {
|
|
233
|
+
if (typeof data.gate !== "string")
|
|
234
|
+
return undefined;
|
|
235
|
+
switch (normalizeGateOutcome(data).kind) {
|
|
236
|
+
case "passed":
|
|
237
|
+
return `${data.gate} passed`;
|
|
238
|
+
case "failed":
|
|
239
|
+
return `${data.gate} failed`;
|
|
240
|
+
case "held":
|
|
241
|
+
// The kind is already normalized; the selected-test list only chooses this surface noun.
|
|
242
|
+
return Array.isArray(data.selectedTests) ? `${data.gate} selected-test screen` : `${data.gate} held`;
|
|
243
|
+
default:
|
|
244
|
+
return data.gate;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
230
247
|
const formatterDetail = ({ event, data }) => {
|
|
231
248
|
const assignment = data.assignment;
|
|
232
249
|
const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
|
|
@@ -240,7 +257,9 @@ const formatterDetail = ({ event, data }) => {
|
|
|
240
257
|
}
|
|
241
258
|
if (event === "tip-verify")
|
|
242
259
|
return `${data.gate} passed`;
|
|
243
|
-
|
|
260
|
+
if (event === "gate-result")
|
|
261
|
+
return gateResultDetail(data);
|
|
262
|
+
return `${data.gate}`;
|
|
244
263
|
}
|
|
245
264
|
if (typeof data.code === "number")
|
|
246
265
|
return `exit ${data.code}`;
|
|
@@ -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,200 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { stateDirName } from "../../graph/graph.js";
|
|
4
|
+
import { Journal } from "../../run/journal.js";
|
|
5
|
+
import { normalizeGateOutcome } from "../../run/outcome.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 historyFor = (tasks, taskId) => {
|
|
39
|
+
let history = tasks.get(taskId);
|
|
40
|
+
if (!history) {
|
|
41
|
+
history = { dispatches: [], deliveries: [] };
|
|
42
|
+
tasks.set(taskId, history);
|
|
43
|
+
}
|
|
44
|
+
return history;
|
|
45
|
+
};
|
|
46
|
+
const dispatchedAuthorFor = (history, data) => {
|
|
47
|
+
const attempt = typeof data.attempt === "number" && Number.isInteger(data.attempt)
|
|
48
|
+
? data.attempt
|
|
49
|
+
: undefined;
|
|
50
|
+
if (attempt !== undefined) {
|
|
51
|
+
const matched = [...history.dispatches].reverse().find((dispatch) => dispatch.attempt === attempt);
|
|
52
|
+
if (matched)
|
|
53
|
+
return matched.author;
|
|
54
|
+
}
|
|
55
|
+
return history.dispatches.at(-1)?.author;
|
|
56
|
+
};
|
|
57
|
+
const runIdsWithJournals = (cwd) => {
|
|
58
|
+
const runsDir = join(cwd, stateDirName(cwd), "runs");
|
|
59
|
+
if (!existsSync(runsDir))
|
|
60
|
+
return [];
|
|
61
|
+
return readdirSync(runsDir, { withFileTypes: true })
|
|
62
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-")
|
|
63
|
+
&& existsSync(join(runsDir, entry.name, "journal.jsonl")))
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
};
|
|
67
|
+
/** Reduce every readable run journal under the repository's state directory. */
|
|
68
|
+
export function collectChannelStats(cwd = process.cwd()) {
|
|
69
|
+
const runIds = runIdsWithJournals(cwd);
|
|
70
|
+
const channels = new Map();
|
|
71
|
+
const channelFor = (author) => {
|
|
72
|
+
let channel = channels.get(author);
|
|
73
|
+
if (!channel) {
|
|
74
|
+
channel = {
|
|
75
|
+
author, reviewers: new Set(), dispatches: 0, deliveries: 0,
|
|
76
|
+
attemptsToGreen: 0, realReds: 0, infraReds: 0, rescues: new Set(),
|
|
77
|
+
};
|
|
78
|
+
channels.set(author, channel);
|
|
79
|
+
}
|
|
80
|
+
return channel;
|
|
81
|
+
};
|
|
82
|
+
for (const runId of runIds) {
|
|
83
|
+
const events = Journal.open(cwd, runId).read();
|
|
84
|
+
const tasks = new Map();
|
|
85
|
+
for (const [eventIndex, event] of events.entries()) {
|
|
86
|
+
if (!event.taskId)
|
|
87
|
+
continue;
|
|
88
|
+
const history = historyFor(tasks, event.taskId);
|
|
89
|
+
if (event.event === "task-dispatch") {
|
|
90
|
+
const author = assignmentAuthor(event.data);
|
|
91
|
+
if (!author)
|
|
92
|
+
continue;
|
|
93
|
+
channelFor(author).dispatches += 1;
|
|
94
|
+
history.dispatches.push({
|
|
95
|
+
author,
|
|
96
|
+
...(typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt)
|
|
97
|
+
? { attempt: event.data.attempt }
|
|
98
|
+
: {}),
|
|
99
|
+
eventIndex,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (event.event === "task-done") {
|
|
104
|
+
const author = assignmentAuthor(event.data) ?? history.dispatches.at(-1)?.author;
|
|
105
|
+
if (!author)
|
|
106
|
+
continue;
|
|
107
|
+
const channel = channelFor(author);
|
|
108
|
+
channel.deliveries += 1;
|
|
109
|
+
const recordedAttempts = typeof event.data.attempts === "number"
|
|
110
|
+
&& Number.isInteger(event.data.attempts) && event.data.attempts >= 0
|
|
111
|
+
? event.data.attempts
|
|
112
|
+
: history.dispatches.filter((dispatch) => dispatch.eventIndex < eventIndex).length;
|
|
113
|
+
channel.attemptsToGreen += recordedAttempts;
|
|
114
|
+
history.deliveries.push({ author, eventIndex });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (event.event === "review-retry") {
|
|
118
|
+
const author = dispatchedAuthorFor(history, event.data);
|
|
119
|
+
if (!author)
|
|
120
|
+
continue;
|
|
121
|
+
for (const reviewer of [event.data.flaked, event.data.retried]) {
|
|
122
|
+
if (typeof reviewer === "string" && reviewer.trim())
|
|
123
|
+
channelFor(author).reviewers.add(reviewer.trim());
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (event.event !== "gate-result")
|
|
128
|
+
continue;
|
|
129
|
+
const author = dispatchedAuthorFor(history, event.data);
|
|
130
|
+
if (!author)
|
|
131
|
+
continue;
|
|
132
|
+
const channel = channelFor(author);
|
|
133
|
+
if (event.data.gate === "review") {
|
|
134
|
+
const reviewer = reviewerFrom(event.data);
|
|
135
|
+
if (reviewer)
|
|
136
|
+
channel.reviewers.add(reviewer);
|
|
137
|
+
}
|
|
138
|
+
const outcome = normalizeGateOutcome(event.data);
|
|
139
|
+
if (outcome.kind === "infra")
|
|
140
|
+
channel.infraReds += 1;
|
|
141
|
+
else if (outcome.kind === "failed")
|
|
142
|
+
channel.realReds += 1;
|
|
143
|
+
}
|
|
144
|
+
// A rescue is task-matched, not attempt-matched: one edge says the failed author and delivering
|
|
145
|
+
// author faced the same task in the same run. Repeated attempts on the failed author do not make
|
|
146
|
+
// the task easier and therefore do not manufacture extra controlled comparisons.
|
|
147
|
+
for (const [taskId, history] of tasks) {
|
|
148
|
+
for (const delivery of history.deliveries) {
|
|
149
|
+
const failedAuthors = new Set(history.dispatches
|
|
150
|
+
.filter((dispatch) => dispatch.eventIndex < delivery.eventIndex && dispatch.author !== delivery.author)
|
|
151
|
+
.map((dispatch) => dispatch.author));
|
|
152
|
+
for (const failedAuthor of failedAuthors) {
|
|
153
|
+
channelFor(delivery.author).rescues.add(`${failedAuthor} → ${delivery.author} (${runId}/${taskId})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
runs: runIds.length,
|
|
160
|
+
channels: [...channels.values()]
|
|
161
|
+
.sort((a, b) => a.author.localeCompare(b.author, "en"))
|
|
162
|
+
.map((channel) => ({
|
|
163
|
+
author: channel.author,
|
|
164
|
+
reviewers: [...channel.reviewers].sort((a, b) => a.localeCompare(b, "en")),
|
|
165
|
+
dispatches: channel.dispatches,
|
|
166
|
+
deliveries: channel.deliveries,
|
|
167
|
+
deliveryRate: channel.dispatches === 0 ? 0 : channel.deliveries / channel.dispatches,
|
|
168
|
+
attemptsToGreen: channel.deliveries === 0 ? null : channel.attemptsToGreen / channel.deliveries,
|
|
169
|
+
realReds: channel.realReds,
|
|
170
|
+
infraReds: channel.infraReds,
|
|
171
|
+
rescues: [...channel.rescues].sort((a, b) => a.localeCompare(b, "en")),
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const percent = (rate) => `${Number((rate * 100).toFixed(1))}%`;
|
|
176
|
+
export function renderStats(report) {
|
|
177
|
+
const lines = [`tickmarkr stats — ${report.runs} run${report.runs === 1 ? "" : "s"}`];
|
|
178
|
+
if (report.channels.length === 0)
|
|
179
|
+
return [...lines, "no channels"].join("\n");
|
|
180
|
+
lines.push(STATS_COLUMNS.join(" | "));
|
|
181
|
+
for (const channel of report.channels) {
|
|
182
|
+
lines.push([
|
|
183
|
+
channel.author,
|
|
184
|
+
channel.reviewers.join(", ") || "—",
|
|
185
|
+
channel.dispatches,
|
|
186
|
+
channel.deliveries,
|
|
187
|
+
percent(channel.deliveryRate),
|
|
188
|
+
channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
|
|
189
|
+
channel.realReds,
|
|
190
|
+
channel.infraReds,
|
|
191
|
+
channel.rescues.join("; ") || "—",
|
|
192
|
+
].join(" | "));
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
export async function stats(argv, cwd = process.cwd()) {
|
|
197
|
+
if (argv.length > 0)
|
|
198
|
+
throw new Error("stats takes no run id — it reads every run in the state directory");
|
|
199
|
+
return renderStats(collectChannelStats(cwd));
|
|
200
|
+
}
|
|
@@ -7,12 +7,12 @@ 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";
|
|
14
14
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
15
|
-
import {
|
|
15
|
+
import { armWatchSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
|
|
16
16
|
import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
|
|
17
17
|
import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
|
|
18
18
|
import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
|
|
@@ -109,6 +109,15 @@ const ASCII_SPINNER = ["|", "/", "-", "\\"];
|
|
|
109
109
|
const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
|
|
110
110
|
const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
|
|
111
111
|
const decisionEvidence = (stateDir, runId, sequence) => `${stateDir}/runs/${runId}/journal.jsonl#L${sequence}`;
|
|
112
|
+
const DECISION_GATE_VERDICTS = {
|
|
113
|
+
passed: "passed",
|
|
114
|
+
failed: "failed",
|
|
115
|
+
skipped: "skipped",
|
|
116
|
+
declined: "skipped",
|
|
117
|
+
held: "unknown",
|
|
118
|
+
unavailable: "unknown",
|
|
119
|
+
infra: "unknown",
|
|
120
|
+
};
|
|
112
121
|
// v1.79 T4: a deterministic JSONL projection over journal truth. Sequence/evidence come from the
|
|
113
122
|
// append-only line position, timestamps and claims come from the row, and no watcher-local clock or
|
|
114
123
|
// filesystem write participates. Re-reading the same bytes therefore returns the same event bytes.
|
|
@@ -126,13 +135,7 @@ export const decisionEventsFromJournal = (events, runId, stateDir = ".tickmarkr"
|
|
|
126
135
|
return [{ ...base, type: "phase-change", tier: "routine", phase: event.data.phase }];
|
|
127
136
|
}
|
|
128
137
|
if (event.event === "gate-result" && typeof event.data.gate === "string") {
|
|
129
|
-
const verdict = event.data.
|
|
130
|
-
? "skipped"
|
|
131
|
-
: event.data.pass === true
|
|
132
|
-
? "passed"
|
|
133
|
-
: event.data.pass === false
|
|
134
|
-
? "failed"
|
|
135
|
-
: "unknown";
|
|
138
|
+
const verdict = DECISION_GATE_VERDICTS[normalizeGateOutcome(event.data).kind];
|
|
136
139
|
return [{
|
|
137
140
|
...base,
|
|
138
141
|
type: "gate-verdict",
|
|
@@ -381,6 +384,13 @@ export const gateBox = (state, unicode) => {
|
|
|
381
384
|
return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
|
|
382
385
|
};
|
|
383
386
|
export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
387
|
+
const GATE_OUTCOME_STATES = {
|
|
388
|
+
// The board state is a rendering vocabulary over the normalized outcome, not another field reader.
|
|
389
|
+
passed: "pass",
|
|
390
|
+
failed: "fail",
|
|
391
|
+
skipped: "skip",
|
|
392
|
+
declined: "skip",
|
|
393
|
+
};
|
|
384
394
|
const gateSnapshot = (task, events, rehashAt) => {
|
|
385
395
|
const outcomes = new Map();
|
|
386
396
|
const start = attemptStartIdx(events, task.id);
|
|
@@ -389,12 +399,9 @@ const gateSnapshot = (task, events, rehashAt) => {
|
|
|
389
399
|
const e = events[eventIndex];
|
|
390
400
|
if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
|
|
391
401
|
continue;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
outcomes.set(e.data.gate, { state: "pass", eventIndex });
|
|
396
|
-
else if (e.data.pass === false)
|
|
397
|
-
outcomes.set(e.data.gate, { state: "fail", eventIndex });
|
|
402
|
+
const state = GATE_OUTCOME_STATES[normalizeGateOutcome(e.data).kind];
|
|
403
|
+
if (state)
|
|
404
|
+
outcomes.set(e.data.gate, { state, eventIndex });
|
|
398
405
|
}
|
|
399
406
|
}
|
|
400
407
|
return {
|
|
@@ -519,7 +526,7 @@ const foldTaskEffort = (tasks, events) => {
|
|
|
519
526
|
task.dispatches += 1;
|
|
520
527
|
else if (event.event === "gate-result" && event.data.gate === "review"
|
|
521
528
|
&& event.data.replayMeasurement !== true
|
|
522
|
-
&& REVIEW_VERDICTS.has(normalizeGateOutcome(event.data
|
|
529
|
+
&& REVIEW_VERDICTS.has(normalizeGateOutcome(event.data).kind))
|
|
523
530
|
task.reviews += 1;
|
|
524
531
|
else if (event.event === "task-human")
|
|
525
532
|
task.parks += 1;
|
|
@@ -979,6 +986,21 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
979
986
|
if (!taskIds.has(taskId))
|
|
980
987
|
phases.delete(taskId);
|
|
981
988
|
const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
|
|
989
|
+
// OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
|
|
990
|
+
// older journal equally readable by asking upheldFeedbackByTask what was active at that exact
|
|
991
|
+
// restore boundary; current resume-restore rows additionally carry the same task for narration.
|
|
992
|
+
const preservedRefs = preservedRefsByTask(events);
|
|
993
|
+
const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
|
|
994
|
+
&& upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
|
|
995
|
+
? [event.taskId]
|
|
996
|
+
: []));
|
|
997
|
+
const recoveryLinesForTask = (taskId) => [
|
|
998
|
+
...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
|
|
999
|
+
`preserved worktree — ${taskId} — ${ref}`,
|
|
1000
|
+
` ${diffCommand}`,
|
|
1001
|
+
]),
|
|
1002
|
+
...(restoredUpheldFeedback.has(taskId) ? [`upheld feedback restored for ${taskId}`] : []),
|
|
1003
|
+
].map(sanitizeTaskText);
|
|
982
1004
|
const cells = renderedTasks.map((t) => {
|
|
983
1005
|
const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
|
|
984
1006
|
const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
|
|
@@ -1011,7 +1033,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1011
1033
|
if (!unicode) {
|
|
1012
1034
|
// machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
|
|
1013
1035
|
// use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
|
|
1014
|
-
const rows = cells.
|
|
1036
|
+
const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
|
|
1015
1037
|
const chain = gateChain(states, false);
|
|
1016
1038
|
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
|
|
1017
1039
|
const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
|
|
@@ -1020,7 +1042,10 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1020
1042
|
// can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
|
|
1021
1043
|
// 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
|
|
1022
1044
|
// (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
|
|
1023
|
-
return
|
|
1045
|
+
return [
|
|
1046
|
+
`${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`,
|
|
1047
|
+
...recoveryLinesForTask(t.id).map((line) => ` ${line}`),
|
|
1048
|
+
];
|
|
1024
1049
|
});
|
|
1025
1050
|
const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
|
|
1026
1051
|
const header = runId
|
|
@@ -1210,6 +1235,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1210
1235
|
return [legend(headerRow), dim(ruleRow), ...rows];
|
|
1211
1236
|
};
|
|
1212
1237
|
const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
|
|
1238
|
+
const recoveryLines = cells.flatMap((cell) => recoveryLinesForTask(cell.t.id));
|
|
1239
|
+
const recoverySection = recoveryLines.length === 0 ? [] : [
|
|
1240
|
+
"",
|
|
1241
|
+
` ${ok("▌")} ${title("RECOVERY")}`,
|
|
1242
|
+
...recoveryLines.map((line) => legend(` ${line}`)),
|
|
1243
|
+
];
|
|
1213
1244
|
const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
|
|
1214
1245
|
return {
|
|
1215
1246
|
content: boardRows([
|
|
@@ -1220,6 +1251,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1220
1251
|
taskSection,
|
|
1221
1252
|
"",
|
|
1222
1253
|
...tableRows(),
|
|
1254
|
+
...recoverySection,
|
|
1223
1255
|
gatesLegend,
|
|
1224
1256
|
"",
|
|
1225
1257
|
rule(boardColumns),
|
|
@@ -1383,7 +1415,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1383
1415
|
// it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
|
|
1384
1416
|
// a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
|
|
1385
1417
|
// acts on, and the one this instrument exists to close.
|
|
1386
|
-
const armed = bounded ? undefined :
|
|
1418
|
+
const armed = bounded ? undefined : armWatchSupervision(cwd, opts.supervisionBeatMs);
|
|
1387
1419
|
let titleSaved = false;
|
|
1388
1420
|
const restoreTitle = () => {
|
|
1389
1421
|
if (!titleSaved)
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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)
|
package/dist/compile/index.d.ts
CHANGED
|
@@ -10,6 +10,6 @@ export type PlanIR = {
|
|
|
10
10
|
tasks: RunGraph["tasks"];
|
|
11
11
|
};
|
|
12
12
|
export type PlanFinalizationHook = (plan: PlanIR) => PlanIR;
|
|
13
|
-
export declare function finalizePlan(plan: PlanIR, src: string): RunGraph;
|
|
13
|
+
export declare function finalizePlan(plan: PlanIR, src: string, repoRoot?: string): RunGraph;
|
|
14
14
|
export declare function compileSource(src: string, type?: SourceType, root?: string, beforeFinalize?: PlanFinalizationHook): RunGraph;
|
|
15
15
|
export {};
|
package/dist/compile/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { validateGraph } from "../graph/schema.js";
|
|
4
4
|
import { taskUnitContractErrors } from "./collateral.js";
|
|
5
|
+
import { ownershipFindings, renderOwnershipFinding } from "./ownership.js";
|
|
5
6
|
import { CompileError } from "./common.js";
|
|
6
7
|
import { compileGsd, isGsdPhaseDir } from "./gsd.js";
|
|
7
8
|
import { compileNative, TICKMARKR_NATIVE_MARKER } from "./native.js";
|
|
@@ -40,8 +41,8 @@ function enforceTaskUnitContract(g, src) {
|
|
|
40
41
|
}
|
|
41
42
|
return g;
|
|
42
43
|
}
|
|
43
|
-
export function finalizePlan(plan, src) {
|
|
44
|
-
const graph = validateGraph({
|
|
44
|
+
export function finalizePlan(plan, src, repoRoot) {
|
|
45
|
+
const graph = enforceTaskUnitContract(validateGraph({
|
|
45
46
|
version: plan.version,
|
|
46
47
|
...(plan.mode !== undefined ? { mode: plan.mode } : {}),
|
|
47
48
|
spec: {
|
|
@@ -51,8 +52,16 @@ export function finalizePlan(plan, src) {
|
|
|
51
52
|
...(plan.base !== undefined ? { base: plan.base } : {}),
|
|
52
53
|
},
|
|
53
54
|
tasks: plan.tasks,
|
|
54
|
-
});
|
|
55
|
-
|
|
55
|
+
}), src);
|
|
56
|
+
// overseer-217: report-only in 2.1.7. The 2.1.8 removal condition is one real authored-graph run
|
|
57
|
+
// plus a measured false-positive rate for the conventional source-name → test-name mapping.
|
|
58
|
+
// Until then this warning MUST NOT become a compile abort: a heuristic cannot deadlock compile.
|
|
59
|
+
if (repoRoot) {
|
|
60
|
+
for (const finding of ownershipFindings(graph.tasks, repoRoot)) {
|
|
61
|
+
console.warn(renderOwnershipFinding(finding));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return graph;
|
|
56
65
|
}
|
|
57
66
|
function compilePlan(src, type, root) {
|
|
58
67
|
const kind = type ?? detect(src);
|
|
@@ -75,5 +84,5 @@ function compilePlan(src, type, root) {
|
|
|
75
84
|
};
|
|
76
85
|
}
|
|
77
86
|
export function compileSource(src, type, root, beforeFinalize = (plan) => plan) {
|
|
78
|
-
return finalizePlan(beforeFinalize(compilePlan(src, type, root)), src);
|
|
87
|
+
return finalizePlan(beforeFinalize(compilePlan(src, type, root)), src, root);
|
|
79
88
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Task } from "../graph/schema.js";
|
|
2
|
+
export type OwnershipFinding = {
|
|
3
|
+
code: "unowned-test";
|
|
4
|
+
test: string;
|
|
5
|
+
taskIds: string[];
|
|
6
|
+
detail: string;
|
|
7
|
+
} | {
|
|
8
|
+
code: "test-path-outside-allowlist";
|
|
9
|
+
taskId: string;
|
|
10
|
+
test: string;
|
|
11
|
+
path: string;
|
|
12
|
+
detail: string;
|
|
13
|
+
} | {
|
|
14
|
+
code: "unordered-context-write";
|
|
15
|
+
taskId: string;
|
|
16
|
+
ownerTaskId: string;
|
|
17
|
+
path: string;
|
|
18
|
+
detail: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Advisory cross-task ownership check. Findings are data: callers may report them, but this checker
|
|
22
|
+
* never throws and never changes the graph.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ownershipFindings(tasks: readonly Task[], repoRoot: string): OwnershipFinding[];
|
|
25
|
+
export declare function renderOwnershipFinding(finding: OwnershipFinding): string;
|