@staix/agent-hub 0.28.0 → 0.29.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/daemon.md +2 -0
  4. package/docs/agent-notes/local-worker.md +3 -1
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/design/task-board.md +1 -1
  8. package/docs/dogfood.md +6 -3
  9. package/docs/operations.md +73 -11
  10. package/docs/quickstart.md +11 -3
  11. package/docs/sdlc.md +1 -1
  12. package/docs/security.md +17 -2
  13. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  14. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  15. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  16. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  17. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +524 -18
  22. package/src/cli/help.ts +1 -1
  23. package/src/cli/output.ts +5 -0
  24. package/src/cli/recovery-package.ts +9 -0
  25. package/src/hub/board.ts +3 -2
  26. package/src/hub/control-client.ts +16 -1
  27. package/src/hub/control-write-trace.ts +409 -0
  28. package/src/hub/daemon.ts +171 -47
  29. package/src/hub/hub-tools.ts +1 -1
  30. package/src/hub/routing.ts +9 -2
  31. package/src/hub/runtime-capture-prune.ts +4 -1
  32. package/src/hub/sdlc-release-authorization.ts +13 -4
  33. package/src/hub/sdlc-release.ts +23 -10
  34. package/src/hub/sdlc.ts +15 -2
  35. package/src/hub/task-worktree.ts +331 -0
  36. package/src/hub/tasks.ts +162 -17
  37. package/src/local/deny.ts +49 -0
  38. package/src/local/tools.ts +4 -2
  39. package/src/memory/brief.ts +5 -0
  40. package/src/memory/client.ts +5 -2
