@staix/agent-hub 0.12.23 → 0.12.25
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 +109 -1
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +10 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +8 -0
- package/docs/agent-notes/tests.md +5 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +666 -19
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +2 -2
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +171 -23
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +914 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +115 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +757 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import { isSupportedPeer } from "./peer-catalog.ts";
|
|
1
2
|
import { lstatSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, relative, sep } from "node:path";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
4
|
|
|
4
5
|
/** Operator-selected approval frequency; agent sandboxes remain separate. */
|
|
5
6
|
export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
|
|
6
7
|
export type PermissionMode = typeof PERMISSION_MODES[number];
|
|
7
8
|
export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
|
|
9
|
+
export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
|
|
8
10
|
export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
|
|
9
11
|
export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
|
|
10
12
|
if (value === undefined) return {};
|
|
11
13
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
|
|
12
14
|
const result: Record<string, PermissionMode> = {};
|
|
13
15
|
for (const [peer, mode] of Object.entries(value)) {
|
|
14
|
-
if (!
|
|
16
|
+
if (!isSupportedPeer(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
|
|
17
|
+
if (peer === "jcode") throw new Error("Jcode native policy is unmanaged; no hub permission_modes.jcode mapping exists");
|
|
15
18
|
if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
|
|
16
19
|
result[peer] = mode;
|
|
17
20
|
}
|
|
@@ -19,13 +22,16 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
|
|
|
19
22
|
}
|
|
20
23
|
/** Approval frequency does not create a common sandbox across native peers. */
|
|
21
24
|
export function permissionBoundary(peer: string): string {
|
|
25
|
+
if (peer === "jcode") return "jcode: native policy unmanaged; NO hub sandbox; approvals belong to its native tools";
|
|
22
26
|
if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
23
27
|
if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
24
28
|
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
25
29
|
}
|
|
26
30
|
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
27
31
|
export function permissionGrant(peer: string, mode: PermissionMode): string {
|
|
32
|
+
if (peer === "jcode") return "no verified hub mode mapping; native policy only";
|
|
28
33
|
if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
|
|
34
|
+
if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
|
|
29
35
|
if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
|
|
30
36
|
if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
|
|
31
37
|
if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
|
|
@@ -33,7 +39,14 @@ export function permissionGrant(peer: string, mode: PermissionMode): string {
|
|
|
33
39
|
}
|
|
34
40
|
export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
|
|
35
41
|
/** Native agent policy files are never included in scoped automatic file grants. */
|
|
36
|
-
export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
|
|
42
|
+
export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".claude.json"]);
|
|
43
|
+
export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
|
|
44
|
+
export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
|
|
45
|
+
export function instructionProtection(value: unknown): boolean {
|
|
46
|
+
if (value === undefined) return false;
|
|
47
|
+
if (typeof value !== "boolean") throw new Error("protect_instruction_files must be a boolean");
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
37
50
|
/**
|
|
38
51
|
* One spelling per name, as a case-insensitive disk sees it: it opens `.mcp.j\u017Fon` (long s) as `.mcp.json`, and
|
|
39
52
|
* HFS+ also ignores the default-ignorable code points (a zero-width joiner inside `.codex` is still `.codex`).
|
|
@@ -52,7 +65,8 @@ export function hardLinked(path: string): boolean {
|
|
|
52
65
|
* inside the project, outside every agent's configuration, judged by the path within the project (a project that
|
|
53
66
|
* itself lives under `.claude/worktrees/` is still a project), and not a hard link.
|
|
54
67
|
*/
|
|
55
|
-
export function grantablePath(root: string, path: string): boolean {
|
|
68
|
+
export function grantablePath(root: string, path: string, originalPath?: string, protectInstructions = false): boolean {
|
|
56
69
|
const inside = relative(root, path);
|
|
57
|
-
|
|
70
|
+
const original = originalPath === undefined ? inside : relative(root, resolve(root, originalPath));
|
|
71
|
+
return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !isAgentConfigPath(original) && !isHubWritePath(original) && !(protectInstructions && (isInstructionPath(inside) || isInstructionPath(original))) && !hardLinked(path);
|
|
58
72
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
|
|
4
|
+
|
|
5
|
+
export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved"] as const;
|
|
6
|
+
export interface PlanningFields {
|
|
7
|
+
rank?: number | null;
|
|
8
|
+
priority?: number | null;
|
|
9
|
+
release?: number | null;
|
|
10
|
+
plannedStart?: number | null;
|
|
11
|
+
due?: number | null;
|
|
12
|
+
pinned?: boolean;
|
|
13
|
+
deps?: number[];
|
|
14
|
+
reserved?: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface PlanningPatch extends PlanningFields {}
|
|
17
|
+
/** Machine-local capacity settings; ids never grant roster membership or authority. */
|
|
18
|
+
export function planningWipLimits(input: unknown): Record<string, number> {
|
|
19
|
+
if (input === undefined) return {};
|
|
20
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length > 64) throw new Error("tasks.wip_limits must be a bounded peer map");
|
|
21
|
+
const limits: Record<string, number> = Object.create(null);
|
|
22
|
+
for (const [peer, value] of Object.entries(input)) {
|
|
23
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 32) throw new Error("tasks.wip_limits needs peer ids and integer capacities 0..32");
|
|
24
|
+
limits[peer] = value;
|
|
25
|
+
}
|
|
26
|
+
return limits;
|
|
27
|
+
}
|
|
28
|
+
export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
|
|
29
|
+
export type ReleaseState = (typeof RELEASE_STATES)[number];
|
|
30
|
+
export interface Release {
|
|
31
|
+
id: number; name: string; goal: string; order: number; state: ReleaseState;
|
|
32
|
+
plannedStart: number | null; cut: number | null; ship: number | null;
|
|
33
|
+
activatedAt: number | null; frozenAt: number | null; shippedAt: number | null;
|
|
34
|
+
created: number; updated: number; revision: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate the entire planning input before a write; actual dates are never caller input. */
|
|
38
|
+
export function planningPatch(input: unknown): PlanningPatch {
|
|
39
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("planning fields must be an object");
|
|
40
|
+
const fields = input as Record<string, unknown>;
|
|
41
|
+
if (!Object.keys(fields).length || Object.keys(fields).some(key => !(PLANNING_FIELDS as readonly string[]).includes(key))) throw new Error("unknown or empty planning fields");
|
|
42
|
+
const result: PlanningPatch = {};
|
|
43
|
+
for (const key of PLANNING_FIELDS) {
|
|
44
|
+
if (!Object.hasOwn(fields, key)) continue;
|
|
45
|
+
const value = fields[key];
|
|
46
|
+
if (key === "deps") {
|
|
47
|
+
if (!Array.isArray(value) || value.length > 64 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) throw new Error("deps must be at most 64 unique positive task ids");
|
|
48
|
+
result.deps = [...value];
|
|
49
|
+
} else if (key === "reserved") {
|
|
50
|
+
const peer = typeof value === "string" ? value.trim().toLowerCase() : null;
|
|
51
|
+
if (value !== null && (!peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || ["user", "hub", "digest"].includes(peer))) throw new Error("reserved must be a valid agent peer id or null");
|
|
52
|
+
result.reserved = peer;
|
|
53
|
+
} else if (key === "pinned") {
|
|
54
|
+
if (typeof value !== "boolean") throw new Error("pinned must be a boolean");
|
|
55
|
+
result.pinned = value;
|
|
56
|
+
} else {
|
|
57
|
+
if (value !== null && (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "release" || key === "plannedStart" || key === "due" ? 1 : 0) || (key === "priority" && value > 3) || (["plannedStart", "due"].includes(key) && value > 8_640_000_000_000_000))) throw new Error(`invalid planning field ${key}`);
|
|
58
|
+
result[key] = value as number | null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Structured lifecycle events only, including reviewerless approval via done. */
|
|
65
|
+
export function actualTaskDates(task: Pick<Task, "history" | "state" | "reviewer">) {
|
|
66
|
+
const first = (events: readonly string[]) => task.history.find(entry => events.includes(entry.event))?.at ?? null;
|
|
67
|
+
const approved = first(["approved"]), reviewed = task.reviewer !== null || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
|
|
68
|
+
const reviewerlessDone = task.state === "approved" && !task.reviewer ? [...task.history].reverse().find(entry => entry.event === "done")?.at ?? null : null;
|
|
69
|
+
return { startedAt: first(["accepted"]), reviewAt: reviewed ? first(["done"]) : null, doneAt: approved ?? reviewerlessDone };
|
|
70
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import dev from "../../templates/profiles/dev.json";
|
|
2
|
+
import research from "../../templates/profiles/research.json";
|
|
3
|
+
import writing from "../../templates/profiles/writing.json";
|
|
4
|
+
import general from "../../templates/profiles/general.json";
|
|
5
|
+
import { ALL_PLAN_KEYS, CLASSES, type TaskClass } from "./board.ts";
|
|
6
|
+
import { catalogAssignments } from "./roles.ts";
|
|
7
|
+
import { sanitize } from "./envelope.ts";
|
|
8
|
+
export const PROFILE_NAMES = ["dev", "research", "writing", "general"] as const;
|
|
9
|
+
export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
10
|
+
export interface ProjectProfile {
|
|
11
|
+
name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
|
|
12
|
+
catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
|
|
13
|
+
}
|
|
14
|
+
const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
|
|
15
|
+
export function profileName(raw: unknown): ProfileName {
|
|
16
|
+
if (raw === undefined) return "dev";
|
|
17
|
+
if (typeof raw !== "string" || !PROFILE_NAMES.includes(raw as ProfileName)) throw new Error(`profile must be one of ${PROFILE_NAMES.join(", ")}`);
|
|
18
|
+
return raw as ProfileName;
|
|
19
|
+
}
|
|
20
|
+
export const projectProfile = (raw?: unknown): ProjectProfile => PROFILES[profileName(raw)];
|
|
21
|
+
export type ProfileSelection = ProfileName | ProjectProfile;
|
|
22
|
+
export const profileData = (profile: ProfileSelection = "dev"): ProjectProfile => typeof profile === "string" ? projectProfile(profile) : profile;
|
|
23
|
+
export function profileClass(raw: string, profile: ProfileSelection = "dev"): TaskClass {
|
|
24
|
+
const data = profileData(profile);
|
|
25
|
+
const base = typeof raw === "string" ? (Object.hasOwn(data.classes, raw) ? data.classes[raw] : Object.hasOwn(dev.classes, raw) ? (dev.classes as Record<string, string>)[raw] : undefined) : undefined;
|
|
26
|
+
if (!base) throw new Error(`class must be one of ${Object.keys(data.classes).join(", ")} (profile ${data.name}; existing base classes also accepted)`);
|
|
27
|
+
return base as TaskClass;
|
|
28
|
+
}
|
|
29
|
+
export const profileTaskLabel = (task: { class: string; profile?: string; profileClass?: string }) => task.profileClass ? `${task.profileClass} (base ${task.class}, ${task.profile})` : task.class;
|
|
30
|
+
export const PROFILE_CLASS_NAMES = [...new Set(Object.values(PROFILES).flatMap(p => Object.keys(p.classes)))];
|
|
31
|
+
|
|
32
|
+
/** Pure project data resolution. No mutation of shipped profiles or executable check defaults. */
|
|
33
|
+
export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfile {
|
|
34
|
+
const original = projectProfile(raw);
|
|
35
|
+
if (overrides === undefined) return original;
|
|
36
|
+
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
|
+
if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
|
|
39
|
+
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 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
|
+
if (data.classes !== undefined) {
|
|
43
|
+
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");
|
|
44
|
+
for (const [name, base] of Object.entries(data.classes)) {
|
|
45
|
+
if (!/^[a-z][a-z0-9_]{0,47}$/.test(name) || typeof base !== "string" || !CLASSES.includes(base as TaskClass) || CLASSES.includes(name as TaskClass) && name !== base) throw new Error("profile class must retain an existing base-class meaning");
|
|
46
|
+
result.classes[name] = base;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (data.plan_keys !== undefined) {
|
|
50
|
+
if (!Array.isArray(data.plan_keys) || !data.plan_keys.length || data.plan_keys.length > ALL_PLAN_KEYS.length || new Set(data.plan_keys).size !== data.plan_keys.length || data.plan_keys.some(key => !ALL_PLAN_KEYS.includes(key))) throw new Error("profile plan_keys must select distinct supported fields");
|
|
51
|
+
result.plan_keys = [...data.plan_keys];
|
|
52
|
+
}
|
|
53
|
+
if (data.role_text !== undefined) {
|
|
54
|
+
if (!data.role_text || typeof data.role_text !== "object" || Array.isArray(data.role_text) || Object.keys(data.role_text).length > 8) throw new Error("profile role_text must be bounded guidance");
|
|
55
|
+
for (const [role, value] of Object.entries(data.role_text)) {
|
|
56
|
+
if (!["planner", "implementer", "verifier", "reviewer", "conductor"].includes(role)) throw new Error("profile role_text must name an existing flow role");
|
|
57
|
+
result.role_text[role] = text(value, 2000);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (data.catalog_roles !== undefined) result.catalog_roles = { ...result.catalog_roles, ...catalogAssignments(data.catalog_roles) };
|
|
61
|
+
if (data.agents !== undefined) result.agents = text(data.agents, 16000);
|
|
62
|
+
if (data.example_checks !== undefined) {
|
|
63
|
+
if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
|
|
64
|
+
result.example_checks = data.example_checks.map(value => text(value, 2000));
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Maps merge across shared/local layers; selections and display text replace as a unit. */
|
|
70
|
+
export function mergeProfileOverrides(a: unknown, b: unknown): Record<string, unknown> | undefined {
|
|
71
|
+
if (a === undefined && b === undefined) return undefined;
|
|
72
|
+
const left = (a ?? {}) as Record<string, unknown>, right = (b ?? {}) as Record<string, unknown>;
|
|
73
|
+
return { ...left, ...right, ...Object.fromEntries(["classes", "role_text", "catalog_roles"].filter(key => left[key] !== undefined && right[key] !== undefined).map(key => [key, { ...(left[key] as object), ...(right[key] as object) }])) };
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { hubGitSync } from "./git.ts";
|
|
5
|
+
import { sanitize } from "./envelope.ts";
|
|
6
|
+
import { foldSegment } from "./permission-mode.ts";
|
|
7
|
+
import { isDenied } from "../local/deny.ts";
|
|
8
|
+
import { MAX_PROJECTION_INPUT, type TextProjections } from "./text-projection.ts";
|
|
9
|
+
import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
|
|
10
|
+
|
|
11
|
+
export const PROJECTED_DIFF_BYTES = 12000;
|
|
12
|
+
const MAX_FILES = 8, DIFF_MS = 8000;
|
|
13
|
+
const HEADER = "Projected text only (authorship and raw restore safety are independent):";
|
|
14
|
+
const treeId = (tree: string) => /^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(tree);
|
|
15
|
+
export function projectionPath(repo: Repo, path: string, deny: string[] = []): boolean {
|
|
16
|
+
const parts = path.split("/"), folded = parts.map(foldSegment).join("/");
|
|
17
|
+
return !!path && !path.startsWith("/") && path.startsWith(repo.prefix) && parts.every(part => !!part && part !== "." && part !== ".." && ![".git", ".agenthub"].includes(foldSegment(part))) && !isDenied(path, deny) && !isDenied(folded, deny.map(foldSegment));
|
|
18
|
+
}
|
|
19
|
+
/** null is an absent file; undefined is unavailable evidence or a non-regular object. */
|
|
20
|
+
export function snapshotBytes(repo: Repo, tree: string, path: string, deadline = performance.now() + DIFF_MS): Buffer | null | undefined {
|
|
21
|
+
if (!treeId(tree) || !projectionPath(repo, path) || performance.now() >= deadline) return undefined;
|
|
22
|
+
const run = (args: string[], encoding: "utf8" | "latin1" = "utf8", maxBuffer = 8192) => hubGitSync(repoGitArgs(repo, args), { encoding, maxBuffer, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
|
|
23
|
+
const entry = run(["ls-tree", "-z", tree, "--", `:(literal)${path}`]);
|
|
24
|
+
if (entry.status !== 0) return undefined;
|
|
25
|
+
if (!entry.stdout) return null;
|
|
26
|
+
const match = /^(100644|100755) blob ([a-f0-9]+)\t/.exec(entry.stdout);
|
|
27
|
+
if (!match || entry.stdout.slice(match[0].length) !== path + "\0" || !treeId(match[2]!)) return undefined;
|
|
28
|
+
if (performance.now() >= deadline) return undefined;
|
|
29
|
+
const size = run(["cat-file", "-s", match[2]!]);
|
|
30
|
+
const length = Number(size.stdout.trim());
|
|
31
|
+
if (size.status !== 0 || !Number.isSafeInteger(length) || length < 0 || length > MAX_PROJECTION_INPUT || performance.now() >= deadline) return undefined;
|
|
32
|
+
// Latin1 preserves all byte values through the existing bounded git wrapper.
|
|
33
|
+
const blob = run(["cat-file", "blob", match[2]!], "latin1", MAX_PROJECTION_INPUT + 1);
|
|
34
|
+
if (blob.status !== 0 || blob.stdout.length !== length) return undefined;
|
|
35
|
+
return Buffer.from(blob.stdout, "latin1");
|
|
36
|
+
}
|
|
37
|
+
function hunks(repo: Repo, before: string, after: string, deadline: number): string | undefined {
|
|
38
|
+
if (performance.now() >= deadline) return undefined;
|
|
39
|
+
const scratch = mkdtempSync(join(tmpdir(), "ahub-projected-diff-"));
|
|
40
|
+
try {
|
|
41
|
+
const a = join(scratch, "before"), b = join(scratch, "after"); writeFileSync(a, before, { mode: 0o600 }); writeFileSync(b, after, { mode: 0o600 });
|
|
42
|
+
const diff = hubGitSync(repoGitArgs(repo, ["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
|
|
43
|
+
if (diff.status !== 0 && diff.status !== 1) return undefined;
|
|
44
|
+
const start = diff.stdout.indexOf("@@");
|
|
45
|
+
if (start < 0) return diff.status === 0 ? "(projected text unchanged)" : undefined;
|
|
46
|
+
return diff.stdout.slice(start).replace(/\n$/, "");
|
|
47
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
48
|
+
}
|
|
49
|
+
/** Presentation of checked snapshot bytes, never evidence of authorship or restore safety. */
|
|
50
|
+
export function projectedDiff(options: { repo: Repo; from: string; to: string; paths: string[]; projections: TextProjections; deny?: string[]; privatePath?: (path: string) => boolean; nameable?: (text: string) => boolean }): string | undefined {
|
|
51
|
+
const { repo, projections } = options;
|
|
52
|
+
if (!projections.enabled) return undefined;
|
|
53
|
+
const deadline = performance.now() + DIFF_MS, rows: string[] = []; let bytes = Buffer.byteLength(HEADER) + 1, files = 0;
|
|
54
|
+
const add = (text: string) => { const safe = sanitize(text); if (bytes + Buffer.byteLength(safe) + 1 > PROJECTED_DIFF_BYTES) return false; rows.push(safe); bytes += Buffer.byteLength(safe) + 1; return true; };
|
|
55
|
+
for (const path of [...new Set(options.paths)]) {
|
|
56
|
+
if (!projections.handles(path) || !projectionPath(repo, path, options.deny) || options.nameable?.(path) === false) continue;
|
|
57
|
+
if (files++ >= MAX_FILES || performance.now() >= deadline) { add("(remaining projection previews omitted: count or time limit)"); break; }
|
|
58
|
+
if (options.privatePath?.(path)) { if (!add(`${path}: (projection withheld: private path)`)) break; continue; }
|
|
59
|
+
const before = snapshotBytes(repo, options.from, path, deadline), after = snapshotBytes(repo, options.to, path, deadline);
|
|
60
|
+
if (before === undefined || after === undefined) { if (!add(`${path}: (projection unavailable: snapshot file is unknown)`)) break; continue; }
|
|
61
|
+
if (before === null && after === null) { if (!add(`${path}: (projection unavailable: file is absent from both snapshots)`)) break; continue; }
|
|
62
|
+
const a = before === null ? null : projections.projectBytes(join(repo.top, path), before, deadline);
|
|
63
|
+
if (a === undefined) continue;
|
|
64
|
+
const b = after === null ? null : projections.projectBytes(join(repo.top, path), after, deadline);
|
|
65
|
+
if (a === undefined || b === undefined) continue; // converter failure keeps the ordinary file-only view
|
|
66
|
+
if (a && b && a.configHash !== b.configHash) { if (!add(`${path}: (projection unavailable: converter changed during comparison)`)) break; continue; }
|
|
67
|
+
const diff = hunks(repo, a?.text ?? "", b?.text ?? "", deadline);
|
|
68
|
+
if (diff === undefined) { if (!add(`${path}: (projection unavailable: diff could not be read)`)) break; continue; }
|
|
69
|
+
const text = options.nameable?.(diff) === false ? `${path}: (projection withheld: private-data pattern)` : `${path}:\n${diff}`;
|
|
70
|
+
if (!add(text)) { add("(remaining projection previews omitted: output limit)"); break; }
|
|
71
|
+
}
|
|
72
|
+
return rows.length ? `${HEADER}\n${rows.join("\n")}` : undefined;
|
|
73
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Trusted synchronous-boundary helper: the parent waits, this process owns and stops the converter group.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
let ownedGroup: number | undefined;
|
|
5
|
+
const stop = () => { if (ownedGroup) try { process.kill(-ownedGroup, "SIGKILL"); } catch { /* owned group gone */ } };
|
|
6
|
+
// The aggregate preview deadline can expire before the converter's own timer.
|
|
7
|
+
process.on("SIGTERM", () => { stop(); process.exit(143); });
|
|
8
|
+
process.on("SIGINT", () => { stop(); process.exit(130); });
|
|
9
|
+
const request = JSON.parse(await new Response(Bun.stdin.stream()).text()) as { cwd: string; sandbox: string; argv: string[]; timeoutMs?: number };
|
|
10
|
+
const limit = 256 * 1024;
|
|
11
|
+
const child = spawn("/usr/bin/sandbox-exec", ["-p", request.sandbox, ...request.argv], {
|
|
12
|
+
cwd: request.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
|
|
14
|
+
});
|
|
15
|
+
ownedGroup = child.pid;
|
|
16
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
17
|
+
let stdout = "", stderrBytes = 0, outputBytes = 0, invalid = false;
|
|
18
|
+
const timeout = typeof request.timeoutMs === "number" && Number.isFinite(request.timeoutMs) ? Math.ceil(Math.max(1, Math.min(5000, request.timeoutMs))) : 5000;
|
|
19
|
+
const timer = setTimeout(() => { invalid = true; stop(); }, timeout);
|
|
20
|
+
child.stdout.on("data", (bytes: Buffer) => { outputBytes += bytes.length; if (outputBytes > limit) { invalid = true; stop(); } else try { stdout += decoder.decode(bytes, { stream: true }); } catch { invalid = true; stop(); } });
|
|
21
|
+
child.stderr.on("data", (bytes: Buffer) => { stderrBytes += bytes.length; if (stderrBytes > limit) { invalid = true; stop(); } });
|
|
22
|
+
child.on("exit", () => stop()); // descendants may retain the output pipe after the leader exits
|
|
23
|
+
child.on("error", () => { clearTimeout(timer); invalid = true; process.stdout.write(JSON.stringify({ ok: false })); });
|
|
24
|
+
child.on("close", code => { clearTimeout(timer); ownedGroup = undefined; try { stdout += decoder.decode(); } catch { invalid = true; } process.stdout.write(JSON.stringify({ ok: !invalid && code === 0, ...(invalid || code !== 0 ? {} : { text: stdout }) })); });
|
package/src/hub/research.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { StampedEvent } from "./events.ts";
|
|
4
|
+
import type { RoleIdentity, StampedEvent } from "./events.ts";
|
|
5
5
|
import { hubHome } from "./project.ts";
|
|
6
|
+
import { ADDONS, addonState, type Addon, type AddonName } from "./tooling.ts";
|
|
7
|
+
import { MIN_ATTEMPTS, mulberry32 } from "./bench.ts";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
@@ -18,6 +20,11 @@ const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplet
|
|
|
18
20
|
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
19
21
|
export interface TaskRecord {
|
|
20
22
|
schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
|
|
23
|
+
profile?: string; profileClass?: string;
|
|
24
|
+
release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null;
|
|
25
|
+
role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string };
|
|
26
|
+
/** Native preamble preparations frozen at each attributed turn's start, never proof of model consumption. */
|
|
27
|
+
sessionRoles?: { peer: string; turn: string; roles: RoleIdentity[] | null }[];
|
|
21
28
|
class: string | null; pii: boolean; outcome: "approved";
|
|
22
29
|
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
23
30
|
createdAt: string | null; startedAt: string | null; approvedAt: string;
|
|
@@ -44,6 +51,12 @@ export interface TaskRecord {
|
|
|
44
51
|
/** Model routes and served models seen for the task's attributed requests. */
|
|
45
52
|
models: string[];
|
|
46
53
|
writer: { version: string; source: "live" | "backfill" };
|
|
54
|
+
/** #248: the add-ons each peer that worked on the task had at its latest attach; a peer missing here is unknown. */
|
|
55
|
+
tooling?: Record<string, Addon[]>;
|
|
56
|
+
/** #248, with `tooling`: every peer with an event attributed to the task, fingerprinted or not. */
|
|
57
|
+
peers?: string[];
|
|
58
|
+
/** #258 AC11: task briefs handed over for it, and how many of them carried a hub-memory fail note; absent without briefs. */
|
|
59
|
+
briefs?: { delivered: number; withFail: number };
|
|
47
60
|
}
|
|
48
61
|
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
49
62
|
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
@@ -56,15 +69,15 @@ export const researchFile = (projectId: string, home = hubHome()): string => joi
|
|
|
56
69
|
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
57
70
|
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
58
71
|
|
|
59
|
-
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
|
|
60
|
-
models: Set<string>; approvedAt: string | null; lastState: string | null;
|
|
72
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models" | "tooling" | "peers" | "briefs"> & {
|
|
73
|
+
models: Set<string>; approvedAt: string | null; lastState: string | null; tooling: Record<string, Addon[]>; peers: Map<string, number>; briefs: { delivered: number; withFail: number };
|
|
61
74
|
};
|
|
62
75
|
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
63
76
|
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
64
77
|
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
65
78
|
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
66
79
|
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
67
|
-
approvedAt: null, lastState: null });
|
|
80
|
+
approvedAt: null, lastState: null, tooling: keyed(), peers: new Map(), briefs: { delivered: 0, withFail: 0 } });
|
|
68
81
|
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
69
82
|
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
70
83
|
|
|
@@ -77,18 +90,51 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
77
90
|
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
78
91
|
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
79
92
|
const seenUsage = new Set<string>();
|
|
93
|
+
// A fingerprint belongs to one attachment of its peer: from a state event that brings it online to the next offline.
|
|
94
|
+
// Detection settles in the background, so it can arrive after the peer's first work in that attachment.
|
|
95
|
+
const attach = new Map<string, number>(), online = new Set<string>();
|
|
96
|
+
const tooling = new Map<string, { attach: number; addons: Addon[] }>();
|
|
97
|
+
/** A peer that worked on the task carries the fingerprint of the attachment it worked in. */
|
|
98
|
+
/**
|
|
99
|
+
* `acted`: the peer did something for the task (an attributed turn, tokens, the actor of a board move). A peer only
|
|
100
|
+
* named on a move (the owner or reviewer an approval lists) joins the task once and never moves to a later
|
|
101
|
+
* attachment, where it did no work.
|
|
102
|
+
*/
|
|
103
|
+
const involve = (a: Acc, peer: string | null | undefined, acted = true) => {
|
|
104
|
+
if (!peer || peer === "hub" || peer === "user" || (!acted && a.peers.has(peer))) return;
|
|
105
|
+
const now = attach.get(peer) ?? 0, f = tooling.get(peer);
|
|
106
|
+
a.peers.set(peer, now);
|
|
107
|
+
if (f?.attach === now) a.tooling[peer] = f.addons;
|
|
108
|
+
else delete a.tooling[peer]; // a fingerprint of an earlier attachment does not describe this work: unknown until one comes
|
|
109
|
+
};
|
|
80
110
|
for (const e of events) {
|
|
111
|
+
// A crash writes no offline: a new run is a new attachment, and no fingerprint from before it describes later work.
|
|
112
|
+
if (e.type === "hub_start") { online.clear(); tooling.clear(); continue; }
|
|
113
|
+
if (e.type === "state") {
|
|
114
|
+
if (e.state === "offline") online.delete(e.peer);
|
|
115
|
+
else if (!online.has(e.peer)) { online.add(e.peer); attach.set(e.peer, (attach.get(e.peer) ?? 0) + 1); }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (e.type === "tooling") {
|
|
119
|
+
const now = attach.get(e.peer) ?? 0;
|
|
120
|
+
tooling.set(e.peer, { attach: now, addons: e.addons });
|
|
121
|
+
for (const a of tasks.values()) if (a.peers.get(e.peer) === now) a.tooling[e.peer] = e.addons;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
81
124
|
if (e.type === "task") {
|
|
125
|
+
const proposed = e.event === "proposed" || e.event === "backlog pulled";
|
|
82
126
|
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
83
|
-
if (e.event === "proposed" && tasks.
|
|
127
|
+
if ((e.event === "proposed" && tasks.has(e.id)) || (e.event === "backlogged" && tasks.has(e.id))) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
|
|
84
128
|
const a = acc(e.id);
|
|
85
|
-
|
|
129
|
+
for (const field of ["release", "priority", "plannedStart", "due"] as const) if (Object.hasOwn(e, field)) a[field] = e[field];
|
|
130
|
+
a.class = e.class; if (e.role && a.role === undefined) { a.role = e.role; if (e.event === "proposed" && e.roleIdentity) a.roleIdentity = e.roleIdentity; } if (e.profile) { a.profile = e.profile; a.profileClass = e.profileClass; } if (e.pii) a.pii = true;
|
|
131
|
+
if (proposed && e.role === a.role && e.roleIdentity && a.roleIdentity === undefined) a.roleIdentity = e.roleIdentity;
|
|
86
132
|
if (e.owner && e.owner !== a.owners.at(-1)) {
|
|
87
133
|
if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
|
|
88
134
|
a.owners.push(e.owner);
|
|
89
135
|
}
|
|
90
136
|
if (e.reviewer) a.reviewer = e.reviewer;
|
|
91
|
-
if (
|
|
137
|
+
if (proposed) a.createdAt ??= e.at;
|
|
92
138
|
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
93
139
|
if (e.event === "done") a.dones++;
|
|
94
140
|
if (e.event === "check passed") a.checkPassed++;
|
|
@@ -97,14 +143,19 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
97
143
|
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
98
144
|
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
99
145
|
a.lastState = e.state;
|
|
146
|
+
involve(a, e.by); involve(a, e.owner, false); involve(a, e.reviewer, false);
|
|
100
147
|
continue;
|
|
101
148
|
}
|
|
102
149
|
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
103
150
|
if (task === undefined) continue;
|
|
104
151
|
const a = acc(task);
|
|
105
152
|
if ("pii" in e && e.pii === true) a.pii = true;
|
|
153
|
+
if ("peer" in e && typeof e.peer === "string") involve(a, e.peer);
|
|
106
154
|
if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
|
|
107
|
-
else if (e.type === "turn_end") {
|
|
155
|
+
else if (e.type === "turn_end") {
|
|
156
|
+
a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0;
|
|
157
|
+
if (e.preparedRoles !== undefined) (a.sessionRoles ??= []).push({ peer: e.peer, turn: e.turn, roles: e.preparedRoles?.map(role => ({ id: role.id, layer: role.layer, hash: role.hash })) ?? null });
|
|
158
|
+
}
|
|
108
159
|
else if (e.type === "usage") {
|
|
109
160
|
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
110
161
|
if (seenUsage.has(key)) continue;
|
|
@@ -117,6 +168,7 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
117
168
|
}
|
|
118
169
|
else if (e.type === "route") a.models.add(e.route);
|
|
119
170
|
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
171
|
+
else if (e.type === "memory_brief") { a.briefs.delivered++; if (e.fails > 0) a.briefs.withFail++; }
|
|
120
172
|
else if (e.type === "stuck") a.stuck++;
|
|
121
173
|
else if (e.type === "overlap") a.overlaps++;
|
|
122
174
|
else if (e.type === "conflict") a.conflicts++;
|
|
@@ -124,11 +176,12 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
124
176
|
const out: TaskRecord[] = [];
|
|
125
177
|
for (const a of [...earlier, ...tasks.values()]) {
|
|
126
178
|
if (!a.approvedAt) continue;
|
|
127
|
-
const { approvedAt, lastState: _state, models, ...rest } = a;
|
|
179
|
+
const { approvedAt, lastState: _state, models, tooling: fingerprints, peers, briefs, ...rest } = a;
|
|
128
180
|
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
|
|
129
181
|
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
|
|
130
182
|
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
131
|
-
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer
|
|
183
|
+
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer,
|
|
184
|
+
...(Object.keys(fingerprints).length ? { tooling: fingerprints, peers: [...peers.keys()].sort() } : {}), ...(briefs.delivered ? { briefs } : {}) });
|
|
132
185
|
}
|
|
133
186
|
return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
|
|
134
187
|
}
|
|
@@ -175,7 +228,11 @@ export function labelTarget(records: ResearchRecord[], events: StampedEvent[], t
|
|
|
175
228
|
if (created !== undefined) {
|
|
176
229
|
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
177
230
|
}
|
|
178
|
-
const
|
|
231
|
+
const creation = events.findLast(e => e.type === "task" && e.id === task && ["proposed", "backlogged"].includes(e.event));
|
|
232
|
+
const proposed = creation?.type === "task" && creation.event === "backlogged"
|
|
233
|
+
? events.slice(events.indexOf(creation) + 1).find(e => e.type === "task" && e.id === task && e.event === "backlog pulled")
|
|
234
|
+
: creation;
|
|
235
|
+
if (creation?.type === "task" && creation.event === "backlogged" && !proposed) fail(`task #${task} is backlog and has no execution proposal; name an earlier record with --created`);
|
|
179
236
|
if (proposed) {
|
|
180
237
|
return mine.find((r) => r.createdAt === proposed.at)
|
|
181
238
|
?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
|
|
@@ -207,19 +264,15 @@ function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
|
|
|
207
264
|
tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
|
|
208
265
|
wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
|
|
209
266
|
}
|
|
210
|
-
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures
|
|
267
|
+
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures>;
|
|
268
|
+
/** #258 AC11: tasks whose brief carried a hub-memory fail note, a brief without one, or no brief recorded. */
|
|
269
|
+
byMemoryBrief: Record<string, Measures> }
|
|
211
270
|
/**
|
|
212
271
|
* The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
|
|
213
272
|
* decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
|
|
214
273
|
*/
|
|
215
274
|
export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
|
|
216
|
-
const
|
|
217
|
-
for (const r of records) {
|
|
218
|
-
const key = recordKey(r);
|
|
219
|
-
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
220
|
-
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
221
|
-
}
|
|
222
|
-
const all = [...latest.values()];
|
|
275
|
+
const { all, labels } = latestRecords(records, since);
|
|
223
276
|
const group = (keys: (r: TaskRecord) => string[]) => {
|
|
224
277
|
// A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
|
|
225
278
|
const out = new Map<string, TaskRecord[]>();
|
|
@@ -227,7 +280,8 @@ export function researchReport(records: ResearchRecord[], since = 0): ResearchRe
|
|
|
227
280
|
return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
|
|
228
281
|
};
|
|
229
282
|
return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
|
|
230
|
-
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"])
|
|
283
|
+
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]),
|
|
284
|
+
byMemoryBrief: group((r) => [!r.briefs ? "no brief recorded" : r.briefs.withFail ? "a fail note in its brief" : "brief without a fail note"]) };
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
@@ -236,7 +290,76 @@ export function formatResearch(r: ResearchReport): string[] {
|
|
|
236
290
|
const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
|
|
237
291
|
const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
238
292
|
const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
|
|
239
|
-
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
|
|
293
|
+
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject), ...(Object.keys(r.byMemoryBrief).some((k) => k !== "no brief recorded") ? section("by memory brief", r.byMemoryBrief) : [])];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Each approved task once, its last record, and every task's latest label: what both reports count. */
|
|
297
|
+
function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
|
|
298
|
+
const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
|
|
299
|
+
for (const r of records) {
|
|
300
|
+
const key = recordKey(r);
|
|
301
|
+
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
302
|
+
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
303
|
+
}
|
|
304
|
+
return { all: [...latest.values()], labels };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The peers that worked on a task: every peer with an attributed event, else (older records) owners, reviewer and token peers. */
|
|
308
|
+
const involved = (r: TaskRecord): string[] => [...new Set([...(r.peers ?? []), ...r.owners, ...(r.reviewer ? [r.reviewer] : []), ...Object.keys(r.tokens.byPeer)])].filter((p) => p !== "user");
|
|
309
|
+
/** Own properties only: a peer may be named `constructor`, and a parsed record's object would answer with an inherited one. */
|
|
310
|
+
const fingerprintOf = (r: TaskRecord, peer: string): Addon[] | undefined => r.tooling && Object.hasOwn(r.tooling, peer) ? r.tooling[peer] : undefined;
|
|
311
|
+
|
|
312
|
+
type AddonMeasure = "successRate" | "firstPassRate" | "reworkRate" | "checkFailureRate" | "tokensMedian" | "wallMsMedian" | "collisionRate";
|
|
313
|
+
const ADDON_MEASURES: AddonMeasure[] = ["successRate", "firstPassRate", "reworkRate", "checkFailureRate", "tokensMedian", "wallMsMedian", "collisionRate"];
|
|
314
|
+
/** Every add-on measure of one set of tasks, computed once (a bootstrap calls this per resample). */
|
|
315
|
+
function addonValues(rs: TaskRecord[], labels: Map<string, Label>): Record<AddonMeasure, number | null> {
|
|
316
|
+
const x = measures(rs, labels);
|
|
317
|
+
return { successRate: x.successRate, firstPassRate: x.firstPassRate, reworkRate: x.reworkRate, checkFailureRate: x.checkFailureRate,
|
|
318
|
+
tokensMedian: x.tokensPerTask.median, wallMsMedian: x.wallMsPerTask.median, collisionRate: pct(rs.reduce((n, r) => n + r.overlaps + r.conflicts, 0), rs.length) };
|
|
319
|
+
}
|
|
320
|
+
export interface AddonComparison {
|
|
321
|
+
addon: AddonName;
|
|
322
|
+
/** Tasks where a peer that worked on it had the add-on, where every such peer is known not to, and the rest. */
|
|
323
|
+
on: number; off: number; unknown: number;
|
|
324
|
+
measures: { measure: AddonMeasure; on: number | null; off: number | null; diff: number | null; low: number | null; high: number | null; inconclusive: boolean }[];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* #248, observational: approved tasks with each add-on on against those with it off, by the tooling fingerprint each
|
|
328
|
+
* record carries. The difference is on minus off with a 95% bootstrap interval (seeded, so the same records give the
|
|
329
|
+
* same interval); below MIN_ATTEMPTS tasks on either side it is inconclusive. Field data shows association only: the
|
|
330
|
+
* peers, task mix and time differ between the sides. The causal side is `ahub bench` arms (docs/bench.md).
|
|
331
|
+
*/
|
|
332
|
+
export function addonReport(records: ResearchRecord[], since = 0, resamples = 1000, seed = 248): AddonComparison[] {
|
|
333
|
+
const { all, labels } = latestRecords(records, since);
|
|
334
|
+
return ADDONS.map((addon) => {
|
|
335
|
+
const side = { on: [] as TaskRecord[], off: [] as TaskRecord[], unknown: [] as TaskRecord[] };
|
|
336
|
+
for (const r of all) side[addonState(addon, involved(r).map((p) => fingerprintOf(r, p)))].push(r);
|
|
337
|
+
const random = mulberry32(seed);
|
|
338
|
+
const pick = (xs: TaskRecord[]) => Array.from({ length: xs.length }, () => xs[Math.floor(random() * xs.length)]!);
|
|
339
|
+
const samples = new Map<AddonMeasure, number[]>(ADDON_MEASURES.map((m) => [m, []]));
|
|
340
|
+
if (side.on.length && side.off.length) for (let i = 0; i < resamples; i++) {
|
|
341
|
+
const a = addonValues(pick(side.on), labels), b = addonValues(pick(side.off), labels);
|
|
342
|
+
for (const m of ADDON_MEASURES) { const x = a[m], y = b[m]; if (x !== null && y !== null) samples.get(m)!.push(x - y); }
|
|
343
|
+
}
|
|
344
|
+
const onValues = addonValues(side.on, labels), offValues = addonValues(side.off, labels);
|
|
345
|
+
return { addon, on: side.on.length, off: side.off.length, unknown: side.unknown.length, measures: ADDON_MEASURES.map((measure) => {
|
|
346
|
+
const on = onValues[measure], off = offValues[measure];
|
|
347
|
+
const s = samples.get(measure)!.sort((x, y) => x - y);
|
|
348
|
+
const rank = (q: number) => s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))]!;
|
|
349
|
+
const diff = on !== null && off !== null ? on - off : null;
|
|
350
|
+
return { measure, on, off, diff, low: s.length ? rank(0.025) : null, high: s.length ? rank(0.975) : null,
|
|
351
|
+
inconclusive: diff === null || side.on.length < MIN_ATTEMPTS || side.off.length < MIN_ATTEMPTS };
|
|
352
|
+
}) };
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
export function formatAddons(rows: AddonComparison[]): string[] {
|
|
356
|
+
const fmt = (m: AddonMeasure, v: number | null) => v === null ? "-" : m === "tokensMedian" ? String(Math.round(v)) : m === "wallMsMedian" ? `${Math.round(v / 60_000)}m` : m === "reworkRate" || m === "collisionRate" ? v.toFixed(2) : `${Math.round(v * 100)}%`;
|
|
357
|
+
const out = ["add-on tasks with it on / off / unknown; on minus off with a 95% bootstrap interval (association, not cause)"];
|
|
358
|
+
for (const r of rows) {
|
|
359
|
+
out.push("", `${r.addon}: ${r.on} on, ${r.off} off, ${r.unknown} unknown`);
|
|
360
|
+
for (const m of r.measures) out.push(` ${m.measure.padEnd(16)} on ${fmt(m.measure, m.on).padStart(6)} off ${fmt(m.measure, m.off).padStart(6)} diff ${fmt(m.measure, m.diff).padStart(6)} (${fmt(m.measure, m.low)} to ${fmt(m.measure, m.high)})${m.inconclusive ? ` inconclusive (fewer than ${MIN_ATTEMPTS} tasks on a side)` : ""}`);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
240
363
|
}
|
|
241
364
|
|
|
242
365
|
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|