@staix/agent-hub 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +6 -4
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/tasks.md +7 -4
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +10 -7
- package/docs/operations.md +45 -13
- package/docs/quickstart.md +6 -2
- package/docs/sdlc.md +23 -2
- package/docs/security.md +9 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verified.json +20 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +91 -20
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/help.ts +2 -1
- package/src/cli/main.ts +5 -0
- package/src/cli/output.ts +47 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/board.ts +22 -1
- package/src/hub/daemon.ts +84 -14
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/instances.ts +14 -4
- package/src/hub/manager.ts +2 -2
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +172 -22
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +58 -0
- package/src/ui/index.html +273 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// #481 S7: SDLC metrics and deterministic control bands. Pure: board history in, numbers out. No I/O, no clock, no model.
|
|
2
|
+
// A metric that cannot be derived is `unknown` and counted as such, never 0.
|
|
3
|
+
// `first_pass_check` reads the hub's own completion check (`check passed` / `check failed` at hub_task_done), not PR CI: it is unknown where completion checks are off.
|
|
4
|
+
|
|
5
|
+
export const BAND_METRICS = ["acceptance_to_plan_ms", "acceptance_to_first_review_ms", "first_pass_approval", "rework_rounds", "first_pass_check", "stale_head_re_reviews"] as const;
|
|
6
|
+
export type BandMetric = typeof BAND_METRICS[number];
|
|
7
|
+
export const BAND_TIERS = ["ok", "log", "diagnose", "propose"] as const;
|
|
8
|
+
export type BandTier = typeof BAND_TIERS[number];
|
|
9
|
+
|
|
10
|
+
/** The slice of a board task the metrics read. History entries carry ids, times and event names only. */
|
|
11
|
+
export interface MetricTask {
|
|
12
|
+
id: number;
|
|
13
|
+
private?: boolean;
|
|
14
|
+
/** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
+
history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Per-task values; `null` is unknown. */
|
|
19
|
+
export interface TaskSample {
|
|
20
|
+
id: number;
|
|
21
|
+
/** When the sample counts: its first verdict, else its acceptance, else its first event. */
|
|
22
|
+
at: number;
|
|
23
|
+
/** When the task was approved, else null: `rework_rounds` and `stale_head_re_reviews` are final only then, and sit at this time in their series. */
|
|
24
|
+
approved_at: number | null;
|
|
25
|
+
acceptance_to_plan_ms: number | null;
|
|
26
|
+
acceptance_to_first_review_ms: number | null;
|
|
27
|
+
acceptance_to_merge_ms: number | null;
|
|
28
|
+
first_pass_approval: 0 | 1 | null;
|
|
29
|
+
rework_rounds: number | null;
|
|
30
|
+
first_pass_check: 0 | 1 | null;
|
|
31
|
+
same_head_ci_reruns: number | null;
|
|
32
|
+
stale_head_re_reviews: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Summary { n: number; unknown: number; mean: number | null; median: number | null; p90: number | null; max: number | null }
|
|
36
|
+
export interface StallCounts { open: number; byKind: Record<string, number>; doneWithoutWrites: number }
|
|
37
|
+
export interface BandConfig { window: number; minSamples: number; logSigma: number; diagnoseSigma: number; proposeSigma: number; driftRun: number; minDelta: number }
|
|
38
|
+
export type BandsConfig = Partial<Record<BandMetric, BandConfig>>;
|
|
39
|
+
export interface BandResult {
|
|
40
|
+
metric: BandMetric;
|
|
41
|
+
tier: BandTier;
|
|
42
|
+
/** Why the tier is what it is, in fixed words and numbers only. */
|
|
43
|
+
reason: string;
|
|
44
|
+
baseline: number;
|
|
45
|
+
mean: number | null;
|
|
46
|
+
sd: number | null;
|
|
47
|
+
last: number | null;
|
|
48
|
+
z: number | null;
|
|
49
|
+
/** The task that produced the last value: the breach's evidence. */
|
|
50
|
+
lastTask: number | null;
|
|
51
|
+
/** The task whose value started the run of non-ok values this one belongs to, else null: one breach episode has one card. */
|
|
52
|
+
episode: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Which way is worse. A rate that falls and a time that grows are both breaches. */
|
|
56
|
+
export const BAD_DIRECTION: Record<BandMetric, "up" | "down"> = {
|
|
57
|
+
acceptance_to_plan_ms: "up", acceptance_to_first_review_ms: "up", first_pass_approval: "down", rework_rounds: "up", first_pass_check: "down", stale_head_re_reviews: "up",
|
|
58
|
+
};
|
|
59
|
+
const HOUR = 3_600_000;
|
|
60
|
+
/** Per-metric floor on the absolute deviation, so a flat baseline does not make every wobble a breach. */
|
|
61
|
+
export const DEFAULT_MIN_DELTA: Record<BandMetric, number> = {
|
|
62
|
+
acceptance_to_plan_ms: HOUR, acceptance_to_first_review_ms: HOUR, first_pass_approval: 0.5, rework_rounds: 1, first_pass_check: 0.5, stale_head_re_reviews: 1,
|
|
63
|
+
};
|
|
64
|
+
/** Per-metric floor on the baseline deviation: a flat baseline (every value the same) must not turn one step into thousands of deviations. */
|
|
65
|
+
export const SD_FLOOR: Record<BandMetric, number> = {
|
|
66
|
+
acceptance_to_plan_ms: HOUR / 2, acceptance_to_first_review_ms: HOUR / 2, first_pass_approval: 0.5, rework_rounds: 0.5, first_pass_check: 0.5, stale_head_re_reviews: 0.5,
|
|
67
|
+
};
|
|
68
|
+
export const DEFAULT_BAND: Omit<BandConfig, "minDelta"> = { window: 20, minSamples: 8, logSigma: 2, diagnoseSigma: 3, proposeSigma: 4, driftRun: 6 };
|
|
69
|
+
|
|
70
|
+
const first = (task: MetricTask, events: readonly string[], after = -Infinity) => task.history.find(entry => entry.at >= after && events.includes(entry.event));
|
|
71
|
+
const VERDICTS = ["approved", "changes_requested"];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Review rounds whose verdict was bound to a different head than the review was requested for: the head moved while the review ran.
|
|
75
|
+
* Known only for a round with both heads recorded; a task with no such round is unknown, never 0. (A `done` after an `approved` is not
|
|
76
|
+
* evidence: the board cannot produce one.)
|
|
77
|
+
* ponytail: the request head is `reviewCost.head` (the commit the done named first) while a verdict's head prefers the lifecycle receipt's
|
|
78
|
+
* `head`/`localHead` over `refs.commit` (`findingHead`), so a receipt head that differs from the commit can read as stale. Ceiling: a
|
|
79
|
+
* spurious stale round for a task whose receipt head and commit disagree; the upgrade is one shared head rule for both sides.
|
|
80
|
+
*/
|
|
81
|
+
function staleHeadRounds(history: MetricTask["history"]): number | null {
|
|
82
|
+
let pending: string | undefined, known = false, stale = 0;
|
|
83
|
+
for (const entry of history) {
|
|
84
|
+
if (entry.event === "done") pending = entry.requestHead;
|
|
85
|
+
else if (VERDICTS.includes(entry.event)) {
|
|
86
|
+
if (pending !== undefined && entry.verdictHead !== undefined) { known = true; if (entry.verdictHead !== pending) stale++; }
|
|
87
|
+
pending = undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return known ? stale : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One task's values from its own history. A private task never reaches here. */
|
|
94
|
+
export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciReruns?: number | null } = {}): TaskSample {
|
|
95
|
+
const history = [...task.history].filter(entry => Number.isFinite(entry.at) && entry.at >= 0).sort((a, b) => a.at - b.at);
|
|
96
|
+
const view: MetricTask = { id: task.id, history };
|
|
97
|
+
const accepted = first(view, ["accepted"]);
|
|
98
|
+
const spec = first(view, ["spec accepted"]);
|
|
99
|
+
const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
|
|
100
|
+
const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
|
|
101
|
+
const dones = history.filter(entry => entry.event === "done");
|
|
102
|
+
const ci = first(view, ["check passed", "check failed"]);
|
|
103
|
+
const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
|
|
104
|
+
return {
|
|
105
|
+
id: task.id,
|
|
106
|
+
at: verdict?.at ?? accepted?.at ?? history[0]?.at ?? 0,
|
|
107
|
+
approved_at: approvedAt,
|
|
108
|
+
acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
|
|
109
|
+
acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
|
|
110
|
+
acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
|
|
111
|
+
first_pass_approval: verdict ? (verdict.event === "approved" ? 1 : 0) : null,
|
|
112
|
+
// Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
|
|
113
|
+
// provisional point would move at each verdict and could start a second breach episode inside one shift.
|
|
114
|
+
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
|
|
115
|
+
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
116
|
+
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
117
|
+
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const percentile = (sorted: number[], p: number) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]!;
|
|
122
|
+
export function summarize(values: readonly (number | null)[]): Summary {
|
|
123
|
+
const known = values.filter((value): value is number => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
|
|
124
|
+
const unknown = values.length - known.length;
|
|
125
|
+
if (!known.length) return { n: 0, unknown, mean: null, median: null, p90: null, max: null };
|
|
126
|
+
const mean = known.reduce((sum, value) => sum + value, 0) / known.length;
|
|
127
|
+
return { n: known.length, unknown, mean: round(mean), median: round(percentile(known, 0.5)), p90: round(percentile(known, 0.9)), max: known.at(-1)! };
|
|
128
|
+
}
|
|
129
|
+
const round = (value: number) => Math.round(value * 1000) / 1000;
|
|
130
|
+
|
|
131
|
+
/** The values of one metric in the order they happened, unknown ones left out. */
|
|
132
|
+
export function series(samples: readonly TaskSample[], metric: BandMetric): { value: number; task: number }[] {
|
|
133
|
+
const final = metric === "rework_rounds" || metric === "stale_head_re_reviews";
|
|
134
|
+
const when = (sample: TaskSample) => final ? sample.approved_at ?? sample.at : sample.at;
|
|
135
|
+
return [...samples].sort((a, b) => when(a) - when(b) || a.id - b.id).flatMap(sample => sample[metric] === null ? [] : [{ value: sample[metric] as number, task: sample.id }]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The newest value against the `window` values before it: mean, population standard deviation, and a tier from how many
|
|
140
|
+
* deviations the newest value sits on the bad side. A run of `driftRun` newest values all more than one deviation on the bad
|
|
141
|
+
* side is a drift and is at least `diagnose`. With fewer than `minSamples` before it, nothing fires.
|
|
142
|
+
*/
|
|
143
|
+
export function controlBands(points: readonly { value: number; task: number }[], metric: BandMetric, config: BandConfig): BandResult {
|
|
144
|
+
const baselineCount = Math.max(0, Math.min(config.window, points.length - 1));
|
|
145
|
+
const none = (reason: string, extra: Partial<BandResult> = {}): BandResult => ({ metric, tier: "ok", reason, baseline: baselineCount, mean: null, sd: null, last: points.at(-1)?.value ?? null, z: null, lastTask: points.at(-1)?.task ?? null, episode: null, ...extra });
|
|
146
|
+
if (points.length < 2 || baselineCount < config.minSamples) return none(`baseline of ${baselineCount} is below the ${config.minSamples} needed`);
|
|
147
|
+
const last = points.at(-1)!, base = points.slice(-1 - baselineCount, -1).map(point => point.value);
|
|
148
|
+
const mean = base.reduce((sum, value) => sum + value, 0) / base.length;
|
|
149
|
+
const sd = Math.sqrt(base.reduce((sum, value) => sum + (value - mean) ** 2, 0) / base.length);
|
|
150
|
+
const sign = BAD_DIRECTION[metric] === "up" ? 1 : -1;
|
|
151
|
+
const deviation = sign * (last.value - mean);
|
|
152
|
+
const z = deviation <= 0 ? 0 : deviation / Math.max(sd, SD_FLOOR[metric]);
|
|
153
|
+
const result = { metric, baseline: base.length, mean: round(mean), sd: round(sd), last: last.value, z: round(Math.min(z, 1e6)), lastTask: last.task, episode: null as number | null };
|
|
154
|
+
const drifting = deviation >= config.minDelta && deviation > 0 && points.length - 1 >= config.driftRun && points.slice(-config.driftRun).every(point => sign * (point.value - mean) > sd);
|
|
155
|
+
let tier: BandTier = "ok", reason = `${round(Math.min(z, 1e6))} deviations on the bad side`;
|
|
156
|
+
if (deviation >= config.minDelta && deviation > 0) {
|
|
157
|
+
if (z >= config.proposeSigma) { tier = "propose"; reason = `${round(Math.min(z, 1e6))} deviations beyond the baseline (propose at ${config.proposeSigma})`; }
|
|
158
|
+
else if (z >= config.diagnoseSigma) { tier = "diagnose"; reason = `${round(z)} deviations beyond the baseline (diagnose at ${config.diagnoseSigma})`; }
|
|
159
|
+
else if (z >= config.logSigma) { tier = "log"; reason = `${round(z)} deviations beyond the baseline (log at ${config.logSigma})`; }
|
|
160
|
+
}
|
|
161
|
+
if (drifting && BAND_TIERS.indexOf(tier) < BAND_TIERS.indexOf("diagnose")) { tier = "diagnose"; reason = `the last ${config.driftRun} values are all more than one deviation on the bad side`; }
|
|
162
|
+
return { ...result, tier, reason };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** How far back an episode is searched. ponytail: a run longer than this restarts its key every sample, so the open-card rule in Tasks holds it to one card. */
|
|
166
|
+
export const EPISODE_LOOKBACK = 200;
|
|
167
|
+
/**
|
|
168
|
+
* `controlBands` for the newest value, plus the breach episode it belongs to: the trailing run of values whose own tier (computed from
|
|
169
|
+
* the values before each, with nothing after) was not `ok`. The episode is the task that started the run, so a sustained shift keeps one
|
|
170
|
+
* key for as long as it lasts, and it is derived from history alone, so it survives a restart.
|
|
171
|
+
*/
|
|
172
|
+
export function controlEpisode(points: readonly { value: number; task: number }[], metric: BandMetric, config: BandConfig): BandResult {
|
|
173
|
+
const now = controlBands(points, metric, config);
|
|
174
|
+
if (now.tier === "ok") return now;
|
|
175
|
+
let start = points.length - 1;
|
|
176
|
+
for (let end = points.length - 2; end >= 0 && points.length - 1 - end <= EPISODE_LOOKBACK; end--) {
|
|
177
|
+
if (controlBands(points.slice(0, end + 1), metric, config).tier === "ok") break;
|
|
178
|
+
start = end;
|
|
179
|
+
}
|
|
180
|
+
return { ...now, episode: points[start]!.task };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface MetricsReport {
|
|
184
|
+
since: number | null;
|
|
185
|
+
tasks: number;
|
|
186
|
+
withheldPrivate: number;
|
|
187
|
+
metrics: {
|
|
188
|
+
acceptance_to_plan_ms: Summary; acceptance_to_first_review_ms: Summary; acceptance_to_merge_ms: Summary;
|
|
189
|
+
first_pass_approval: { n: number; unknown: number; rate: number | null };
|
|
190
|
+
rework_rounds: Summary;
|
|
191
|
+
first_pass_check: { n: number; unknown: number; rate: number | null };
|
|
192
|
+
same_head_ci_reruns: Summary;
|
|
193
|
+
stale_head_re_reviews: Summary;
|
|
194
|
+
};
|
|
195
|
+
stalls: StallCounts;
|
|
196
|
+
bands: BandResult[] | "off";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rate = (values: readonly (number | null)[]) => {
|
|
200
|
+
const known = values.filter((value): value is number => value !== null);
|
|
201
|
+
return { n: known.length, unknown: values.length - known.length, rate: known.length ? round(known.reduce((sum, value) => sum + value, 0) / known.length) : null };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The whole report from board tasks. `since` keeps samples that count at or after it; `mergedAt` and `ciReruns` come from bounded
|
|
206
|
+
* PR readbacks keyed by task id and are optional. Private tasks are counted as withheld and read for nothing else.
|
|
207
|
+
*/
|
|
208
|
+
export function sdlcMetrics(tasks: readonly MetricTask[], opts: { since?: number; bands?: BandsConfig; stalls?: StallCounts; mergedAt?: Readonly<Record<number, number>>; ciReruns?: Readonly<Record<number, number | null>> } = {}): MetricsReport {
|
|
209
|
+
const visible = tasks.filter(task => !task.private);
|
|
210
|
+
const all = visible.map(task => taskSample(task, { mergedAt: opts.mergedAt?.[task.id], ciReruns: opts.ciReruns?.[task.id] }));
|
|
211
|
+
const samples = opts.since === undefined ? all : all.filter(sample => sample.at >= opts.since!);
|
|
212
|
+
const bands: BandResult[] | "off" = opts.bands && Object.keys(opts.bands).length
|
|
213
|
+
? BAND_METRICS.flatMap(metric => opts.bands![metric] ? [controlEpisode(series(samples, metric), metric, opts.bands![metric]!)] : [])
|
|
214
|
+
: "off";
|
|
215
|
+
return {
|
|
216
|
+
since: opts.since ?? null, tasks: samples.length, withheldPrivate: tasks.length - visible.length,
|
|
217
|
+
metrics: {
|
|
218
|
+
acceptance_to_plan_ms: summarize(samples.map(s => s.acceptance_to_plan_ms)), acceptance_to_first_review_ms: summarize(samples.map(s => s.acceptance_to_first_review_ms)),
|
|
219
|
+
acceptance_to_merge_ms: summarize(samples.map(s => s.acceptance_to_merge_ms)),
|
|
220
|
+
first_pass_approval: rate(samples.map(s => s.first_pass_approval)), rework_rounds: summarize(samples.map(s => s.rework_rounds)),
|
|
221
|
+
first_pass_check: rate(samples.map(s => s.first_pass_check)), same_head_ci_reruns: summarize(samples.map(s => s.same_head_ci_reruns)),
|
|
222
|
+
stale_head_re_reviews: summarize(samples.map(s => s.stale_head_re_reviews)),
|
|
223
|
+
},
|
|
224
|
+
stalls: opts.stalls ?? { open: 0, byKind: {}, doneWithoutWrites: 0 }, bands,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const BAND_KEYS = ["window", "min_samples", "log_sigma", "diagnose_sigma", "propose_sigma", "drift_run", "min_delta"] as const;
|
|
229
|
+
/**
|
|
230
|
+
* `[bands.<metric>]` tables of sdlc.toml. Every field is optional and bounded; an unknown metric or field is refused with its
|
|
231
|
+
* path, and the tiers must be ordered log <= diagnose <= propose so a breach never skips a tier it is meant to pass through.
|
|
232
|
+
*/
|
|
233
|
+
export function bandsConfig(value: unknown): BandsConfig {
|
|
234
|
+
if (value === undefined) return {};
|
|
235
|
+
const fail = (path: string, why: string): never => { throw new Error(`sdlc.bands${path}: ${why}`); };
|
|
236
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail("", "expected a table");
|
|
237
|
+
const out: BandsConfig = {};
|
|
238
|
+
for (const [metric, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
239
|
+
if (!(BAND_METRICS as readonly string[]).includes(metric)) fail(`.${metric}`, `unknown metric (use ${BAND_METRICS.join(", ")})`);
|
|
240
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) fail(`.${metric}`, "expected a table");
|
|
241
|
+
const row = raw as Record<string, unknown>;
|
|
242
|
+
for (const key of Object.keys(row)) if (!(BAND_KEYS as readonly string[]).includes(key)) fail(`.${metric}.${key}`, "unknown field");
|
|
243
|
+
const number = (key: typeof BAND_KEYS[number], fallback: number, min: number, max: number, integer: boolean) => {
|
|
244
|
+
const given = row[key] ?? fallback;
|
|
245
|
+
if (typeof given !== "number" || !Number.isFinite(given) || given < min || given > max || integer && !Number.isSafeInteger(given)) fail(`.${metric}.${key}`, `expected ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
|
|
246
|
+
return given as number;
|
|
247
|
+
};
|
|
248
|
+
const m = metric as BandMetric;
|
|
249
|
+
const config: BandConfig = {
|
|
250
|
+
window: number("window", DEFAULT_BAND.window, 4, 200, true), minSamples: number("min_samples", DEFAULT_BAND.minSamples, 3, 200, true),
|
|
251
|
+
logSigma: number("log_sigma", DEFAULT_BAND.logSigma, 0.5, 20, false), diagnoseSigma: number("diagnose_sigma", DEFAULT_BAND.diagnoseSigma, 0.5, 20, false),
|
|
252
|
+
proposeSigma: number("propose_sigma", DEFAULT_BAND.proposeSigma, 0.5, 20, false), driftRun: number("drift_run", DEFAULT_BAND.driftRun, 3, 50, true),
|
|
253
|
+
minDelta: number("min_delta", DEFAULT_MIN_DELTA[m], 0, 1e12, false),
|
|
254
|
+
};
|
|
255
|
+
if (config.minSamples > config.window) fail(`.${metric}.min_samples`, "must not exceed window");
|
|
256
|
+
if (!(config.logSigma <= config.diagnoseSigma && config.diagnoseSigma <= config.proposeSigma)) fail(`.${metric}`, "tiers must be ordered log_sigma <= diagnose_sigma <= propose_sigma");
|
|
257
|
+
out[m] = config;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstRe
|
|
|
2
2
|
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
3
3
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
5
|
+
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
5
6
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
6
7
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
@@ -32,7 +33,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
32
33
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
33
34
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
34
35
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
35
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
36
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
36
37
|
export interface PlanReviewReceipt {
|
|
37
38
|
planner: string;
|
|
38
39
|
planningRevision: number;
|
|
@@ -133,10 +134,11 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
133
134
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
134
135
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
135
136
|
const raw = value as Record<string, unknown>;
|
|
136
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
138
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
138
139
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
139
|
-
const
|
|
140
|
+
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
141
|
+
const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig };
|
|
140
142
|
const mode = raw.mode ?? "off";
|
|
141
143
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
142
144
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
package/src/hub/spec-read.ts
CHANGED
|
@@ -48,6 +48,37 @@ export function specRefs(task: Pick<Task, "title" | "detail" | "refs"> & Partial
|
|
|
48
48
|
return [...issues];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* #723: issue numbers a title names as a sibling repository (`agent-mem#13`) when the task's `refs.repo` is that repository.
|
|
53
|
+
* The bare name must equal the repository part of `refs.repo`; nothing else is guessed, and nothing is read. A bare
|
|
54
|
+
* `#N` and an `owner/repo#N` form are specRefs' business, never this function's.
|
|
55
|
+
*/
|
|
56
|
+
export function siblingTitleRefs(title: string, repo: string | undefined): number[] {
|
|
57
|
+
if (!repo || !isGitHubRepository(repo)) return [];
|
|
58
|
+
const name = repo.split("/")[1]!.toLowerCase(), found = new Set<number>();
|
|
59
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])([A-Za-z0-9_.-]+)#([1-9][0-9]*)\b/g)) {
|
|
60
|
+
if (match[1]!.toLowerCase() === name) found.add(Number(match[2]));
|
|
61
|
+
}
|
|
62
|
+
return [...found].slice(0, 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Words that precede `#N` without naming a repository (`PR#12`); never worth a refs.repo suggestion. */
|
|
66
|
+
const NOT_A_REPOSITORY = new Set(["pr", "pull", "issue", "issues", "task", "tasks", "bug", "fix", "phase", "step", "item", "ticket", "gh", "id", "no", "nr", "p", "s", "t", "u"]);
|
|
67
|
+
/**
|
|
68
|
+
* #723: the qualified repository names a title carries (`owner/repo#N` as `owner/repo`, `agent-mem#13` as `agent-mem`), so
|
|
69
|
+
* propose can suggest `refs.repo`. Advice only: a hit is a hint, never an identity.
|
|
70
|
+
*/
|
|
71
|
+
export function qualifiedTitleRepos(title: string): string[] {
|
|
72
|
+
const found = new Set<string>();
|
|
73
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)|([A-Za-z0-9_.-]*[A-Za-z][A-Za-z0-9_.-]*))#[1-9][0-9]*\b/g)) {
|
|
74
|
+
const name = match[1] ?? match[2]!;
|
|
75
|
+
if (match[1] ? !isGitHubRepository(name) : NOT_A_REPOSITORY.has(name.toLowerCase())) continue;
|
|
76
|
+
found.add(name);
|
|
77
|
+
if (found.size >= 4) break;
|
|
78
|
+
}
|
|
79
|
+
return [...found];
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
interface GhResult { code: number; stdout: string; }
|
|
52
83
|
/** One fixed-argv authenticated boundary, no shell, no retries and no retained stderr. */
|
|
53
84
|
function gh(args: string[], opts: SpecOptions): Promise<GhResult> {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
2
|
+
|
|
3
|
+
// #684 2c: the dashboard task sheet's "Code" group, classified on the hub so the page never matches names itself. Pure data in, ids and
|
|
4
|
+
// names out. The observation (`codeSymbols`) is the receipt `Tasks.publicView` already re-screened; the plan names are `plan.symbols`, the same
|
|
5
|
+
// list the review brief reads as `planned`. Advisory only: ripwire does not report deleted symbols, so neither list proves a plan was followed.
|
|
6
|
+
|
|
7
|
+
export interface CodeGroupSymbol { id: string; name: string; path: string; kind: string; mark: "matching" | "unplanned" }
|
|
8
|
+
export interface CodeGroup {
|
|
9
|
+
state: "known" | "unknown";
|
|
10
|
+
complete: boolean;
|
|
11
|
+
head?: string;
|
|
12
|
+
symbols: CodeGroupSymbol[];
|
|
13
|
+
/** Plan names nothing changed under. */
|
|
14
|
+
untouched: string[];
|
|
15
|
+
/** How many more changed symbols exist than are listed, so a cap is never read as the whole. */
|
|
16
|
+
more: number;
|
|
17
|
+
/** Totals before the cap, so the sheet's counts are the whole and not the listed part. */
|
|
18
|
+
counts: { matching: number; unplanned: number; untouched: number };
|
|
19
|
+
/** Plan names the hub would not show (screened out or malformed). While any is withheld a symbol marked unplanned may be planned, and one marked untouched may not be. */
|
|
20
|
+
planWithheld: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CODE_GROUP_MAX = 40;
|
|
24
|
+
/** The stored plan's own bounds (`cleanPlan`: 300 characters a name, 50 names), so no stored name is dropped for its length alone. */
|
|
25
|
+
export const PLAN_NAME_MAX = 300, PLAN_NAMES_MAX = 50;
|
|
26
|
+
interface Receipt { state?: unknown; complete?: unknown; head?: unknown; symbols?: unknown }
|
|
27
|
+
const word = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `receipt` is the public `codeSymbols` (never the stored one, which carries `repositoryKey`); `plan` the task's `plan.symbols`; `screen`
|
|
31
|
+
* the hub's `publicMetadataText`. Returns undefined for a private task: the sheet then shows no Code group at all.
|
|
32
|
+
*/
|
|
33
|
+
export function codeGroup(task: { id: number }, receipt: unknown, plan: unknown, screen: (text: string) => boolean, privateTask: boolean): CodeGroup | undefined {
|
|
34
|
+
if (privateTask) return undefined;
|
|
35
|
+
const r = (receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt : undefined) as Receipt | undefined;
|
|
36
|
+
if (!r || r.state !== "known" || !Array.isArray(r.symbols) || Object.hasOwn(r, "repositoryKey")) return { state: "unknown", complete: false, symbols: [], untouched: [], more: 0, counts: { matching: 0, unplanned: 0, untouched: 0 }, planWithheld: 0 };
|
|
37
|
+
// Every stored plan name is read; one that is malformed or fails the screen is counted, never silently dropped, because dropping it would
|
|
38
|
+
// turn a symbol the plan names into an "unplanned" one and the sheet would show a departure that never happened.
|
|
39
|
+
const given = Array.isArray(plan) ? plan.slice(0, PLAN_NAMES_MAX + 1) : [];
|
|
40
|
+
const planned = given.slice(0, PLAN_NAMES_MAX).filter((name): name is string => word(name, PLAN_NAME_MAX)).filter(name => { try { return screen(name); } catch { return false; } });
|
|
41
|
+
const planWithheld = (given.length > PLAN_NAMES_MAX ? given.length - PLAN_NAMES_MAX : 0) + (Math.min(given.length, PLAN_NAMES_MAX) - planned.length);
|
|
42
|
+
const changed: CodeGroupSymbol[] = [];
|
|
43
|
+
for (const item of r.symbols as Record<string, unknown>[]) {
|
|
44
|
+
if (!item || !word(item.id, 512) || !word(item.name, 512) || !word(item.path, 512) || !word(item.kind, 64) || item.id !== `symbol:${item.name}@${item.path}`) continue;
|
|
45
|
+
changed.push({ id: item.id, name: item.name, path: item.path, kind: item.kind, mark: planned.some(name => symbolsOverlap(name, item.name as string)) ? "matching" : "unplanned" });
|
|
46
|
+
}
|
|
47
|
+
const untouched = planned.filter(name => !changed.some(symbol => symbolsOverlap(name, symbol.name)));
|
|
48
|
+
const shown = changed.slice(0, CODE_GROUP_MAX);
|
|
49
|
+
return { state: "known", complete: r.complete === true && changed.length === r.symbols.length && planWithheld === 0, ...(typeof r.head === "string" && /^[a-f0-9]{40}$/.test(r.head) ? { head: r.head.slice(0, 8) } : {}),
|
|
50
|
+
symbols: shown, untouched: untouched.slice(0, CODE_GROUP_MAX), more: Math.max(0, changed.length - shown.length) + Math.max(0, untouched.length - CODE_GROUP_MAX),
|
|
51
|
+
counts: { matching: changed.filter(s => s.mark === "matching").length, unplanned: changed.filter(s => s.mark === "unplanned").length, untouched: untouched.length }, planWithheld };
|
|
52
|
+
}
|