@@ -6258,6 +6258,28 @@ var init_path_policy = __esm(() => {
6258
6258
 
6259
6259
  // src/local/deny.ts
6260
6260
  import { basename as basename4, dirname as dirname8 } from "path";
6261
+ function gitRefRules(gitDirs, subDirs = []) {
6262
+ const git = hfsFoldedName(".git");
6263
+ const re = (s) => `(regex ${sbplString(s)})`;
6264
+ const name = (n) => hfsFoldedName(n);
6265
+ const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
6266
+ const plain = [
6267
+ re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
6268
+ re(`^.*/${git}/${name("refs")}(/|$)`),
6269
+ re(`^.*/${git}/${name("packed")}`),
6270
+ `(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`
6271
+ ];
6272
+ const external = [...gitDirs, ...subDirs].flatMap((d) => {
6273
+ const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
6274
+ return [
6275
+ `(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
6276
+ `(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
6277
+ `(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
6278
+ `(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`
6279
+ ];
6280
+ });
6281
+ return [...plain, ...external];
6282
+ }
6261
6283
  function hubWriteRegexes(root, gitDirs = [], moduleGit = [], worktreesRoot) {
6262
6284
  const git = hfsFoldedName(".git");
6263
6285
  const re = (s) => `(regex ${sbplString(s)})`;
@@ -6311,6 +6333,7 @@ function hubWriteRegexes(root, gitDirs = [], moduleGit = [], worktreesRoot) {
6311
6333
  `(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
6312
6334
  re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
6313
6335
  `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
6336
+ ...gitRefRules(gitDirs, allDirs),
6314
6337
  ...modules,
6315
6338
  ...siblings,
6316
6339
  ...external
@@ -6339,6 +6362,9 @@ function denyRegexes(root, extra = [], withNames = true) {
6339
6362
  var sbplEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), sbplString = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`, HFS_IGNORABLE_POINTS, HFS_IGNORABLE, hfsFoldedName = (name) => `${HFS_IGNORABLE}*${[...name].map((c) => sbplEscape(c)).join(`${HFS_IGNORABLE}*`)}${HFS_IGNORABLE}*`, CONFIG_WORKTREE_NAME, MAX_ANCHOR_DEPTH = 80, hooksUnder = (parent) => {
6340
6363
  const depth = parent.split("/").filter(Boolean).length;
6341
6364
  return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
6365
+ }, refDepth = (d) => d.split("/").filter(Boolean).length, refUnder = (d, name) => {
6366
+ const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
6367
+ return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
6342
6368
  };
6343
6369
  var init_deny = __esm(() => {
6344
6370
  init_platform();
@@ -7073,7 +7099,9 @@ ${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path), a),
7073
7099
  try {
7074
7100
  ctx.onProcessExit?.(res.code);
7075
7101
  } catch {}
7076
- return `${res.output}
7102
+ const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
7103
+ return `${res.output}${refused ? `
7104
+ note: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself.` : ""}
7077
7105
  (exit ${res.code})`;
7078
7106
  }
7079
7107
  case "doc_read": {
@@ -7126,7 +7154,7 @@ var init_tools = __esm(() => {
7126
7154
  fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
7127
7155
  fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
7128
7156
  fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
7129
- fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
7157
+ fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
7130
7158
  fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"])
7131
7159
  ];
7132
7160
  DOC_READ_SCHEMA = fn("doc_read", "Read a PDF, DOCX or HWPX document inside the project as plain text, through the converters the project configured. Returns numbered lines, bounded; read again with offset for more. Read-only.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]);
@@ -9958,6 +9986,464 @@ var init_test_integrity = __esm(() => {
9958
9986
  // src/hub/plan-conformance.ts
9959
9987
  var init_plan_conformance = () => {};
9960
9988
 
9989
+ // src/hub/control-write-trace.ts
9990
+ import { AsyncLocalStorage } from "async_hooks";
9991
+ function isObject(value) {
9992
+ return !!value && typeof value === "object" && !Array.isArray(value);
9993
+ }
9994
+ function validTaskId(value) {
9995
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
9996
+ }
9997
+ function numericTaskId(value) {
9998
+ return validTaskId(value) ? value : undefined;
9999
+ }
10000
+ function ownValue(value, key) {
10001
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
10002
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : undefined;
10003
+ }
10004
+ function controlWriteMetadata(message) {
10005
+ if (!isObject(message))
10006
+ return;
10007
+ try {
10008
+ let kind;
10009
+ let args;
10010
+ const type = ownValue(message, "t"), operation = ownValue(message, "op");
10011
+ if (type === "delivery_complete")
10012
+ kind = "hub_delivery_done";
10013
+ else if (type === "task" && typeof operation === "string") {
10014
+ kind = Object.hasOwn(OPERATIONS, operation) ? OPERATIONS[operation] : undefined;
10015
+ const rawArgs = ownValue(message, "args");
10016
+ args = isObject(rawArgs) ? rawArgs : undefined;
10017
+ }
10018
+ if (!kind)
10019
+ return;
10020
+ const task = numericTaskId(args ? ownValue(args, "id") : undefined);
10021
+ return task === undefined ? { kind } : { kind, task };
10022
+ } catch {
10023
+ return;
10024
+ }
10025
+ }
10026
+ function isPhase(value) {
10027
+ return CONTROL_WRITE_PHASES.includes(value);
10028
+ }
10029
+ function isThenable(value) {
10030
+ try {
10031
+ return !!value && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
10032
+ } catch {
10033
+ return false;
10034
+ }
10035
+ }
10036
+ function controlWriteSpan(phase, fn) {
10037
+ const request = currentTrace.getStore();
10038
+ if (!request || request.settled || request.privacySuppressed || !isPhase(phase))
10039
+ return fn();
10040
+ const span = request.telemetry.beginSpan(request, phase);
10041
+ let result;
10042
+ try {
10043
+ result = fn();
10044
+ } catch (error) {
10045
+ request.telemetry.finishSpan(span);
10046
+ throw error;
10047
+ }
10048
+ if (!isThenable(result)) {
10049
+ request.telemetry.finishSpan(span);
10050
+ return result;
10051
+ }
10052
+ span.async = true;
10053
+ request.telemetry.recordSyncPrefix(span);
10054
+ return Promise.resolve(result).then((value) => {
10055
+ request.telemetry.finishSpan(span);
10056
+ return value;
10057
+ }, (error) => {
10058
+ request.telemetry.finishSpan(span);
10059
+ throw error;
10060
+ });
10061
+ }
10062
+
10063
+ class ControlWriteTelemetry {
10064
+ options;
10065
+ nowFn;
10066
+ maxActiveRequests;
10067
+ maxActiveSpans;
10068
+ maxRecentSpans;
10069
+ rateLimitMs;
10070
+ requests = new Map;
10071
+ spans = new Map;
10072
+ recent = [];
10073
+ emitted = [];
10074
+ lastEmit = new Map;
10075
+ nextRequestId = 1;
10076
+ nextSpanId = 1;
10077
+ timer;
10078
+ nextSampleAt;
10079
+ lastNow = 0;
10080
+ stopped = false;
10081
+ completedRequests = 0;
10082
+ expiredRequests = 0;
10083
+ slowRequests = 0;
10084
+ blockedLoopSamples = 0;
10085
+ droppedActive = 0;
10086
+ droppedSpans = 0;
10087
+ droppedRecent = 0;
10088
+ droppedLogs = 0;
10089
+ droppedMetadata = 0;
10090
+ clockFailures = 0;
10091
+ constructor(options) {
10092
+ this.options = options;
10093
+ this.nowFn = options.now ?? (() => performance.now());
10094
+ this.maxActiveRequests = options.maxActiveRequests ?? MAX_ACTIVE_REQUESTS;
10095
+ this.maxActiveSpans = options.maxActiveSpans ?? MAX_ACTIVE_SPANS;
10096
+ this.maxRecentSpans = options.maxRecentSpans ?? MAX_RECENT_SPANS;
10097
+ this.rateLimitMs = options.rateLimitMs ?? RATE_LIMIT_MS;
10098
+ if (![this.maxActiveRequests, this.maxActiveSpans, this.maxRecentSpans].every((value) => Number.isSafeInteger(value) && value > 0) || !Number.isSafeInteger(this.rateLimitMs) || this.rateLimitMs < 0 || this.rateLimitMs > RATE_LIMIT_MS || this.maxActiveRequests > MAX_ACTIVE_REQUESTS || this.maxActiveSpans > MAX_ACTIVE_SPANS || this.maxRecentSpans > MAX_RECENT_SPANS)
10099
+ throw new Error("invalid control write trace bounds");
10100
+ }
10101
+ run(metadata, privacyCurrent, fn, inputPublic) {
10102
+ if (this.stopped)
10103
+ return fn();
10104
+ const safeMetadata = this.safeMetadata(metadata);
10105
+ if (!safeMetadata || typeof privacyCurrent !== "function") {
10106
+ this.droppedMetadata++;
10107
+ return fn();
10108
+ }
10109
+ const now = this.readNow();
10110
+ this.expireRequests(now);
10111
+ if (this.requests.size >= this.maxActiveRequests) {
10112
+ this.droppedActive++;
10113
+ return fn();
10114
+ }
10115
+ if (!this.privateNow(privacyCurrent))
10116
+ return fn();
10117
+ const inputVisible = !inputPublic || this.privateNow(inputPublic);
10118
+ inputPublic = undefined;
10119
+ if (!inputVisible)
10120
+ return fn();
10121
+ const request = {
10122
+ telemetry: this,
10123
+ metadata: safeMetadata,
10124
+ privacyCurrent,
10125
+ startedAt: now,
10126
+ phases: new Map,
10127
+ phaseOrder: [],
10128
+ settled: false,
10129
+ privacySuppressed: false,
10130
+ logPrivacy: () => false
10131
+ };
10132
+ request.logPrivacy = () => !request.privacySuppressed && this.privateNow(request.privacyCurrent);
10133
+ const id = this.nextRequestId++;
10134
+ this.requests.set(id, request);
10135
+ let result;
10136
+ try {
10137
+ result = currentTrace.run(request, fn);
10138
+ } catch (error) {
10139
+ this.finishRequest(id, request, "threw");
10140
+ throw error;
10141
+ }
10142
+ if (!isThenable(result)) {
10143
+ this.finishRequest(id, request, "returned");
10144
+ return result;
10145
+ }
10146
+ return Promise.resolve(result).then((value) => {
10147
+ this.finishRequest(id, request, "returned");
10148
+ return value;
10149
+ }, (error) => {
10150
+ this.finishRequest(id, request, "threw");
10151
+ throw error;
10152
+ });
10153
+ }
10154
+ observe(phase, privacyCurrent, fn, inputPublic) {
10155
+ if (this.stopped)
10156
+ return fn();
10157
+ const current = currentTrace.getStore();
10158
+ if (current?.telemetry === this && !current.settled) {
10159
+ if (current.privacySuppressed)
10160
+ return fn();
10161
+ const inputVisible = this.privateNow(privacyCurrent) && (!inputPublic || this.privateNow(inputPublic));
10162
+ inputPublic = undefined;
10163
+ if (!inputVisible)
10164
+ current.privacySuppressed = true;
10165
+ return controlWriteSpan(phase, fn);
10166
+ }
10167
+ return this.run({ kind: "background" }, privacyCurrent, () => controlWriteSpan(phase, fn), inputPublic);
10168
+ }
10169
+ start() {
10170
+ if (this.stopped || this.timer)
10171
+ return;
10172
+ this.nextSampleAt = this.readNow() + CONTROL_WRITE_SAMPLE_MS;
10173
+ this.timer = setInterval(() => this.sample(), CONTROL_WRITE_SAMPLE_MS);
10174
+ this.timer.unref?.();
10175
+ }
10176
+ stop() {
10177
+ this.stopped = true;
10178
+ if (this.timer)
10179
+ clearInterval(this.timer);
10180
+ this.timer = undefined;
10181
+ this.nextSampleAt = undefined;
10182
+ }
10183
+ sample() {
10184
+ if (this.stopped)
10185
+ return;
10186
+ const now = this.readNow(), expected = this.nextSampleAt ?? now;
10187
+ this.expireRequests(now);
10188
+ this.nextSampleAt = now + CONTROL_WRITE_SAMPLE_MS;
10189
+ const delayMs = Math.max(0, now - expected);
10190
+ if (delayMs < CONTROL_WRITE_LOOP_DELAY_MS)
10191
+ return;
10192
+ this.blockedLoopSamples++;
10193
+ const from = expected;
10194
+ const observed = [];
10195
+ for (const interval of this.recent) {
10196
+ if (interval.async || interval.endedAt === undefined || interval.startedAt >= now || interval.endedAt < from)
10197
+ continue;
10198
+ observed.push({ kind: interval.kind, ...interval.task === undefined ? {} : { task: interval.task }, phase: interval.phase, durationMs: this.roundMs(interval.endedAt - interval.startedAt), privacyCurrent: interval.privacyCurrent });
10199
+ }
10200
+ for (const span of this.spans.values()) {
10201
+ if (!span.async || span.startedAt >= now)
10202
+ continue;
10203
+ observed.push({
10204
+ kind: span.request.metadata.kind,
10205
+ ...span.request.metadata.task === undefined ? {} : { task: span.request.metadata.task },
10206
+ phase: span.phase,
10207
+ durationMs: this.roundMs(now - span.startedAt),
10208
+ open: true,
10209
+ privacyCurrent: span.request.logPrivacy
10210
+ });
10211
+ }
10212
+ const unique = new Map;
10213
+ for (const row of observed.sort((a, b) => b.durationMs - a.durationMs)) {
10214
+ const key = `${row.kind}:${row.task ?? ""}:${row.phase}:${row.open === true}`;
10215
+ if (!unique.has(key))
10216
+ unique.set(key, row);
10217
+ }
10218
+ const selected = [...unique.values()].slice(0, MAX_LOOP_SPANS);
10219
+ const spans = selected.map(({ kind, task, phase, durationMs, open }) => ({ kind, ...task === undefined ? {} : { task }, phase, durationMs, ...open ? { open: true } : {} }));
10220
+ const guards = [...new Set(selected.map((row) => row.privacyCurrent))];
10221
+ const attribution = selected.some((row) => row.open !== true) ? "sync_overlap_observed" : selected.length ? "open_async_wait" : "unknown";
10222
+ this.emit("loop", {
10223
+ type: "control_write_loop_delay",
10224
+ delayMs: this.roundMs(delayMs),
10225
+ attribution,
10226
+ spans
10227
+ }, () => guards.every((guard) => this.privateNow(guard)));
10228
+ }
10229
+ status() {
10230
+ return {
10231
+ active: this.requests.size,
10232
+ activeSpans: this.spans.size,
10233
+ completedRequests: this.completedRequests,
10234
+ expiredRequests: this.expiredRequests,
10235
+ slowRequests: this.slowRequests,
10236
+ blockedLoopSamples: this.blockedLoopSamples,
10237
+ recentSpans: this.recent.length,
10238
+ droppedActive: this.droppedActive,
10239
+ droppedSpans: this.droppedSpans,
10240
+ droppedRecent: this.droppedRecent,
10241
+ droppedLogs: this.droppedLogs,
10242
+ droppedMetadata: this.droppedMetadata,
10243
+ clockFailures: this.clockFailures
10244
+ };
10245
+ }
10246
+ beginSpan(request, phase) {
10247
+ const span = { request, phase, startedAt: this.readNow(), async: false, finished: false, prefixRecorded: false };
10248
+ if (this.spans.size >= this.maxActiveSpans) {
10249
+ this.droppedSpans++;
10250
+ return span;
10251
+ }
10252
+ span.id = this.nextSpanId++;
10253
+ this.spans.set(span.id, span);
10254
+ return span;
10255
+ }
10256
+ finishSpan(span) {
10257
+ if (span.finished)
10258
+ return;
10259
+ span.finished = true;
10260
+ if (span.request.settled)
10261
+ return;
10262
+ const endedAt = this.readNow();
10263
+ if (span.id !== undefined && this.spans.get(span.id) === span)
10264
+ this.spans.delete(span.id);
10265
+ const durationMs = Math.max(0, endedAt - span.startedAt);
10266
+ const request = span.request;
10267
+ const prior = request.phases.get(span.phase) ?? 0;
10268
+ if (!request.phaseOrder.includes(span.phase))
10269
+ request.phaseOrder.push(span.phase);
10270
+ request.phases.set(span.phase, prior + durationMs);
10271
+ if (!span.async && !span.prefixRecorded)
10272
+ this.rememberSyncInterval(span, endedAt);
10273
+ }
10274
+ recordSyncPrefix(span) {
10275
+ if (span.finished || span.prefixRecorded || span.request.settled)
10276
+ return;
10277
+ const endedAt = this.readNow();
10278
+ if (endedAt - span.startedAt < MIN_SYNC_PREFIX_MS)
10279
+ return;
10280
+ span.prefixRecorded = true;
10281
+ this.rememberSyncInterval(span, endedAt);
10282
+ }
10283
+ rememberSyncInterval(span, endedAt) {
10284
+ const request = span.request;
10285
+ this.recent.push({ kind: request.metadata.kind, ...request.metadata.task === undefined ? {} : { task: request.metadata.task }, phase: span.phase, startedAt: span.startedAt, endedAt, async: false, privacyCurrent: request.logPrivacy });
10286
+ while (this.recent.length > this.maxRecentSpans) {
10287
+ this.recent.shift();
10288
+ this.droppedRecent++;
10289
+ }
10290
+ }
10291
+ finishRequest(id, request, outcome) {
10292
+ if (!this.requests.delete(id))
10293
+ return;
10294
+ request.settled = true;
10295
+ for (const [spanId, span] of this.spans)
10296
+ if (span.request === request)
10297
+ this.spans.delete(spanId);
10298
+ this.completedRequests++;
10299
+ const durationMs = this.roundMs(this.readNow() - request.startedAt);
10300
+ if (durationMs < CONTROL_WRITE_SLOW_MS)
10301
+ return;
10302
+ this.slowRequests++;
10303
+ const phases = request.phaseOrder.map((phase) => ({ phase, durationMs: this.roundMs(request.phases.get(phase) ?? 0) }));
10304
+ this.emit("slow", {
10305
+ type: "control_write_slow",
10306
+ kind: request.metadata.kind,
10307
+ ...request.metadata.task === undefined ? {} : { task: request.metadata.task },
10308
+ durationMs,
10309
+ outcome,
10310
+ phases: phases.slice(0, MAX_LOOP_SPANS),
10311
+ ...phases.length > MAX_LOOP_SPANS ? { truncated: true } : {}
10312
+ }, request.logPrivacy);
10313
+ }
10314
+ expireRequests(now) {
10315
+ for (const [id, request] of this.requests) {
10316
+ if (now - request.startedAt < CONTROL_WRITE_REQUEST_MAX_AGE_MS)
10317
+ continue;
10318
+ request.settled = true;
10319
+ request.privacySuppressed = true;
10320
+ this.requests.delete(id);
10321
+ this.expiredRequests++;
10322
+ }
10323
+ for (const [id, span] of this.spans)
10324
+ if (span.request.settled)
10325
+ this.spans.delete(id);
10326
+ }
10327
+ emit(key, value, privacyCurrent) {
10328
+ if (this.stopped || !this.privateNow(privacyCurrent))
10329
+ return;
10330
+ const now = this.readNow(), last = this.lastEmit.get(key);
10331
+ if (last !== undefined && now - last < this.rateLimitMs) {
10332
+ this.droppedLogs++;
10333
+ return;
10334
+ }
10335
+ while (this.emitted.length && now - this.emitted[0] >= 60000)
10336
+ this.emitted.shift();
10337
+ if (this.emitted.length >= MAX_EMITS_PER_MINUTE) {
10338
+ this.droppedLogs++;
10339
+ return;
10340
+ }
10341
+ let line;
10342
+ try {
10343
+ line = JSON.stringify(value);
10344
+ } catch {
10345
+ this.droppedLogs++;
10346
+ return;
10347
+ }
10348
+ if (Buffer.byteLength(line) > CONTROL_WRITE_MAX_LINE_BYTES) {
10349
+ const minimal = value.type === "control_write_loop_delay" ? { type: "control_write_loop_delay", delayMs: typeof value.delayMs === "number" ? value.delayMs : 0, attribution: "unknown", spans: [], truncated: true } : { type: "control_write_slow", kind: value.kind, ...value.task === undefined ? {} : { task: value.task }, durationMs: value.durationMs, outcome: value.outcome, phases: [], truncated: true };
10350
+ line = JSON.stringify(minimal);
10351
+ if (Buffer.byteLength(line) > CONTROL_WRITE_MAX_LINE_BYTES) {
10352
+ this.droppedLogs++;
10353
+ return;
10354
+ }
10355
+ }
10356
+ this.lastEmit.set(key, now);
10357
+ this.emitted.push(now);
10358
+ try {
10359
+ this.options.emit(line);
10360
+ } catch {
10361
+ this.droppedLogs++;
10362
+ }
10363
+ }
10364
+ privateNow(guard) {
10365
+ try {
10366
+ return guard() === true;
10367
+ } catch {
10368
+ return false;
10369
+ }
10370
+ }
10371
+ safeMetadata(metadata) {
10372
+ if (!isObject(metadata))
10373
+ return;
10374
+ try {
10375
+ const kind = ownValue(metadata, "kind"), task = ownValue(metadata, "task");
10376
+ if (typeof kind !== "string" || !CONTROL_WRITE_KINDS.includes(kind) || task !== undefined && !validTaskId(task))
10377
+ return;
10378
+ return { kind, ...task === undefined ? {} : { task } };
10379
+ } catch {
10380
+ return;
10381
+ }
10382
+ }
10383
+ readNow() {
10384
+ let value;
10385
+ try {
10386
+ value = this.nowFn();
10387
+ } catch {
10388
+ this.clockFailures++;
10389
+ return this.lastNow;
10390
+ }
10391
+ if (!Number.isFinite(value) || value < this.lastNow) {
10392
+ this.clockFailures++;
10393
+ return this.lastNow;
10394
+ }
10395
+ this.lastNow = value;
10396
+ return value;
10397
+ }
10398
+ roundMs(value) {
10399
+ const bounded = Number.isFinite(value) ? Math.min(CONTROL_WRITE_MAX_DURATION_MS, Math.max(0, value)) : CONTROL_WRITE_MAX_DURATION_MS;
10400
+ return Math.round(bounded * 10) / 10;
10401
+ }
10402
+ }
10403
+ var CONTROL_WRITE_KINDS, CONTROL_WRITE_PHASES, CONTROL_WRITE_SLOW_MS = 1000, CONTROL_WRITE_LOOP_DELAY_MS = 250, CONTROL_WRITE_SAMPLE_MS = 100, CONTROL_WRITE_MAX_LINE_BYTES = 2048, CONTROL_WRITE_MAX_DURATION_MS = 86400000, CONTROL_WRITE_REQUEST_MAX_AGE_MS, MAX_ACTIVE_REQUESTS = 128, MAX_ACTIVE_SPANS = 256, MAX_RECENT_SPANS = 512, MAX_LOOP_SPANS = 8, MIN_SYNC_PREFIX_MS = 10, MAX_EMITS_PER_MINUTE = 60, RATE_LIMIT_MS = 1000, currentTrace, OPERATIONS;
10404
+ var init_control_write_trace = __esm(() => {
10405
+ CONTROL_WRITE_KINDS = [
10406
+ "hub_task_propose",
10407
+ "hub_review",
10408
+ "hub_task_accept",
10409
+ "hub_task_done",
10410
+ "hub_task_decline",
10411
+ "hub_task_cancel",
10412
+ "hub_task_assign",
10413
+ "task_assign",
10414
+ "task_assign_checked",
10415
+ "task_unassign",
10416
+ "hub_delivery_done",
10417
+ "background"
10418
+ ];
10419
+ CONTROL_WRITE_PHASES = [
10420
+ "client-send",
10421
+ "client-wait",
10422
+ "daemon-dispatch",
10423
+ "daemon-reply",
10424
+ "board-write",
10425
+ "queue-hook",
10426
+ "status-hook",
10427
+ "memory",
10428
+ "external",
10429
+ "snapshot"
10430
+ ];
10431
+ CONTROL_WRITE_REQUEST_MAX_AGE_MS = 5 * 60000;
10432
+ currentTrace = new AsyncLocalStorage;
10433
+ OPERATIONS = {
10434
+ hub_task_propose: "hub_task_propose",
10435
+ hub_review: "hub_review",
10436
+ hub_task_accept: "hub_task_accept",
10437
+ hub_task_done: "hub_task_done",
10438
+ hub_task_decline: "hub_task_decline",
10439
+ hub_task_cancel: "hub_task_cancel",
10440
+ hub_task_assign: "hub_task_assign",
10441
+ task_assign: "task_assign",
10442
+ task_assign_checked: "task_assign_checked",
10443
+ task_unassign: "task_unassign"
10444
+ };
10445
+ });
10446
+
9961
10447
  // src/hub/review-findings.ts
9962
10448
  var MAX_BYTES2;
9963
10449
  var init_review_findings = __esm(() => {
@@ -10003,6 +10489,7 @@ var SDLC_STAGES, PLAYBOOK_STAGES, BASES, STAGE_GATES, GATES;
10003
10489
  var init_sdlc = __esm(() => {
10004
10490
  init_test_integrity();
10005
10491
  init_plan_conformance();
10492
+ init_control_write_trace();
10006
10493
  init_review_findings();
10007
10494
  init_sdlc_metrics();
10008
10495
  init_eval_gate();
@@ -11188,10 +11675,10 @@ var init_shard_bridge = __esm(() => {
11188
11675
  });
11189
11676
 
11190
11677
  // node_modules/@staix/agent-mem/src/memory/cli-attribution.ts
11191
- import { AsyncLocalStorage } from "async_hooks";
11678
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
11192
11679
  var scope;
11193
11680
  var init_cli_attribution = __esm(() => {
11194
- scope = new AsyncLocalStorage;
11681
+ scope = new AsyncLocalStorage2;
11195
11682
  });
11196
11683
 
11197
11684
  // node_modules/@staix/agent-mem/src/memory/private-spans.ts
@@ -11789,7 +12276,7 @@ var init_hook_contract = __esm(() => {
11789
12276
  });
11790
12277
 
11791
12278
  // node_modules/@staix/agent-mem/src/hook-spool.ts
11792
- var RECORD_BYTES, MAX_BYTES6, DRAIN_BYTES, CAPACITY_BYTES, RETIRE_AFTER_MS;
12279
+ var RECORD_BYTES, MAX_BYTES6, HOOK_REPLAY_MAX_BYTES, CAPACITY_BYTES, RETIRE_AFTER_MS;
11793
12280
  var init_hook_spool = __esm(() => {
11794
12281
  init_formats();
11795
12282
  init_capture_input();
@@ -11804,7 +12291,7 @@ var init_hook_spool = __esm(() => {
11804
12291
  init_hook_contract();
11805
12292
  RECORD_BYTES = HOOK_INPUT_BYTES + 8192;
11806
12293
  MAX_BYTES6 = 2 * 1024 * 1024;
11807
- DRAIN_BYTES = 256 * 1024;
12294
+ HOOK_REPLAY_MAX_BYTES = 256 * 1024;
11808
12295
  CAPACITY_BYTES = 256 * 1024;
11809
12296
  RETIRE_AFTER_MS = 7 * 24 * 3600000;
11810
12297
  });
@@ -11851,6 +12338,7 @@ var init_hook_jcode = __esm(() => {
11851
12338
  });
11852
12339
 
11853
12340
  // node_modules/@staix/agent-mem/src/hooks.ts
12341
+ var hookStartedAt;
11854
12342
  var init_hooks = __esm(() => {
11855
12343
  init_hook_project();
11856
12344
  init_hook_native();
@@ -11866,6 +12354,7 @@ var init_hooks = __esm(() => {
11866
12354
  init_config();
11867
12355
  init_hook_contract();
11868
12356
  init_hook_contract();
12357
+ hookStartedAt = performance.now();
11869
12358
  });
11870
12359
 
11871
12360
  // node_modules/@staix/agent-mem/src/ingest.ts
@@ -12013,7 +12502,7 @@ var package_default;
12013
12502
  var init_package = __esm(() => {
12014
12503
  package_default = {
12015
12504
  name: "@staix/agent-hub",
12016
- version: "0.28.0",
12505
+ version: "0.29.0",
12017
12506
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
12018
12507
  license: "MIT",
12019
12508
  type: "module",
@@ -12030,7 +12519,7 @@ var init_package = __esm(() => {
12030
12519
  bun: ">=1.4.2"
12031
12520
  },
12032
12521
  dependencies: {
12033
- "@staix/agent-mem": "0.19.0"
12522
+ "@staix/agent-mem": "0.20.0"
12034
12523
  },
12035
12524
  devDependencies: {
12036
12525
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -12100,6 +12589,7 @@ var init_board = __esm(() => {
12100
12589
  init_task_code_symbols();
12101
12590
  init_plan_conformance();
12102
12591
  init_db_schema();
12592
+ init_control_write_trace();
12103
12593
  init_planning();
12104
12594
  init_envelope();
12105
12595
  init_review_findings();
@@ -12392,7 +12882,7 @@ var init_hub_tools = __esm(() => {
12392
12882
  tool2("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: id2, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
12393
12883
  tool2("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: id2, reason: str2, superseded_by: str2, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
12394
12884
  tool2("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: id2, reason: str2, capacity: { type: "boolean" } }, ["id"]),
12395
- tool2("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. 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: id2, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
12885
+ tool2("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 re-filing work after review, set refs.commit to the new full SHA; summary text does not change the review head. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. 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: id2, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
12396
12886
  tool2("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: id2, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str2, issue: id2, pr: id2, localHead: str2, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str2, phase: { type: "object", properties: { id: str2, head: str2, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id: id2, after: { type: "array", maxItems: 16, items: id2 } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str2, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str2 } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
12397
12887
  tool2("hub_task_list", "The task board, newest updated first, 50 compact rows (id, state, owner, reviewer, title) per call. The reply is {tasks, total, next?}: pass next as cursor for the following page. Use ids for full rows of specific tasks, or detail: true for full rows of a page. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "rows per page, default 50" }, cursor: { type: "string", description: "the next value from the previous reply, unchanged" }, ids: { type: "array", minItems: 1, maxItems: 200, items: { type: "integer", minimum: 1 }, description: "full rows for exactly these task numbers; no paging" }, detail: { type: "boolean", description: "full rows instead of compact rows" } }),
12398
12888
  tool2("hub_specs", "Read the project's design-spec checks or find specs covering repository-relative paths. Read-only; at most 20 result items are returned. Does not reveal private task data.", { action: { type: "string", enum: ["check", "covers"] }, paths: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 2000 } } }, ["action"]),
@@ -23337,6 +23827,12 @@ function negotiateProtocol(local, remote) {
23337
23827
  }
23338
23828
 
23339
23829
  // src/hub/control-client.ts
23830
+ init_control_write_trace();
23831
+ var clientWriteTelemetry = new ControlWriteTelemetry({ emit: (line) => {
23832
+ if (process.env.AHUB_CONTROL_WRITE_TIMING === "1")
23833
+ console.error(`[agent-hub] ${line}`);
23834
+ } });
23835
+ var publicClientTiming = () => true;
23340
23836
  function stateDirFor(cwd) {
23341
23837
  return projectContext(cwd).stateDir;
23342
23838
  }
@@ -23502,6 +23998,14 @@ class ControlClient {
23502
23998
  return catalog;
23503
23999
  }
23504
24000
  request(msg, timeoutMs = 30000, signal) {
24001
+ if (process.env.AHUB_CONTROL_WRITE_TIMING !== "1")
24002
+ return this.requestBody(msg, timeoutMs, signal);
24003
+ const metadata = controlWriteMetadata(msg);
24004
+ if (!metadata)
24005
+ return this.requestBody(msg, timeoutMs, signal);
24006
+ return clientWriteTelemetry.run({ kind: metadata.kind }, publicClientTiming, () => controlWriteSpan("client-wait", () => this.requestBody(msg, timeoutMs, signal)));
24007
+ }
24008
+ requestBody(msg, timeoutMs, signal) {
23505
24009
  if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel")
23506
24010
  return Promise.resolve({ ok: false, error: "hub connection is not open" });
23507
24011
  const invocation = msg.t === "task" && msg.op === "hub_invoke";
@@ -23561,7 +24065,7 @@ class ControlClient {
23561
24065
  }
23562
24066
  timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
23563
24067
  try {
23564
- this.ws.send(JSON.stringify({ ...wire, rid }));
24068
+ controlWriteSpan("client-send", () => this.ws.send(JSON.stringify({ ...wire, rid })));
23565
24069
  } catch {
23566
24070
  abandon("hub request could not be sent");
23567
24071
  }
@@ -23619,6 +24123,8 @@ init_plan_conformance();
23619
24123
  // src/hub/sdlc-release.ts
23620
24124
  init_child_process();
23621
24125
  init_git();
24126
+ import { fileURLToPath } from "url";
24127
+ var CHECKER_PATH = fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url));
23622
24128
 
23623
24129
  // src/hub/tasks.ts
23624
24130
  init_spec_read();
@@ -24235,7 +24741,7 @@ function slugify(input) {
24235
24741
  return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
24236
24742
  }
24237
24743
  var captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {};
24238
- function isObject(data) {
24744
+ function isObject2(data) {
24239
24745
  return typeof data === "object" && data !== null && !Array.isArray(data);
24240
24746
  }
24241
24747
  var allowsEval = /* @__PURE__ */ cached(() => {
@@ -24254,7 +24760,7 @@ var allowsEval = /* @__PURE__ */ cached(() => {
24254
24760
  }
24255
24761
  });
24256
24762
  function isPlainObject(o) {
24257
- if (isObject(o) === false)
24763
+ if (isObject2(o) === false)
24258
24764
  return false;
24259
24765
  const ctor = o.constructor;
24260
24766
  if (ctor === undefined)
@@ -24262,7 +24768,7 @@ function isPlainObject(o) {
24262
24768
  if (typeof ctor !== "function")
24263
24769
  return true;
24264
24770
  const prot = ctor.prototype;
24265
- if (isObject(prot) === false)
24771
+ if (isObject2(prot) === false)
24266
24772
  return false;
24267
24773
  if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) {
24268
24774
  return false;
@@ -26203,7 +26709,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
26203
26709
  }
26204
26710
  return propValues;
26205
26711
  });
26206
- const isObject2 = isObject;
26712
+ const isObject = isObject2;
26207
26713
  const catchall = def.catchall;
26208
26714
  let value;
26209
26715
  const memo = globalConfig.memoizer;
@@ -26211,7 +26717,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
26211
26717
  inst._zod.parse = (payload, ctx) => {
26212
26718
  value ?? (value = _normalized.value);
26213
26719
  const input = payload.value;
26214
- if (!isObject2(input)) {
26720
+ if (!isObject(input)) {
26215
26721
  payload.issues.push({
26216
26722
  expected: "object",
26217
26723
  code: "invalid_type",
@@ -26347,7 +26853,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
26347
26853
  return doc.compile();
26348
26854
  };
26349
26855
  let fastpass;
26350
- const isObject2 = isObject;
26856
+ const isObject = isObject2;
26351
26857
  const jit = !globalConfig.jitless;
26352
26858
  const allowsEval2 = allowsEval;
26353
26859
  const fastEnabled = jit && allowsEval2.value;
@@ -26356,7 +26862,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
26356
26862
  inst._zod.parse = (payload, ctx) => {
26357
26863
  value ?? (value = _normalized.value);
26358
26864
  const input = payload.value;
26359
- if (!isObject2(input)) {
26865
+ if (!isObject(input)) {
26360
26866
  payload.issues.push({
26361
26867
  expected: "object",
26362
26868
  code: "invalid_type",
@@ -26494,7 +27000,7 @@ var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnio
26494
27000
  const disc = cached(() => discriminatorMap(def));
26495
27001
  inst._zod.parse = (payload, ctx) => {
26496
27002
  const input = payload.value;
26497
- if (!isObject(input)) {
27003
+ if (!isObject2(input)) {
26498
27004
  payload.issues.push({
26499
27005
  code: "invalid_type",
26500
27006
  expected: "object",