@staix/agent-hub 0.12.45 → 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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +319 -34
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/instances.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import { canonicalPath, realPath } from "./project.ts";
|
|
2
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
4
7
|
|
|
@@ -10,7 +13,17 @@ export interface InstanceConfig {
|
|
|
10
13
|
review_mode?: "fresh";
|
|
11
14
|
worktree?: string;
|
|
12
15
|
readOnly?: boolean;
|
|
16
|
+
/** Jcode instances only (#685): their own native data home, and optional model selection. Machine-local, never editable from settings. */
|
|
17
|
+
jcode_home?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
provider_profile?: string;
|
|
13
21
|
}
|
|
22
|
+
const JCODE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
|
|
23
|
+
/** At most this many extra Jcode instances per project (#685). */
|
|
24
|
+
export const MAX_JCODE_INSTANCES = 8;
|
|
25
|
+
/** A flag value jcode takes as one argv word: bounded, printable, no spaces. */
|
|
26
|
+
const NATIVE_WORD = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
14
27
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
15
28
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
16
29
|
const MAX_INSTANCES = 64;
|
|
@@ -41,6 +54,58 @@ function roles(value: unknown, field: string): string[] {
|
|
|
41
54
|
return out;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/** The Jcode-only fields of one declaration (#685). Only the validated values are copied; nothing is read twice. */
|
|
58
|
+
function jcodeInstanceFields(entry: Record<string, PropertyDescriptor>, path: string): Partial<InstanceConfig> {
|
|
59
|
+
const home: unknown = entry.jcode_home?.value;
|
|
60
|
+
if (typeof home !== "string" || !isAbsolute(home) || !home.trim() || Buffer.byteLength(home) > 4096 || /\p{Cc}/u.test(home)) return fail(`${path}.jcode_home`, "is required: a nonempty control-free absolute path of at most 4096 UTF-8 bytes, other than the base Jcode's home");
|
|
61
|
+
const out: Partial<InstanceConfig> = { jcode_home: home };
|
|
62
|
+
for (const key of ["provider", "model", "provider_profile"] as const) {
|
|
63
|
+
const present = entry[key];
|
|
64
|
+
if (!present) continue;
|
|
65
|
+
const word: unknown = present.value;
|
|
66
|
+
if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.${key}`, "must be one bounded printable word without spaces");
|
|
67
|
+
out[key] = word;
|
|
68
|
+
}
|
|
69
|
+
if (entry.start_mode && entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "a Jcode instance starts headless");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The directory a home names, as the disk sees it (#685 review): symlinks and system aliases (`/tmp` and `/private/tmp`) resolved with
|
|
75
|
+
* `realPath` (never `realpathSync`, which Bun fails on a backslash path), and for a home that does not exist yet the nearest existing
|
|
76
|
+
* ancestor resolved the same way. Compared case-folded when the volume folds case, so a case-only alias is the same home.
|
|
77
|
+
*/
|
|
78
|
+
function canonicalHome(home: string): { path: string; key: string; identity?: string } {
|
|
79
|
+
let path: string;
|
|
80
|
+
try { path = realPath(home); } catch { path = canonicalPath(home); }
|
|
81
|
+
const stat = (target: string) => { try { return statSync(target); } catch { return undefined; } };
|
|
82
|
+
let existing = path;
|
|
83
|
+
while (!stat(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
84
|
+
const info = stat(path), anchor = stat(existing);
|
|
85
|
+
const swapped = existing.replace(/[A-Za-z]/g, letter => letter === letter.toLowerCase() ? letter.toUpperCase() : letter.toLowerCase());
|
|
86
|
+
const alias = swapped !== existing ? stat(swapped) : undefined;
|
|
87
|
+
const folds = !!anchor && !!alias && alias.ino === anchor.ino && alias.dev === anchor.dev;
|
|
88
|
+
return { path, key: folds ? path.toLowerCase() : path, ...(info ? { identity: `${info.dev}:${info.ino}` } : {}) };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One native data home per Jcode (#685): an instance may not use the base Jcode's home, another instance's, or one nested
|
|
92
|
+
* inside either (their stores would overlap), however the path is spelled: a symlink, a system alias or a case-only alias of one
|
|
93
|
+
* is the same home. Run once with the loaded base `jcode_home`.
|
|
94
|
+
*/
|
|
95
|
+
export function checkJcodeInstanceHomes(declarations: InstancesConfig | undefined, baseHome: string): void {
|
|
96
|
+
const homes: { id: string; key: string; identity?: string }[] = [{ id: "the base Jcode", ...canonicalHome(baseHome) }];
|
|
97
|
+
for (const [id, config] of Object.entries(declarations ?? {})) {
|
|
98
|
+
if (config.jcode_home === undefined) continue; // only a native-home kind carries one
|
|
99
|
+
const home = canonicalHome(config.jcode_home);
|
|
100
|
+
for (const other of homes) {
|
|
101
|
+
const label = other.id === "the base Jcode" ? "base" : other.id;
|
|
102
|
+
if (home.key === other.key || (home.identity !== undefined && home.identity === other.identity)) return fail(`instances.${id}.jcode_home`, other.id === "the base Jcode" ? "must differ from the base Jcode's home" : `is the same home as ${label}`);
|
|
103
|
+
if (home.key.startsWith(`${other.key}${sep}`) || other.key.startsWith(`${home.key}${sep}`)) return fail(`instances.${id}.jcode_home`, `is nested inside or around ${other.id === "the base Jcode" ? "the base Jcode's home" : `${label}'s home`}`);
|
|
104
|
+
}
|
|
105
|
+
homes.push({ id, ...home });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
|
|
45
110
|
export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
|
|
46
111
|
return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
|
|
@@ -57,11 +122,14 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
57
122
|
const result: Record<string, InstanceConfig> = Object.create(null);
|
|
58
123
|
if (raw === undefined) return Object.freeze(result);
|
|
59
124
|
const declarations = fields(raw, "instances");
|
|
125
|
+
let jcodeCount = 0;
|
|
60
126
|
if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
|
|
61
127
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
62
128
|
if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
63
129
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
64
|
-
|
|
130
|
+
const declaredKind: unknown = entry.kind?.value;
|
|
131
|
+
const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
|
|
132
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", ...(jcodeFields ? JCODE_FIELDS : [])].includes(key))) return fail(path, "unsupported field");
|
|
65
133
|
const kind: unknown = entry.kind?.value;
|
|
66
134
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
67
135
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
@@ -81,16 +149,24 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
81
149
|
const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
|
|
82
150
|
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
83
151
|
if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
|
|
152
|
+
if (declaredRoles.includes("planner") && definition.projection?.plannerEligible !== true) return fail(`${path}.roles`, "this kind cannot plan");
|
|
84
153
|
if (external) {
|
|
85
154
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
86
155
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
87
156
|
if (entry.start_mode && definition.reviewerVariant) {
|
|
157
|
+
const reviewerRoles = declared.length === 1 && declared[0] === "reviewer";
|
|
158
|
+
// #525 R1: a headless writer is the kind's implementer variant, which arrives with slice 525-4; until then it is refused.
|
|
159
|
+
if (!reviewerRoles && declared.includes("implementer") && declared.every(role => role === "implementer" || role === "verifier")) return fail(`${path}.start_mode`, "headless Claude implementer is not available in this release");
|
|
160
|
+
if (!reviewerRoles) return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
88
161
|
if (entry.worktree) return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
|
|
89
|
-
if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
90
162
|
} else {
|
|
91
|
-
if (entry.start_mode
|
|
163
|
+
if (entry.start_mode) return fail(`${path}.start_mode`, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>); a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
|
|
92
164
|
const allowed = definition.instances?.roles ?? definition.roles;
|
|
93
|
-
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`,
|
|
165
|
+
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, `a channel instance must declare its roles within: ${allowed.join(", ")}`);
|
|
166
|
+
// #525 R1: an implementer or verifier writes, so it needs its own worktree; a planner or reviewer reads the project checkout.
|
|
167
|
+
const writes = declared.includes("implementer") || declared.includes("verifier");
|
|
168
|
+
if (writes && !entry.worktree) return fail(`${path}.worktree`, "an implementer or verifier channel instance needs its own worktree");
|
|
169
|
+
if (!writes && entry.worktree) return fail(`${path}.worktree`, "a planner or reviewer channel instance reads the project checkout: worktree is not supported");
|
|
94
170
|
}
|
|
95
171
|
} else {
|
|
96
172
|
const allowed = runtime.instances?.roles;
|
|
@@ -102,6 +178,10 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
102
178
|
if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
|
|
103
179
|
}
|
|
104
180
|
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
181
|
+
if (jcodeFields) {
|
|
182
|
+
if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
|
|
183
|
+
Object.assign(value, jcodeInstanceFields(entry, path));
|
|
184
|
+
}
|
|
105
185
|
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
106
186
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
107
187
|
if (entry.start_mode) {
|
|
@@ -145,5 +225,7 @@ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig
|
|
|
145
225
|
if (!definition) throw new Error(`${id}: no declared native instance`);
|
|
146
226
|
const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
|
|
147
227
|
const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
|
|
148
|
-
|
|
228
|
+
const roles = declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles;
|
|
229
|
+
// #525 R1: a roles entry keyed by an instance id skips loadConductorPolicy's kind check, so planner is gated here too.
|
|
230
|
+
return roles.filter(role => role !== "planner" || definition.projection?.plannerEligible === true);
|
|
149
231
|
}
|
package/src/hub/manager.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
|
|
9
9
|
import { Registry, type Project } from "./registry.ts";
|
|
10
10
|
import { hubHome } from "./project.ts";
|
|
11
|
-
import { startDashboard } from "./ui.ts";
|
|
11
|
+
import { startDashboard, dashboardView } from "./ui.ts";
|
|
12
12
|
import { projectChain } from "@staix/agent-mem";
|
|
13
13
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
14
14
|
import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
|
|
@@ -164,7 +164,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
164
164
|
else if (deliveryRead) nominalMs = 30_000;
|
|
165
165
|
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
166
166
|
const result = await client.request(input.action === "snapshot"
|
|
167
|
-
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
|
|
167
|
+
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
168
168
|
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
169
169
|
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
170
170
|
if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
package/src/hub/profiles.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
|
10
10
|
export interface ProjectProfile {
|
|
11
11
|
name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
|
|
12
12
|
catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
|
|
13
|
+
review_checklist?: string[];
|
|
13
14
|
}
|
|
14
15
|
const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
|
|
15
16
|
export function profileName(raw: unknown): ProfileName {
|
|
@@ -34,10 +35,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
34
35
|
const original = projectProfile(raw);
|
|
35
36
|
if (overrides === undefined) return original;
|
|
36
37
|
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new Error("profile_overrides must be an object");
|
|
37
|
-
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks"]);
|
|
38
|
+
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks", "review_checklist"]);
|
|
38
39
|
if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
|
|
39
40
|
if (!Object.keys(data).length) return original;
|
|
40
|
-
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks] };
|
|
41
|
+
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks], ...(original.review_checklist ? { review_checklist: [...original.review_checklist] } : {}) };
|
|
41
42
|
const text = (value: unknown, cap: number): string => { if (typeof value !== "string" || value.length > cap) throw new Error("profile text is missing or oversized"); return sanitize(value); };
|
|
42
43
|
if (data.classes !== undefined) {
|
|
43
44
|
if (!data.classes || typeof data.classes !== "object" || Array.isArray(data.classes) || Object.keys(data.classes).length > 32) throw new Error("profile classes must be a bounded base-class map");
|
|
@@ -63,6 +64,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
63
64
|
if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
|
|
64
65
|
result.example_checks = data.example_checks.map(value => text(value, 2000));
|
|
65
66
|
}
|
|
67
|
+
if (data.review_checklist !== undefined) {
|
|
68
|
+
if (!Array.isArray(data.review_checklist) || !data.review_checklist.length || data.review_checklist.length > 16 || data.review_checklist.some(value => typeof value !== "string" || !value.trim() || value.length > 500)) throw new Error("profile review_checklist must be bounded checklist lines");
|
|
69
|
+
result.review_checklist = data.review_checklist.map(value => sanitize(value.trim()));
|
|
70
|
+
}
|
|
66
71
|
return result;
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import type { ReleaseGit } from "./sdlc-release.ts";
|
|
5
|
+
|
|
6
|
+
/** Filter-free tracked checkout comparison. Unknown or transformed content refuses. */
|
|
7
|
+
// ponytail: 4096 entries, 4 MiB/file, 32 MiB total and 250 ms; larger/gitlink/transformed checkouts require a future bounded artifact attestation.
|
|
8
|
+
export function releaseTrackedClean(root: string, git: ReleaseGit): boolean {
|
|
9
|
+
const start = performance.now(), deadline = () => performance.now() - start <= 250;
|
|
10
|
+
const run = (args: string[]) => {
|
|
11
|
+
if (!deadline()) return;
|
|
12
|
+
const result = git(args);
|
|
13
|
+
return deadline() && result.code === 0 && Buffer.byteLength(result.stdout) <= 2 * 1024 * 1024 ? result.stdout : undefined;
|
|
14
|
+
};
|
|
15
|
+
try {
|
|
16
|
+
const base = resolve(root);
|
|
17
|
+
if (!lstatSync(base).isDirectory() || lstatSync(base).isSymbolicLink()) return false;
|
|
18
|
+
if (run(["diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", "--"]) === undefined) return false;
|
|
19
|
+
const inventory = run(["ls-files", "--stage", "-z"]);
|
|
20
|
+
if (inventory === undefined || inventory && !inventory.endsWith("\0")) return false;
|
|
21
|
+
const entries = inventory ? inventory.slice(0, -1).split("\0") : [];
|
|
22
|
+
if (entries.length > 4096) return false;
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!deadline()) return false;
|
|
26
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40}) ([0-3])\t([\s\S]+)$/.exec(entry);
|
|
27
|
+
if (!match) return false;
|
|
28
|
+
const [, mode, oid, stage, name] = match;
|
|
29
|
+
if (stage !== "0" || !["100644", "100755", "120000"].includes(mode!)) return false;
|
|
30
|
+
if (!name || name.length > 4096 || name.startsWith("/") || name.includes("\\") || name.split("/").some(part => !part || part === "." || part === "..") || /[\p{Cc}]/u.test(name)) return false;
|
|
31
|
+
const file = resolve(base, name), within = relative(base, file);
|
|
32
|
+
if (!within || within === ".." || within.startsWith("../")) return false;
|
|
33
|
+
for (let parent = dirname(file); parent !== base; parent = dirname(parent)) {
|
|
34
|
+
if (!deadline()) return false;
|
|
35
|
+
const info = lstatSync(parent);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return false;
|
|
37
|
+
}
|
|
38
|
+
const before = lstatSync(file);
|
|
39
|
+
if (before.size > 4 * 1024 * 1024 || total + before.size > 32 * 1024 * 1024) return false;
|
|
40
|
+
let bytes: Buffer;
|
|
41
|
+
if (mode === "120000") {
|
|
42
|
+
if (!before.isSymbolicLink()) return false;
|
|
43
|
+
bytes = readlinkSync(file, { encoding: "buffer" });
|
|
44
|
+
} else {
|
|
45
|
+
if (!before.isFile() || before.isSymbolicLink() || !!(before.mode & 0o111) !== (mode === "100755")) return false;
|
|
46
|
+
bytes = readFileSync(file);
|
|
47
|
+
}
|
|
48
|
+
total += bytes.length;
|
|
49
|
+
if (bytes.length > 4 * 1024 * 1024 || total > 32 * 1024 * 1024 || !deadline()) return false;
|
|
50
|
+
const after = lstatSync(file);
|
|
51
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) return false;
|
|
52
|
+
if (createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== oid) return false;
|
|
53
|
+
}
|
|
54
|
+
return deadline();
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
package/src/hub/review-brief.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { CodeFactResult, CodeSymbol } from "./code-facts.ts";
|
|
1
2
|
import { childEnv } from "./child-process.ts";
|
|
2
3
|
import { hubGitSync } from "./git.ts";
|
|
4
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
|
|
@@ -10,6 +12,7 @@ import { hubGitSync } from "./git.ts";
|
|
|
10
12
|
export interface BriefFile { path: string; status: string; oldPath?: string; insertions?: number; deletions?: number }
|
|
11
13
|
export interface BriefExcluded { path: string; status: string; oldPath?: string; reason: string }
|
|
12
14
|
export interface ReviewBrief {
|
|
15
|
+
symbols?: { state: "known" | "unknown"; complete?: boolean; files: {path:string; symbols:CodeSymbol[]}[]; planned: string[]; unplanned: string[]; unseen: string[] };
|
|
13
16
|
head: string;
|
|
14
17
|
/** The default branch the head is compared with: origin/HEAD, origin/main or origin/master. */
|
|
15
18
|
base: string;
|
|
@@ -149,6 +152,38 @@ function attributes(cwd: string, source: string, paths: string[]): Map<string, {
|
|
|
149
152
|
return out;
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/** One plan name against one changed name: a qualified pair must be equal, otherwise the last segments are. Shared with the console's task detail (#684 2b). */
|
|
156
|
+
export const planSymbolMatches = (left: string, right: string): boolean => symbolsOverlap(left, right);
|
|
157
|
+
/** Project derived names through the host metadata screen before comparing or rendering them. */
|
|
158
|
+
export function withCodeSymbols(brief:ReviewBrief,result:CodeFactResult<CodeSymbol[]>,plan:readonly string[]|undefined,publicMetadataText:(text:string)=>boolean):ReviewBrief {
|
|
159
|
+
const unknown={...brief,symbols:{state:"unknown" as const,files:[],planned:[],unplanned:[],unseen:[]}};
|
|
160
|
+
if(result.state!=="known")return unknown;
|
|
161
|
+
try {
|
|
162
|
+
const paths=new Set(brief.reviewable.map(file=>file.path));
|
|
163
|
+
const changed=result.value.filter(s=>!s.ambiguous && paths.has(s.path) && publicMetadataText(s.path) && publicMetadataText(s.name) && publicMetadataText(s.kind));
|
|
164
|
+
const planned=(plan??[]).filter(name=>publicMetadataText(name));
|
|
165
|
+
const matches=planSymbolMatches;
|
|
166
|
+
const names=[...new Set(changed.map(s=>s.name))];
|
|
167
|
+
return {...brief,symbols:{state:"known",complete:changed.length===result.value.length && brief.withheld===0,files:[...paths].map(path=>({path,symbols:changed.filter(s=>s.path===path)})),
|
|
168
|
+
planned:names.filter(name=>planned.some(p=>matches(p,name))),unplanned:names.filter(name=>!planned.some(p=>matches(p,name))),unseen:planned.filter(p=>!names.some(name=>matches(p,name)))}};
|
|
169
|
+
} catch { return unknown; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderCodeSymbols(brief:ReviewBrief):string[] {
|
|
173
|
+
const symbols=brief.symbols;
|
|
174
|
+
if(!symbols)return [];
|
|
175
|
+
if(symbols.state === "unknown")return ["Changed symbols: unknown (optional code facts unavailable)."];
|
|
176
|
+
const lines=[
|
|
177
|
+
"Changed symbols (advisory; change: unknown):",
|
|
178
|
+
...symbols.files.flatMap(file=>file.symbols.length ? file.symbols.map(symbol=>`- ${file.path}: ${symbol.name} (${symbol.kind}, lines ${symbol.line}${symbol.endLine === undefined ? "..unknown" : `..${symbol.endLine}`}; change: unknown)`) : [`- ${file.path}: no changed symbol observed`]),
|
|
179
|
+
`Changed and planned: ${symbols.planned.join(", ") || "none observed"}`,
|
|
180
|
+
`Changed, not planned: ${symbols.unplanned.join(", ") || "none observed"}`,
|
|
181
|
+
`Planned, not seen as changed: ${symbols.unseen.join(", ") || "none observed"}`,
|
|
182
|
+
"Ceiling: ripwire 0.6.7 does not report deleted symbols; these lists never establish plan conformance.",
|
|
183
|
+
];
|
|
184
|
+
return Buffer.byteLength(lines.join("\n"))<=16000 ? lines : ["Changed symbols: unknown (advice exceeds 16000 bytes)."];
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
/** The brief as review text: what every reviewer reads and how it accounts for its coverage. */
|
|
153
188
|
export function renderReviewBrief(brief: ReviewBrief): string {
|
|
154
189
|
const file = (f: BriefFile) => `- ${f.oldPath ? `${f.oldPath} -> ` : ""}${f.path} (${f.status}${f.insertions !== undefined ? `, +${f.insertions} -${f.deletions}` : ""})`;
|
|
@@ -156,6 +191,7 @@ export function renderReviewBrief(brief: ReviewBrief): string {
|
|
|
156
191
|
`Review brief: head ${brief.head} against ${brief.base} (merge base ${brief.mergeBase.slice(0, 12)}); ${brief.reviewable.length} reviewable, ${brief.excluded.length} excluded${brief.withheld ? `, ${brief.withheld} withheld by the privacy policy` : ""}.`,
|
|
157
192
|
...(brief.reviewable.length ? ["Reviewable:", ...brief.reviewable.map(file)] : ["Reviewable: none (record why in the note)."]),
|
|
158
193
|
...(brief.excluded.length ? ["Excluded (still read their diff when it matters):", ...brief.excluded.map(e => `- ${e.oldPath ? `${e.oldPath} -> ` : ""}${e.path} (${e.status}): ${e.reason}`)] : []),
|
|
194
|
+
...renderCodeSymbols(brief),
|
|
159
195
|
...(brief.unlisted.length ? ["Not in the brief's list, read directly:", ...brief.unlisted.map(path => `- ${path}`)] : []),
|
|
160
196
|
...brief.ruleGroups.filter(g => g.sources.length).map(g => `Rules ${g.sources.join(" + ")} apply to: ${brief.ruleGroups.length === 1 ? "every reviewable file" : g.paths.join(", ")}`),
|
|
161
197
|
...(brief.rulesMissing.length ? ["Configured rules not loaded (read them in the repository):", ...brief.rulesMissing.map(m => `- ${m.source}: ${m.reason}`)] : []),
|
|
@@ -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
|
+
}
|