@staix/agent-hub 0.17.0 → 0.19.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 +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { DogfoodInput, Source } from "./dogfood.ts";
|
|
2
|
+
import { CLASSES, type TaskState } from "./board.ts";
|
|
3
|
+
import { PII_CATEGORIES, type ScreenItem, type ScreenMiss, type ScreenRecord } from "./inference.ts";
|
|
4
|
+
import type { Attribution } from "./attribution.ts";
|
|
5
|
+
import { PROFILE_NAMES, projectProfile, type ProfileName } from "./profiles.ts";
|
|
6
|
+
import { summarizeByTask } from "./report.ts";
|
|
7
|
+
import type { StampedEvent } from "./events.ts";
|
|
8
|
+
|
|
9
|
+
export interface ProfileHistory {
|
|
10
|
+
at: number; event: string; criteria?: string[]; criteriaPrivate?: true; deliverables?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ProfileTaskMetadata {
|
|
13
|
+
profile: ProfileName; state?: string; class: string; alias: string | null; history: ProfileHistory[];
|
|
14
|
+
plan?: { sources: number | null; claims: number | null };
|
|
15
|
+
}
|
|
16
|
+
const complete = (s: Source<unknown>) => s.available && s.complete;
|
|
17
|
+
const number = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
18
|
+
const declarationCount = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number)>=0 && (n as number)<=50;
|
|
19
|
+
const positive = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) > 0;
|
|
20
|
+
const peer = (s: string) => /^[a-z][a-z0-9-]{0,63}$/.test(s) && !Object.hasOwn(Object.prototype, s);
|
|
21
|
+
export const dogfoodProfileName = (s: unknown): s is ProfileName => typeof s === "string" && (PROFILE_NAMES as readonly string[]).includes(s);
|
|
22
|
+
const baseClass = (s: unknown): s is string => typeof s === "string" && (CLASSES as readonly string[]).includes(s);
|
|
23
|
+
const criterion = (s: unknown): s is string => typeof s === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(s);
|
|
24
|
+
|
|
25
|
+
// Exhaustive maps make producer union changes a compile-time review point.
|
|
26
|
+
const taskStates: Record<TaskState,true> = {backlog:true,proposed:true,in_progress:true,in_review:true,approved:true,changes_requested:true,cancelled:true};
|
|
27
|
+
const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true};
|
|
28
|
+
const attributions: Record<Attribution,true> = {delivery:true,single_open:true,unattributed:true};
|
|
29
|
+
const screenItems: Record<ScreenItem,true> = {task:true,summary:true,"review note":true,handoff:true,note:true};
|
|
30
|
+
const screenMisses: Record<ScreenMiss,true> = {"too long":true,"off campus":true,timeout:true,unreadable:true,failed:true};
|
|
31
|
+
const screenSources: Record<ScreenRecord["source"],true> = {regex:true,screen:true,unknown:true,inherited:true};
|
|
32
|
+
const screenLabels: Record<ScreenRecord["label"],true> = {pii:true,clear:true};
|
|
33
|
+
const member = (value: unknown, choices: object) => typeof value === "string" && Object.hasOwn(choices,value);
|
|
34
|
+
|
|
35
|
+
/** One validation boundary for the profile consumer and its bounded collector. Unknown event types stay forward compatible. */
|
|
36
|
+
export function profileEventValid(value: unknown): boolean {
|
|
37
|
+
if (!value || typeof value!=="object" || Array.isArray(value)) return false;
|
|
38
|
+
const e=value as Record<string,unknown>;
|
|
39
|
+
if (e.v!==1 || typeof e.at!=="string" || !Number.isFinite(Date.parse(e.at)) || typeof e.type!=="string") return false;
|
|
40
|
+
const optional = (key:string, valid:(value:unknown)=>boolean) => e[key]===undefined || valid(e[key]);
|
|
41
|
+
const text = (value:unknown): value is string => typeof value==="string" && value.length>0;
|
|
42
|
+
const peerId = (value:unknown) => typeof value==="string" && peer(value);
|
|
43
|
+
const strings = (value:unknown) => Array.isArray(value) && value.every(v=>typeof v==="string");
|
|
44
|
+
const bool = (value:unknown) => typeof value==="boolean";
|
|
45
|
+
const oneOf = (value:unknown, choices:readonly string[]) => typeof value==="string" && choices.includes(value);
|
|
46
|
+
const taskId = () => optional("task",positive);
|
|
47
|
+
if (e.type==="task") {
|
|
48
|
+
return positive(e.id) && baseClass(e.class) && text(e.event)
|
|
49
|
+
&& member(e.state,taskStates)
|
|
50
|
+
&& optional("profile",dogfoodProfileName) && optional("profileClass",v=>typeof v==="string" && /^[a-z][a-z0-9_]{0,47}$/.test(v))
|
|
51
|
+
// Older metadata projections may omit privacy; absence remains conservative at event-only binding.
|
|
52
|
+
&& optional("pii",bool) && optional("by",peerId)
|
|
53
|
+
&& optional("owner",v=>v===null || peerId(v)) && optional("reviewer",v=>v===null || peerId(v));
|
|
54
|
+
}
|
|
55
|
+
if (e.type==="overlap") {
|
|
56
|
+
return positive(e.task) && optional("owner",peerId) && Array.isArray(e.others) && e.others.every(other=>{
|
|
57
|
+
if (!other || typeof other!=="object" || Array.isArray(other)) return false;
|
|
58
|
+
return positive(other.task) && strings(other.paths) && (other.owner===undefined || peerId(other.owner))
|
|
59
|
+
&& (other.symbols===undefined || strings(other.symbols));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (e.type==="conflict") {
|
|
63
|
+
return taskId() && positive(e.other) && strings(e.paths) && bool(e.concurrent)
|
|
64
|
+
&& optional("peer",peerId) && optional("owner",peerId)
|
|
65
|
+
&& optional("turns",v=>Array.isArray(v) && v.length===2 && v.every(text));
|
|
66
|
+
}
|
|
67
|
+
if (e.type==="tokens" || e.type==="usage" || e.type==="turn_end") {
|
|
68
|
+
if (!peerId(e.peer) || !taskId() || !optional("pii",bool)
|
|
69
|
+
|| !optional("attribution",v=>member(v,attributions))) return false;
|
|
70
|
+
if ((e.attribution==="delivery" || e.attribution==="single_open") && !positive(e.task)) return false;
|
|
71
|
+
if (e.type==="tokens") return number(e.n);
|
|
72
|
+
if (e.type==="turn_end") return number(e.ms) && optional("turn",text) && optional("snapshotMs",number) && optional("tokens",number) && optional("files",number);
|
|
73
|
+
return text(e.id) && member(e.source,usageSources)
|
|
74
|
+
&& ["inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens","reasoningTokens"].every(key=>optional(key,number))
|
|
75
|
+
&& optional("measuredAt",v=>typeof v==="string" && Number.isFinite(Date.parse(v)));
|
|
76
|
+
}
|
|
77
|
+
if (e.type==="pii_screen") {
|
|
78
|
+
return taskId() && member(e.item,screenItems) && member(e.label,screenLabels)
|
|
79
|
+
&& member(e.source,screenSources) && optional("ms",number)
|
|
80
|
+
&& optional("category",v=>oneOf(v,PII_CATEGORIES))
|
|
81
|
+
&& optional("miss",v=>member(v,screenMisses));
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Private custom aliases never become grouping keys; public aliases remain bounded stored identifiers. */
|
|
87
|
+
export function dogfoodAlias(profile: ProfileName, base: string, raw: unknown, privateTask: boolean): string | null {
|
|
88
|
+
if (raw == null) return baseClass(base) ? base : null;
|
|
89
|
+
if (typeof raw !== "string" || !/^[a-z][a-z0-9_]{0,47}$/.test(raw) || Object.hasOwn(Object.prototype, raw)) return "withheld";
|
|
90
|
+
if (Object.hasOwn(projectProfile(profile).classes, raw) && projectProfile(profile).classes[raw] === base) return raw;
|
|
91
|
+
return privateTask ? "withheld" : raw;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type BoundTask = { id: number; pii: boolean; metadata: ProfileTaskMetadata; full: boolean };
|
|
95
|
+
function quantiles(values: number[]) {
|
|
96
|
+
values.sort((a,b) => a-b);
|
|
97
|
+
const at = (q: number) => values.length ? values[Math.ceil(values.length*q)-1]! : null;
|
|
98
|
+
return { samples: values.length, p50: at(.5), p90: at(.9) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Additive metadata projection. No PR, file, model or config execution and no telemetry inference from prose. */
|
|
102
|
+
export function profileScorecard(input: DogfoodInput) {
|
|
103
|
+
// Do not trust a direct Source caller's completeness claim after a malformed consumed record.
|
|
104
|
+
const validEvents=input.events.data.filter(profileEventValid);
|
|
105
|
+
input={...input,events:{...input.events,data:validEvents,complete:input.events.complete && validEvents.length===input.events.data.length}};
|
|
106
|
+
const inWindow = (at: number) => number(at) && at >= input.since && at <= input.until;
|
|
107
|
+
const events = input.events.data.filter(e => inWindow(Date.parse(e.at)));
|
|
108
|
+
const bindingLimit = 500;
|
|
109
|
+
let bindingsTruncated = false;
|
|
110
|
+
const observedProfiles = new Set<ProfileName>();
|
|
111
|
+
const bound = new Map<number, BoundTask>();
|
|
112
|
+
for (const task of input.tasks.data) {
|
|
113
|
+
const m = task.profileMetadata;
|
|
114
|
+
if (!positive(task.id) || !m || !dogfoodProfileName(m.profile) || !baseClass(m.class)) continue;
|
|
115
|
+
if (m.history.some(h=>inWindow(h.at))) observedProfiles.add(m.profile);
|
|
116
|
+
if (!bound.has(task.id) && bound.size>=bindingLimit) { bindingsTruncated=true; continue; }
|
|
117
|
+
bound.set(task.id, {id: task.id, pii: task.pii, metadata: {...m, alias: dogfoodAlias(m.profile,m.class,m.alias,task.pii)}, full: complete(input.tasks)});
|
|
118
|
+
}
|
|
119
|
+
// Index at most 500 identities once. Beyond-cap profile names remain visible, but never aliases or task rows.
|
|
120
|
+
const taskEvents = new Map<number, Extract<StampedEvent,{type:"task"}>[]>([...bound.keys()].map(id=>[id,[]]));
|
|
121
|
+
let eventBindingsComplete = true;
|
|
122
|
+
for (const e of events) {
|
|
123
|
+
if (e.type!=="task") continue;
|
|
124
|
+
if (dogfoodProfileName(e.profile)) observedProfiles.add(e.profile);
|
|
125
|
+
if (!positive(e.id)) {eventBindingsComplete=false;continue;}
|
|
126
|
+
if (!taskEvents.has(e.id)) {
|
|
127
|
+
if (!dogfoodProfileName(e.profile) || !baseClass(e.class)) {eventBindingsComplete=false;continue;}
|
|
128
|
+
if (taskEvents.size>=bindingLimit) {bindingsTruncated=true;eventBindingsComplete=false;continue;}
|
|
129
|
+
taskEvents.set(e.id,[]);
|
|
130
|
+
}
|
|
131
|
+
taskEvents.get(e.id)!.push(e);
|
|
132
|
+
}
|
|
133
|
+
// Event windows prove recorded milestones, but cannot restore pre-window history or first-pass review.
|
|
134
|
+
for (const [id, recorded] of taskEvents) {
|
|
135
|
+
const existing = bound.get(id);
|
|
136
|
+
if (existing) { if (recorded.some(e=>e.pii)) existing.pii=true; continue; }
|
|
137
|
+
const e=recorded.find(e=>dogfoodProfileName(e.profile) && baseClass(e.class));
|
|
138
|
+
if (!e || !dogfoodProfileName(e.profile)) {eventBindingsComplete=false;continue;}
|
|
139
|
+
const privateTask=recorded.some(e=>e.pii!==false);
|
|
140
|
+
const history=recorded.map(e=>({at:Date.parse(e.at),event:e.event}));
|
|
141
|
+
bound.set(id,{id,pii:privateTask,full:false,metadata:{profile:e.profile,class:e.class,alias:dogfoodAlias(e.profile,e.class,e.profileClass,privateTask),history}});
|
|
142
|
+
}
|
|
143
|
+
const activeIds = new Set<number>();
|
|
144
|
+
for (const e of events) {
|
|
145
|
+
const id = e.type === "task" ? e.id : "task" in e ? e.task : undefined;
|
|
146
|
+
if (positive(id) && bound.has(id)) {
|
|
147
|
+
activeIds.add(id);
|
|
148
|
+
if ("pii" in e && e.pii) bound.get(id)!.pii=true;
|
|
149
|
+
}
|
|
150
|
+
if (e.type === "overlap" && Array.isArray(e.others)) for (const other of e.others) if (other && bound.has(other.task)) activeIds.add(other.task);
|
|
151
|
+
if (e.type === "conflict" && bound.has(e.other)) activeIds.add(e.other);
|
|
152
|
+
}
|
|
153
|
+
eventBindingsComplete &&= !bindingsTruncated;
|
|
154
|
+
const eventsFull = complete(input.events) && eventBindingsComplete;
|
|
155
|
+
const bindingsComplete = !bindingsTruncated && complete(input.tasks) && input.tasks.data.every(t=>bound.has(t.id));
|
|
156
|
+
const edits = new Map<string, Map<string, number>>();
|
|
157
|
+
const pair = (a:number,b:number) => [a,b].sort((x,y)=>x-y).join(":");
|
|
158
|
+
for (const e of events) {
|
|
159
|
+
if (e.type!=="conflict" || !positive(e.task) || !positive(e.other) || !Array.isArray(e.paths)) continue;
|
|
160
|
+
const key=pair(e.task,e.other), paths=edits.get(key)??new Map<string,number>();
|
|
161
|
+
for (const path of e.paths) if (typeof path==="string") paths.set(path,Math.max(paths.get(path)??0,Date.parse(e.at)));
|
|
162
|
+
edits.set(key,paths);
|
|
163
|
+
}
|
|
164
|
+
const selectedProjectProfile = input.projectProfile && complete(input.projectProfile) && dogfoodProfileName(input.projectProfile.data) ? input.projectProfile.data : null;
|
|
165
|
+
const active = [...bound.values()].filter(t => t.metadata.history.some(h => inWindow(h.at)) || activeIds.has(t.id));
|
|
166
|
+
if (![...observedProfiles].some(profile=>profile!=="dev") && !active.some(t => t.metadata.profile !== "dev") && (selectedProjectProfile === null || selectedProjectProfile === "dev")) return {};
|
|
167
|
+
const profiles = [...new Set([...observedProfiles,...active.map(t=>t.metadata.profile), ...(selectedProjectProfile && selectedProjectProfile!=="dev" ? [selectedProjectProfile] : [])])].sort();
|
|
168
|
+
const rows = profiles.flatMap(profile => {
|
|
169
|
+
const tasks = active.filter(t=>t.metadata.profile === profile);
|
|
170
|
+
const groups: {alias: string|null; base: string|null; tasks: BoundTask[]}[] = [{alias:null,base:null,tasks}];
|
|
171
|
+
for (const task of tasks) {
|
|
172
|
+
const alias = dogfoodAlias(profile,task.metadata.class,task.metadata.alias,task.pii);
|
|
173
|
+
const group = groups.find(g=>g.base === task.metadata.class && g.alias === alias);
|
|
174
|
+
if (group) group.tasks.push(task); else groups.push({alias,base:task.metadata.class,tasks:[task]});
|
|
175
|
+
}
|
|
176
|
+
return groups.map(group => {
|
|
177
|
+
const ids = new Set(group.tasks.map(t=>t.id));
|
|
178
|
+
const full = bindingsComplete && group.tasks.every(t=>t.full);
|
|
179
|
+
const history = group.tasks.flatMap(t=>t.metadata.history.filter(h=>inWindow(h.at)).map(h=>({...h,task:t})));
|
|
180
|
+
const recorded = [...ids].flatMap(id=>taskEvents.get(id)??[]);
|
|
181
|
+
const autoApprovals = recorded.filter(e=>e.event==="done" && e.state==="approved");
|
|
182
|
+
const milestones = full ? history : recorded.map(e=>({at:Date.parse(e.at),event:e.event,task:bound.get(e.id)!}));
|
|
183
|
+
const milestoneKnown = full || eventsFull;
|
|
184
|
+
const scope = full ? "history" : eventsFull ? "recorded_events" : null;
|
|
185
|
+
const count = (event: string) => milestoneKnown ? new Set(milestones.filter(h=>h.event===event || event==="proposed" && h.event==="backlog pulled").map(h=>h.task.id)).size : null;
|
|
186
|
+
const approvalIds = new Set(milestones.filter(h=>h.event==="approved").map(h=>h.task.id));
|
|
187
|
+
for (const e of autoApprovals) approvalIds.add(e.id);
|
|
188
|
+
// A current approved state alone cannot date a reviewer-less done. Never place it in this window by guessing.
|
|
189
|
+
const approvalUnknown = full && group.tasks.some(t=>t.metadata.state==="approved" && !t.metadata.history.some(h=>h.event==="approved") && !autoApprovals.some(e=>e.id===t.id));
|
|
190
|
+
const leads: number[] = [], first: number[] = [];
|
|
191
|
+
for (const task of group.tasks) {
|
|
192
|
+
const all = task.metadata.history.filter(h=>h.at<=input.until).sort((a,b)=>a.at-b.at);
|
|
193
|
+
const proposed = all.find(h=>h.event==="proposed" || h.event==="backlog pulled");
|
|
194
|
+
const approval = all.findLast(h=>h.event==="approved" && inWindow(h.at));
|
|
195
|
+
const automatic = autoApprovals.filter(e=>e.id===task.id).map(e=>Date.parse(e.at)).sort((a,b)=>b-a)[0];
|
|
196
|
+
const approvedAt = approval?.at ?? automatic;
|
|
197
|
+
if (proposed && approvedAt!==undefined && approvedAt>=proposed.at) leads.push(approvedAt-proposed.at);
|
|
198
|
+
// An automatic completion is an approval transition, never a reviewer verdict or first-pass review sample.
|
|
199
|
+
if (approval) { const verdict = all.find(h=>h.event==="approved" || h.event==="changes_requested"); if (verdict) first.push(Number(verdict.event==="approved")); }
|
|
200
|
+
}
|
|
201
|
+
const verdicts = milestones.filter(h=>h.event==="approved" || h.event==="changes_requested");
|
|
202
|
+
const rounds = verdicts.length;
|
|
203
|
+
const rejectionCount = verdicts.filter(h=>h.event==="changes_requested").length;
|
|
204
|
+
const rejections = history.filter(h=>h.event==="changes_requested");
|
|
205
|
+
const criteriaKnown = full && rejections.every(h=>h.criteria !== undefined);
|
|
206
|
+
const criteria = new Map<string,number>(); let privateCriteria = 0;
|
|
207
|
+
for (const h of rejections) {
|
|
208
|
+
for (const id of h.criteria ?? []) {
|
|
209
|
+
if (h.task.pii || h.criteriaPrivate) privateCriteria++; else if (criterion(id)) criteria.set(id,(criteria.get(id)??0)+1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const selected = events.filter(e => "task" in e && typeof e.task === "number" && ids.has(e.task));
|
|
213
|
+
const overlaps = events.filter((e): e is Extract<StampedEvent,{type:"overlap"}> => e.type==="overlap" && positive(e.task) && Array.isArray(e.others) && (ids.has(e.task) || e.others.some(o=>o && ids.has(o.task))));
|
|
214
|
+
const warnings = overlaps.flatMap(e=>e.others.filter(o=>o && positive(o.task) && (ids.has(e.task) || ids.has(o.task)) && Array.isArray(o.paths)).map(o=>({at:Date.parse(e.at),a:e.task,b:o.task,paths:o.paths,sections:Array.isArray(o.symbols) && o.symbols.some(s=>typeof s==="string" && s.startsWith("section "))})));
|
|
215
|
+
// conflict is snapshot/fact edit evidence, not another overlap advisory. Concurrent snapshots do not assign authorship.
|
|
216
|
+
const confirmed = new Set<string>();
|
|
217
|
+
for (const warning of warnings) {
|
|
218
|
+
const key=pair(warning.a,warning.b), edited=edits.get(key);
|
|
219
|
+
if (warning.paths.some(p=>typeof p==="string" && (edited?.get(p)??0)>warning.at)) confirmed.add(key);
|
|
220
|
+
}
|
|
221
|
+
const snapshots = selected.flatMap(e=>e.type==="turn_end" && (e.attribution==="delivery" || e.attribution==="single_open") && number(e.snapshotMs) ? [e.snapshotMs] : []);
|
|
222
|
+
const accounting = selected.filter(e => (e.type==="tokens" || e.type==="usage" || e.type==="turn_end") && peer(e.peer) && (e.attribution==="delivery" || e.attribution==="single_open") && (e.type!=="tokens" || number(e.n)));
|
|
223
|
+
const report = summarizeByTask(accounting);
|
|
224
|
+
const costs = [...ids].sort((a,b)=>a-b).map(task=>({task,peers:complete(input.events) && report.tasks[String(task)] ? Object.fromEntries(Object.entries(report.tasks[String(task)]!.peers).map(([id,p])=>[id,{tokens:p.tokens,input:p.usage.inputTokens,output:p.usage.outputTokens,cacheRead:p.usage.cacheReadTokens,cacheWrite:p.usage.cacheWriteTokens,total:p.usage.totalTokens,records:p.usage.records}])) : null}));
|
|
225
|
+
const screened = selected.filter(e=>e.type==="pii_screen" && e.item==="note" && e.label==="pii");
|
|
226
|
+
const eventCount = (n:number) => full && complete(input.events) ? n : null;
|
|
227
|
+
const planCount = (t: BoundTask,key:"sources"|"claims") => {
|
|
228
|
+
const count=t.metadata.plan?.[key];
|
|
229
|
+
return full && declarationCount(count) ? count : null;
|
|
230
|
+
};
|
|
231
|
+
const research = profile === "research" ? {research:{
|
|
232
|
+
declarations:{scope:"current_plan",perTask:group.tasks.map(t=>({task:t.id,sources:planCount(t,"sources"),claims:planCount(t,"claims")}))},
|
|
233
|
+
claims:{reachedPersonVerdict:null,perTask:group.tasks.map(t=>{const count=planCount(t,"claims");return {task:t.id,claims:count===null?null:Array.from({length:count},(_,i)=>({slot:i+1,modelLabel:null,personVerdict:null,agreement:null}))};})},
|
|
234
|
+
observedCheckOutcomes:{scope:"task_check_history",passed:full?history.filter(h=>h.event==="check passed").length:null,failed:full?history.filter(h=>h.event==="check failed").length:null},
|
|
235
|
+
factCheck:{scope:null,selection:null,passed:null,failed:null,missingKeysPerRun:null,
|
|
236
|
+
agreement:["supported","unsupported","unclear"].map(label=>({label,count:null}))}
|
|
237
|
+
}} : {};
|
|
238
|
+
return {...research,profile, alias:group.alias, baseClass:group.base, tasks:[...ids].sort((a,b)=>a-b),
|
|
239
|
+
flow:{scope,autoApprovalTransitions:{scope:"recorded_events",value:eventsFull?new Set(autoApprovals.map(e=>e.id)).size:null},proposed:count("proposed"),accepted:count("accepted"),approved:milestoneKnown&&!approvalUnknown?approvalIds.size:null,cancelled:count("cancelled"),proposeToApproveMs:full&&!approvalUnknown?quantiles(leads):{samples:null,p50:null,p90:null},reviewRounds:milestoneKnown?rounds:null,firstPassApproval:full&&!approvalUnknown&&first.length?first.reduce((a,b)=>a+b,0)/first.length:null,escalations:count("escalated")},
|
|
240
|
+
review:{changesRequested:milestoneKnown?rejectionCount:null,checklistItems:null,criteria:criteriaKnown?[...criteria].sort(([a],[b])=>a.localeCompare(b)).map(([id,count])=>({id,count})):null,privateCriteria:criteriaKnown?privateCriteria:null},
|
|
241
|
+
overlap:{pathNotices:eventCount(warnings.filter(w=>w.paths.length).length),sectionNotices:eventCount(warnings.filter(w=>w.sections).length),observedPathEditPairs:eventCount(confirmed.size),sectionEditPairs:null},
|
|
242
|
+
deliverables:{recordedCount:full&&history.some(h=>h.deliverables!==undefined)?group.tasks.reduce((n,t)=>n+new Set(history.filter(h=>h.task.id===t.id).flatMap(h=>h.deliverables??[])).size,0):null,changedSinceApproval:null,unknownStates:null},
|
|
243
|
+
privacy:{privateTasks:full?group.tasks.filter(t=>t.pii).length:null,refusals:null,memoryNotesDropped:null,observedPiiNoteScreens:!bindingsTruncated&&complete(input.events)&&screened.length?screened.length:null,captureRowsDropped:null},
|
|
244
|
+
undo:{requests:null,completed:null,guardRefused:null},snapshots:{budgetExhaustions:null,durationMs:!bindingsTruncated&&complete(input.events)?quantiles(snapshots):{samples:null,p50:null,p90:null}},profileErrors:{unknownAlias:null,otherProfileAlias:null},cost:{perTask:ids.size || bindingsComplete || eventsFull ? costs : null}};
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
return {profile:{bindings:{limit:bindingLimit,retained:bound.size,complete:!bindingsTruncated},sources:{tasks:{available:input.tasks.available,complete:complete(input.tasks),bindingsComplete},events:{available:input.events.available,complete:complete(input.events),bindingsComplete:eventBindingsComplete},config:input.projectProfile?{available:input.projectProfile.available,complete:complete(input.projectProfile)}:null},rows}};
|
|
248
|
+
}
|
package/src/hub/dogfood-read.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
|
5
5
|
import { readPrCostHistory, sdlcGh, type SdlcGh } from "./sdlc.ts";
|
|
6
6
|
import { dogfoodScorecard, missing, source, type DogfoodTask, type DogfoodPr, type FeatureEvidence, type Source } from "./dogfood.ts";
|
|
7
7
|
import type { HistoryEntry } from "./board.ts";
|
|
8
|
+
import { dogfoodAlias, dogfoodProfileName, profileEventValid, type ProfileTaskMetadata } from "./dogfood-profile.ts";
|
|
9
|
+
import { CLASSES } from "./board.ts";
|
|
10
|
+
import type { ProfileName } from "./profiles.ts";
|
|
8
11
|
import type { StampedEvent } from "./events.ts";
|
|
9
12
|
|
|
10
13
|
export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
|
|
@@ -37,14 +40,10 @@ export function dogfoodEvents(lines: Source<string[]>, since: number, until: num
|
|
|
37
40
|
try {
|
|
38
41
|
const e = JSON.parse(line);
|
|
39
42
|
if (!e || typeof e !== "object" || e.v !== 1 || typeof e.type !== "string" || date(e.at) === undefined) { complete = false; continue; }
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
e.type === "tokens" && !number(e.n) || e.type === "turn_end" && !number(e.ms) ||
|
|
45
|
-
e.type === "usage" && (typeof e.id !== "string" || typeof e.source !== "string") ||
|
|
46
|
-
["overflow","undeliverable"].includes(e.type) && typeof e.id !== "string");
|
|
47
|
-
if (malformed) {complete=false;continue;}
|
|
43
|
+
// Other dogfood rows retain their existing validation; profile-consumed types share one predicate.
|
|
44
|
+
const malformed = ["overflow","undeliverable"].includes(e.type) &&
|
|
45
|
+
(typeof e.peer!=="string" || !/^[a-z][a-z0-9-]{0,63}$/.test(e.peer) || Object.hasOwn(Object.prototype,e.peer) || typeof e.id!=="string");
|
|
46
|
+
if (malformed || !profileEventValid(e)) {complete=false;continue;}
|
|
48
47
|
const at = e.type === "usage" && date(e.measuredAt) !== undefined ? date(e.measuredAt)! : date(e.at)!;
|
|
49
48
|
if (at >= since && at <= until) out.push({ ...e, at: new Date(at).toISOString() });
|
|
50
49
|
} catch { complete = false; }
|
|
@@ -57,14 +56,23 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
|
|
|
57
56
|
try {
|
|
58
57
|
db = new Database(file, { readonly: true });
|
|
59
58
|
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
60
|
-
const
|
|
59
|
+
const columns = new Set((db.query("PRAGMA table_info(tasks)").all() as {name:string}[]).map(c=>c.name));
|
|
60
|
+
const metadata = columns.has("class") ? "CASE WHEN length(class) <= 48 THEN class END" : "NULL";
|
|
61
|
+
const profile = columns.has("project_profile") ? "CASE WHEN length(project_profile) <= 65536 THEN project_profile END" : "NULL";
|
|
62
|
+
const plan = columns.has("plan") ? "CASE WHEN length(plan) <= 65536 THEN plan END" : "NULL";
|
|
63
|
+
const planCut = columns.has("plan") ? "(plan IS NOT NULL AND length(plan) > 65536)" : "0";
|
|
64
|
+
const state = columns.has("state") ? "CASE WHEN length(state) <= 32 THEN state END" : "NULL";
|
|
65
|
+
const metadataCut = [columns.has("class") ? "(class IS NOT NULL AND length(class) > 48)" : "0", columns.has("state") ? "(state IS NOT NULL AND length(state) > 32)" : "0"].join(" OR ");
|
|
66
|
+
const profileCut = columns.has("project_profile") ? "(project_profile IS NOT NULL AND length(project_profile) > 65536)" : "0";
|
|
67
|
+
const rows = db.query(`SELECT (${planCut}) AS plan_cut, ${plan} AS plan, (${metadataCut}) AS metadata_cut, ${state} AS current_state, ${metadata} AS base_class, ${profileCut} AS profile_cut, ${profile} AS project_profile, id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
|
|
61
68
|
CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
|
|
62
|
-
CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
|
|
69
|
+
CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
|
|
63
70
|
let complete = rows.length <= MAX_TASKS, bytes = 0;
|
|
64
71
|
const tasks: DogfoodTask[] = [];
|
|
65
72
|
for (const row of rows.slice(0,MAX_TASKS)) {
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
// A cut plan nulls only that task's declarations and stays out of the byte cap: the dev scorecard never reads the plan column, so a plan nobody consumes cannot blank every PR row.
|
|
74
|
+
if (row.receipt_cut || row.profile_cut || row.metadata_cut) complete = false;
|
|
75
|
+
bytes += Buffer.byteLength(row.base_class ?? "") + Buffer.byteLength(row.current_state ?? "") + (row.project_profile?.length ?? 0) + (row.sdlc?.length ?? 0) + (row.signals?.length ?? 0) + (row.history?.length ?? 0);
|
|
68
76
|
if (bytes > DOGFOOD_BYTE_CAP) { complete = false; break; }
|
|
69
77
|
try {
|
|
70
78
|
const signals = JSON.parse(row.signals ?? "null"), history = JSON.parse(row.history ?? "null"), receipt = JSON.parse(row.sdlc ?? "{}");
|
|
@@ -75,7 +83,42 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
|
|
|
75
83
|
if (!h || !Number.isFinite(h.at) || typeof h.event !== "string") { complete = false; continue; }
|
|
76
84
|
clean.push({ at: h.at, by: "hub", event: h.event, ...(h.reviewCost && typeof h.reviewCost === "object" ? { reviewCost: { ...(h.reviewCost.review && ["none","delta","full"].includes(h.reviewCost.review.scope) ? {review:{scope:h.reviewCost.review.scope,reviewers:1}} : {}) } } : {}), ...(Array.isArray(h.reviewFindings?.items) ? { reviewFindings: { items:h.reviewFindings.items.filter((f:any)=>f && typeof f.id === "string" && ["important","nit"].includes(f.severity)).map((f:any)=>({id:f.id,severity:f.severity,pass:"bugs",disposition:"open"})) } } : {}) });
|
|
77
85
|
}
|
|
78
|
-
|
|
86
|
+
const pii = signals.includes("pii") || signals.includes("pii_path");
|
|
87
|
+
let profileMetadata: ProfileTaskMetadata | undefined;
|
|
88
|
+
try {
|
|
89
|
+
if (!row.profile_cut && row.base_class && (CLASSES as readonly string[]).includes(row.base_class)) {
|
|
90
|
+
const raw = row.project_profile === null ? {profile:"dev",profileClass:row.base_class} : JSON.parse(row.project_profile);
|
|
91
|
+
if (!dogfoodProfileName(raw?.profile)) complete = false;
|
|
92
|
+
else {
|
|
93
|
+
// A plan that parses but holds a malformed declaration list is this task's bad plan only:
|
|
94
|
+
// its declarations go unknown, the source stays complete (#799 nit 1).
|
|
95
|
+
let planBad=false;
|
|
96
|
+
if (history.some((h:any) => h && (h.unmetCriteria !== undefined && (!Array.isArray(h.unmetCriteria) || h.unmetCriteria.length>32 || new Set(h.unmetCriteria).size!==h.unmetCriteria.length || h.unmetCriteria.some((v:unknown)=>typeof v!=="string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v))) || h.deliverables !== undefined && (!Array.isArray(h.deliverables) || h.deliverables.length>16 || h.deliverables.some((v:any)=>typeof v?.id!=="string" || !/^d-[0-9a-f]{12}$/.test(v.id)))))) complete=false;
|
|
97
|
+
const projection = history.filter((h:any)=>h && Number.isFinite(h.at) && typeof h.event==="string").map((h:any) => ({at:h.at,event:h.event,
|
|
98
|
+
...(Array.isArray(h.unmetCriteria) ? {criteria:h.unmetCriteria.slice(0,32).filter((v:unknown)=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v)).map((v:string)=>pii || h.withheld ? "withheld" : v), ...(h.withheld ? {criteriaPrivate:true as const} : {})} : {}),
|
|
99
|
+
...(Array.isArray(h.deliverables) ? {deliverables:h.deliverables.slice(0,16).filter((v:any)=>typeof v?.id==="string" && /^d-[0-9a-f]{12}$/.test(v.id)).map((v:any)=>v.id)} : {})}));
|
|
100
|
+
let declarations: ProfileTaskMetadata["plan"];
|
|
101
|
+
if (!row.plan_cut && row.plan !== null) {
|
|
102
|
+
try {
|
|
103
|
+
const rawPlan = JSON.parse(row.plan);
|
|
104
|
+
if (!rawPlan || typeof rawPlan!=="object" || Array.isArray(rawPlan)) planBad=true;
|
|
105
|
+
else {
|
|
106
|
+
const count = (key:"sources"|"claims") => {
|
|
107
|
+
const value=rawPlan[key];
|
|
108
|
+
// cleanPlan never stores an empty list, so a board plan omitting a key is an explicit zero, not an unknown (#799 nit 2).
|
|
109
|
+
if (value===undefined) return 0;
|
|
110
|
+
if (!Array.isArray(value) || value.length>50 || value.some((v:unknown)=>typeof v!=="string" || v.length>300)) throw new Error("plan list malformed");
|
|
111
|
+
return value.length;
|
|
112
|
+
};
|
|
113
|
+
declarations={sources:count("sources"),claims:count("claims")};
|
|
114
|
+
}
|
|
115
|
+
} catch {planBad=true;}
|
|
116
|
+
}
|
|
117
|
+
profileMetadata = {...(declarations?{plan:declarations}:{}),...(row.current_state ? {state:row.current_state} : {}),profile:raw.profile,class:row.base_class,alias:dogfoodAlias(raw.profile,row.base_class,raw.profileClass,pii),history:projection};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch { complete=false; }
|
|
121
|
+
tasks.push({ id: row.id, pii, ...(profileMetadata ? {profileMetadata} : {}), ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}), history: clean });
|
|
79
122
|
} catch { complete = false; }
|
|
80
123
|
}
|
|
81
124
|
return source(tasks, complete);
|
|
@@ -184,6 +227,26 @@ export function dogfoodFeatures(file: string): Source<FeatureEvidence[]> {
|
|
|
184
227
|
const rows = text.data.filter(line=>line.startsWith("| ") && !line.startsWith("| Feature ") && !line.startsWith("| ---"));
|
|
185
228
|
return {available:text.available,complete:text.complete,data:rows.map((row,index)=>{const rule=mappings.find(([re])=>re.test(row.split("|")[1] ?? ""));return {id:index+1,types:rule?.[1] ?? null,...(rule?.[2] ? {taskEvents:rule[2]} : {})};})};
|
|
186
229
|
}
|
|
230
|
+
/** Data-only profile selection; never load daemon configuration or project adapter modules. */
|
|
231
|
+
export function dogfoodProjectProfile(root: string): Source<ProfileName | null> {
|
|
232
|
+
let selected: ProfileName = "dev";
|
|
233
|
+
for (const name of ["config.json", "config.local.json"]) {
|
|
234
|
+
const file = join(root,".agenthub",name);
|
|
235
|
+
let fd: number | undefined;
|
|
236
|
+
try {
|
|
237
|
+
fd = openSync(file,constants.O_RDONLY | constants.O_NONBLOCK);
|
|
238
|
+
const stat = fstatSync(fd);
|
|
239
|
+
if (!stat.isFile() || stat.size > 128*1024) return missing(null);
|
|
240
|
+
const buffer = Buffer.alloc(stat.size), n = readSync(fd,buffer,0,buffer.length,0);
|
|
241
|
+
if (n !== stat.size) return missing(null);
|
|
242
|
+
const raw = JSON.parse(buffer.toString("utf8"));
|
|
243
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return missing(null);
|
|
244
|
+
if (raw.profile !== undefined) { if (!dogfoodProfileName(raw.profile)) return missing(null); selected = raw.profile; }
|
|
245
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; return missing(null); }
|
|
246
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
247
|
+
}
|
|
248
|
+
return source(selected);
|
|
249
|
+
}
|
|
187
250
|
export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
|
|
188
251
|
const tasks = dogfoodTasks(join(stateDir,"hub.db"));
|
|
189
252
|
let repo = "";
|
|
@@ -195,5 +258,5 @@ export async function readDogfood(root: string, stateDir: string, since: number,
|
|
|
195
258
|
if (at === undefined) { log.complete = false; return false; }
|
|
196
259
|
return at >= since && at <= until;
|
|
197
260
|
});
|
|
198
|
-
return dogfoodScorecard({since,until,tasks,events,log,journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
|
|
261
|
+
return dogfoodScorecard({since,until,tasks,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
|
|
199
262
|
}
|
package/src/hub/dogfood.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { profileScorecard, type ProfileTaskMetadata } from "./dogfood-profile.ts";
|
|
2
|
+
import type { ProfileName } from "./profiles.ts";
|
|
1
3
|
import { costMetrics, type CostPr, type CostTask } from "./cost-governance.ts";
|
|
2
4
|
import type { HistoryEntry } from "./board.ts";
|
|
3
5
|
import { parseSince } from "./events.ts";
|
|
@@ -22,7 +24,7 @@ export function dogfoodSince(args: string[], now = Date.now()): number {
|
|
|
22
24
|
export interface Source<T> { data: T; complete: boolean; available: boolean }
|
|
23
25
|
export const source = <T>(data: T, complete = true): Source<T> => ({ data, complete, available: true });
|
|
24
26
|
export const missing = <T>(data: T): Source<T> => ({ data, complete: false, available: false });
|
|
25
|
-
export interface DogfoodTask { id: number; repo?: string; pr?: number; pii: boolean; history: HistoryEntry[] }
|
|
27
|
+
export interface DogfoodTask { id: number; repo?: string; pr?: number; pii: boolean; history: HistoryEntry[]; profileMetadata?: ProfileTaskMetadata }
|
|
26
28
|
export interface DogfoodPr extends CostPr {
|
|
27
29
|
firstPassCi?: boolean;
|
|
28
30
|
sameHeadReruns?: number;
|
|
@@ -32,6 +34,7 @@ export interface DogfoodPr extends CostPr {
|
|
|
32
34
|
export interface FeatureEvidence { id: number; types: string[] | null; taskEvents?: string[] }
|
|
33
35
|
export interface DogfoodInput {
|
|
34
36
|
since: number; until: number;
|
|
37
|
+
projectProfile?: Source<ProfileName | null>;
|
|
35
38
|
events: Source<StampedEvent[]>; tasks: Source<DogfoodTask[]>; prs: Source<DogfoodPr[]>;
|
|
36
39
|
journal?: Source<{ digests: number; held: number; rows: number }>;
|
|
37
40
|
log: Source<string[]>; features: Source<FeatureEvidence[]>;
|
|
@@ -154,6 +157,7 @@ export function dogfoodScorecard(input: DogfoodInput) {
|
|
|
154
157
|
adoption: input.features.data.map(feature => ({ id: feature.id, events: feature.types && known(input.events) ? events.filter(e => feature.types!.includes(e.type) && (!feature.taskEvents || e.type === "task" && feature.taskEvents.includes(e.event))).length : null })),
|
|
155
158
|
journalSource: input.journal ? {available:input.journal.available,complete:input.journal.complete,rows:input.journal.data.rows} : {available:false,complete:false,rows:0},
|
|
156
159
|
piiTasks: known(input.tasks) ? tasks.filter(t => t.pii).length : null,
|
|
160
|
+
...profileScorecard(input),
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
export type DogfoodScorecard = ReturnType<typeof dogfoodScorecard>;
|
package/src/hub/events.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type HubEvent =
|
|
|
17
17
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
18
18
|
| { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
|
|
19
19
|
/** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
|
|
20
|
-
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
|
|
20
|
+
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal" | "conductor"; peer?: string; undo?: boolean; reason?: string }
|
|
21
21
|
/** A structural config edit (#689): the file, key paths with short hashes of their values before and after (never a value), the file revisions and the person who made it. */
|
|
22
22
|
| { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
|
|
23
23
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
+
import type { ReadOnlyProgram } from "./read-only-shell.ts";
|
|
1
2
|
import { isAbsolute, relative } from "node:path";
|
|
3
|
+
import { lstatSync } from "node:fs";
|
|
2
4
|
import { realPath } from "./project.ts";
|
|
3
5
|
|
|
4
6
|
/** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
|
|
5
|
-
export function hostExecutable(name: "gh" | "container" | "git" | "dot", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
7
|
+
export function hostExecutable(name: "gh" | "bun" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
|
|
6
8
|
const found = Bun.which(name, { PATH: path });
|
|
7
9
|
if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
|
|
8
10
|
const target = realPath(found), inside = relative(realPath(projectDir), target);
|
|
9
11
|
if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
|
|
10
12
|
return target;
|
|
11
13
|
}
|
|
14
|
+
|
|
15
|
+
/** A privileged command's children must not discover a project-controlled PATH shim either. */
|
|
16
|
+
export function hostShellPath(projectDir: string, executionDir: string, value = process.env.PATH ?? "/usr/bin:/bin"): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
if (!value || value.length > 16384 || value.split(":").length > 128) return;
|
|
19
|
+
const roots = [realPath(projectDir), realPath(executionDir)], directories = new Set<string>();
|
|
20
|
+
const inside = (path: string) => roots.some(root => { const p = relative(root, path); return !p || p !== ".." && !p.startsWith("../") && !isAbsolute(p); });
|
|
21
|
+
for (const entry of value.split(":")) {
|
|
22
|
+
if (!isAbsolute(entry) || entry.split("/").includes("..")) return;
|
|
23
|
+
if (inside(entry)) continue;
|
|
24
|
+
try {
|
|
25
|
+
const canonical = realPath(entry);
|
|
26
|
+
if (!inside(canonical) && lstatSync(canonical).isDirectory()) directories.add(canonical);
|
|
27
|
+
} catch { /* A missing PATH directory supplies no trusted program. */ }
|
|
28
|
+
}
|
|
29
|
+
return directories.size ? [...directories].join(":") : undefined;
|
|
30
|
+
} catch { return undefined; }
|
|
31
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -103,7 +103,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
103
103
|
|
|
104
104
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, set refs.repo for work in another repository, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
|
105
105
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
|
|
106
|
-
tool("hub_task_cancel", "Cancel
|
|
106
|
+
tool("hub_task_cancel", "Cancel proposed/backlog work you proposed as its configured planner, including work an owner accepted and then declined while it has no execution, branch, commit or PR evidence; work with any such evidence is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
|
|
107
107
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
|
|
108
108
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema }, ["id", "summary"]),
|
|
109
109
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
@@ -130,7 +130,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
130
130
|
/** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
|
|
131
131
|
export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
132
132
|
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
133
|
-
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
133
|
+
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor, the configured planner and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
134
134
|
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named while attached or when they claim it; a peer that cannot take the task is refused with the reason, and the task is unchanged. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
135
135
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
136
136
|
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
|
package/src/hub/instances.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
|
4
4
|
import { canonicalPath, realPath } from "./project.ts";
|
|
5
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
6
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
7
|
+
import { isReviewScope } from "./review-scope.ts";
|
|
7
8
|
|
|
8
9
|
export interface InstanceConfig {
|
|
9
10
|
kind: string;
|
|
@@ -11,9 +12,11 @@ export interface InstanceConfig {
|
|
|
11
12
|
start_mode?: StartForm;
|
|
12
13
|
permission_mode?: PermissionMode;
|
|
13
14
|
review_mode?: "fresh";
|
|
15
|
+
/** A fresh reviewer's review scope (#852): `code` reads the code only, `full` also runs tests and reads CI. Machine-local, tier 3. */
|
|
16
|
+
review_scope?: "full" | "code";
|
|
14
17
|
worktree?: string;
|
|
15
18
|
readOnly?: boolean;
|
|
16
|
-
/** Jcode instances
|
|
19
|
+
/** Jcode instances (#685): their own native data home, and optional model selection. A headless Claude implementer also takes `model` (#844). Machine-local, never editable from settings. */
|
|
17
20
|
jcode_home?: string;
|
|
18
21
|
provider?: string;
|
|
19
22
|
model?: string;
|
|
@@ -129,7 +132,9 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
129
132
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
130
133
|
const declaredKind: unknown = entry.kind?.value;
|
|
131
134
|
const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
|
|
132
|
-
|
|
135
|
+
// #844: a kind whose headless variant declares `instances.modelSelect` may name its model; no other kind but Jcode has the field.
|
|
136
|
+
const modelSelect = typeof declaredKind === "string" && resolve(declaredKind)?.headlessVariant?.instances?.modelSelect === true;
|
|
137
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", "review_scope", ...(jcodeFields ? JCODE_FIELDS : []), ...(modelSelect ? ["model"] : [])].includes(key))) return fail(path, "unsupported field");
|
|
133
138
|
const kind: unknown = entry.kind?.value;
|
|
134
139
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
135
140
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
@@ -185,6 +190,17 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
185
190
|
if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
|
|
186
191
|
Object.assign(value, jcodeInstanceFields(entry, path));
|
|
187
192
|
}
|
|
193
|
+
if (entry.model && modelSelect) {
|
|
194
|
+
if (!headlessWriter) return fail(`${path}.model`, "only a headless implementer takes a model");
|
|
195
|
+
const word: unknown = entry.model.value;
|
|
196
|
+
if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.model`, "must be one bounded printable word without spaces");
|
|
197
|
+
value.model = word;
|
|
198
|
+
}
|
|
199
|
+
if (entry.review_scope) {
|
|
200
|
+
if (!freshReviewer) return fail(`${path}.review_scope`, "only a fresh reviewer instance has a review scope");
|
|
201
|
+
if (!isReviewScope(entry.review_scope.value)) return fail(`${path}.review_scope`, 'must be "code" or "full"');
|
|
202
|
+
value.review_scope = entry.review_scope.value;
|
|
203
|
+
}
|
|
188
204
|
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
189
205
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
190
206
|
if (entry.start_mode) {
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -119,7 +119,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
119
119
|
try {
|
|
120
120
|
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
121
121
|
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
122
|
-
unattended, onShutdownStart,
|
|
122
|
+
unattended, onShutdownStart, agentDrift: true,
|
|
123
123
|
// #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
|
|
124
124
|
terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
|
|
125
125
|
break;
|