@mattstack/rt-client 0.20.0 → 0.23.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/dist/client.d.ts CHANGED
@@ -187,6 +187,7 @@ export declare function paneFocus(a: Commands["pane:focus"]["payload"], o?: RtCl
187
187
  export declare function reconcilerStatus(o?: RtClientOptions): Promise<RtResponse<Commands["reconciler:status"]["data"]>>;
188
188
  export declare function reconcilerClear(a: Commands["reconciler:clear"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["reconciler:clear"]["data"]>>;
189
189
  export declare function gateOpen(a: Commands["gate:open"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:open"]["data"]>>;
190
+ export declare function gateAsk(a: Commands["gate:ask"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:ask"]["data"]>>;
190
191
  export declare function gateAnswer(a: Commands["gate:answer"]["payload"], o?: RtClientOptions): Promise<RtResponse<Commands["gate:answer"]["data"]>>;
191
192
  /** Daemon clamps its own wait to 240s (gates-store.ts); the client abort
192
193
  must outlive that cap, same +10s buffer as commands/events.ts's
@@ -107,12 +107,14 @@ export type ExecutorState = "live" | "blocked" | "hidden" | "gone" | "cleared" |
107
107
  export type GateOption = string | {
108
108
  value: string;
109
109
  label: string;
110
+ recommended?: boolean;
110
111
  };
111
112
  export interface GateOrigin {
112
113
  paneId?: string;
113
114
  tabId?: string;
114
115
  runId?: string;
115
116
  worktree?: string;
117
+ surface?: string;
116
118
  presentation?: "form" | "wait";
117
119
  }
118
120
  export interface GateQuestion {
@@ -123,6 +125,11 @@ export interface GateQuestion {
123
125
  }
124
126
  export declare function gateOptionValue(o: GateOption): string;
125
127
  export declare function gateOptionLabel(o: GateOption): string;
128
+ /** `session` is the answering surface's own session id, recorded so the
129
+ push facility can tell a self-answer from a remote one and skip the
130
+ doorbell it would otherwise send back to the writer. Optional: a caller
131
+ that supplies none (the board status-bin answers `by: "pane"` with no
132
+ session) still matches self by `by === GATE_BY_PANE`. */
126
133
  export interface GateAnswer {
127
134
  answers: Record<string, string | string[] | {
128
135
  value: string | string[];
@@ -131,6 +138,7 @@ export interface GateAnswer {
131
138
  by: string;
132
139
  answeredAt: number;
133
140
  overridden?: boolean;
141
+ session?: string;
134
142
  }
135
143
  export interface GateRow {
136
144
  id: string;
@@ -161,6 +169,15 @@ export interface GateRow {
161
169
  at: number;
162
170
  } | null;
163
171
  released: boolean;
172
+ /** Set once the nudged pane has provably read the answer: either it
173
+ self-answered (stamped in the same transaction as the answer) or a
174
+ later `markConsumed` call recorded that it acted on a push. `null`
175
+ until then, so a sweep can tell an answered-but-unread row from a
176
+ settled one. Currently only stamped for herd-subject gates (the
177
+ self-answer path and `rt herd answer`'s nudged-session read); a
178
+ non-herd gate with a nudge stays `null` even after its pane
179
+ reconciles. */
180
+ consumedAt: number | null;
164
181
  owner: string | null;
165
182
  escalatedAt: number | null;
166
183
  /** Set by answer-time execution handling: an answered gate whose executor
@@ -216,7 +233,7 @@ export interface HerdJobInfo {
216
233
  createdAt: number;
217
234
  updatedAt: number;
218
235
  }
219
- /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. */
236
+ /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
220
237
  export interface HerdStatusData {
221
238
  herd: HerdInfo;
222
239
  jobs: Array<HerdJobInfo & {
@@ -224,6 +241,7 @@ export interface HerdStatusData {
224
241
  paneStatus: string | null;
225
242
  lastGateStatus: GateStatus | null;
226
243
  lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null;
244
+ lastGateConsumed: boolean | null;
227
245
  }>;
228
246
  unread: number;
229
247
  lifecycleConnected: boolean;
@@ -1329,6 +1347,29 @@ export interface Commands {
1329
1347
  };
1330
1348
  data: DiscussionsDiffsData;
1331
1349
  };
1350
+ /** Positioned inline MR comment with server-side DiffNote verification:
1351
+ posts, re-checks the created note's type from the creation response,
1352
+ and on the silent general-note degrade deletes the stray note and
1353
+ retries ONCE with freshly fetched diff_refs. The retry repairs only
1354
+ the stale-diff-refs degrade; a caller-supplied position GitLab
1355
+ rejects stays rejected, and after a second degrade both stray notes
1356
+ are deleted and the call fails. `verified: true` means the check ran. */
1357
+ "mr:comment-inline": {
1358
+ payload: {
1359
+ repoName: string;
1360
+ iid: number;
1361
+ body: string;
1362
+ path: string;
1363
+ line: number;
1364
+ oldPath?: string;
1365
+ oldLine?: number;
1366
+ };
1367
+ data: {
1368
+ discussionId: string;
1369
+ noteId: number;
1370
+ verified: true;
1371
+ };
1372
+ };
1332
1373
  /** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
1333
1374
  "mr:action": {
1334
1375
  payload: {
@@ -1432,6 +1473,41 @@ export interface Commands {
1432
1473
  supersededId: string | null;
1433
1474
  };
1434
1475
  };
1476
+ /** Ceremony layer over gate:open: resolves subject from the caller's
1477
+ session (an explicit subject always wins as the subject, but still
1478
+ picks up run linkage from the session's own running run; else its
1479
+ single running run as `run:<id>`; else its agent record's recorded
1480
+ subject, or `agent:<id>` when it has none), computes
1481
+ presentation via gatePresentation, supplies nudge/origin, and omits an
1482
+ oversized context instead of rejecting it. `meta` and `agent` forward
1483
+ to gate:open verbatim; `origin` passthrough fields fill gaps in the
1484
+ ceremony's own origin (presentation, paneId, runId, run-derived
1485
+ worktree always win over a caller-supplied value for the same key).
1486
+ Delegates to gate:open for everything else (validation, supersede,
1487
+ events, push). */
1488
+ "gate:ask": {
1489
+ payload: {
1490
+ questions: GateQuestion[];
1491
+ context?: string;
1492
+ kind?: string;
1493
+ subject?: string;
1494
+ sessionId?: string;
1495
+ paneId?: string;
1496
+ meta?: Record<string, unknown>;
1497
+ agent?: string;
1498
+ origin?: {
1499
+ surface?: string;
1500
+ tabId?: string;
1501
+ worktree?: string;
1502
+ };
1503
+ };
1504
+ data: {
1505
+ id: string;
1506
+ presentation: "form" | "wait";
1507
+ subject: string;
1508
+ supersededId: string | null;
1509
+ };
1510
+ };
1435
1511
  /**
1436
1512
  * A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
1437
1513
  * `conflict:true` and the WINNING row, so every consumer gets the winner
@@ -1652,6 +1728,7 @@ export interface Commands {
1652
1728
  "herd:answer": {
1653
1729
  payload: {
1654
1730
  gate: string;
1731
+ sessionId?: string;
1655
1732
  };
1656
1733
  data: {
1657
1734
  gate: string;
@@ -0,0 +1,10 @@
1
+ import type { GateQuestion, GateAnswer } from "./commands.ts";
2
+ export type GateAnswerWire = GateAnswer["answers"][string];
3
+ /** Both wire shapes carry the same value underneath: bare, or {value, note?}
4
+ when a panel attaches free text. Validation reads only the value. */
5
+ export declare function unwrapGateAnswerValue(raw: unknown): unknown;
6
+ /** Option membership is required whenever a question declares options,
7
+ checked against the unwrapped value (every element, for multi); an
8
+ empty options array stays free-form. Every question id must appear as
9
+ an answers key. Error strings are a wire contract; packages/rt-client/test/gate-answers.test.ts pins them verbatim. */
10
+ export declare function validateGateAnswers(questions: GateQuestion[], answers: Record<string, unknown>): string | null;
@@ -0,0 +1,22 @@
1
+ import type { GateOption, GateQuestion } from "./commands.ts";
2
+ /** The canonical stored/emitted option shape (contract C11). GateOption
3
+ (the input union) is unchanged; rows normalized by the daemon always
4
+ satisfy this. */
5
+ export interface GateOptionObject {
6
+ value: string;
7
+ label: string;
8
+ }
9
+ /** Bare string s becomes {value: s, label: s}; a well-formed {value,label}
10
+ object passes through untouched. Total over whatever actually arrives
11
+ on the wire, not just the declared GateOption union: a partial object
12
+ fills the missing field from the one present, and anything else
13
+ (null, a number, an object with neither field) is coerced via String()
14
+ into both fields. The resulting label is then capitalized when
15
+ word-like (see isWordLikeLabel), and an object form's `recommended:
16
+ true` lifts into a " (Recommended)" label suffix -- guarded against
17
+ double-appending -- since `recommended` itself does not survive into
18
+ the returned object; the suffix IS its wire representation. Every
19
+ returned entry is a full {value,label} pair -- callers may trust the
20
+ return type without re-checking it. Pure and order-preserving. */
21
+ export declare function normalizeGateOptions(options: GateOption[]): GateOptionObject[];
22
+ export declare function normalizeGateQuestions(questions: GateQuestion[]): GateQuestion[];
@@ -0,0 +1,10 @@
1
+ import type { GateQuestion } from "./commands.ts";
2
+ export declare const GATE_FORM_OPTION_CAP = 4;
3
+ /** The ONE presentation rule (spec Phase 1): form iff an injectable pane
4
+ exists, a nudge target exists, and every question fits the native form's
5
+ per-question option cap. */
6
+ export declare function gatePresentation(args: {
7
+ paneId?: string | undefined;
8
+ sessionId?: string | undefined;
9
+ questions: GateQuestion[];
10
+ }): "form" | "wait";
package/dist/index.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
- export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
3
+ export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAsk, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
4
4
  export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
5
+ export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
6
+ export { normalizeGateOptions, normalizeGateQuestions } from "./gate-options.ts";
7
+ export type { GateOptionObject } from "./gate-options.ts";
8
+ export { unwrapGateAnswerValue, validateGateAnswers } from "./gate-answers.ts";
9
+ export type { GateAnswerWire } from "./gate-answers.ts";
5
10
  export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, ChatClaimOutcome, RoomSummary, BuddyStatus, PresenceRow, AgentRecord, AgentSurface, AgentStatus, ExecutorState, ExecutorView, ReconcilerStatus, ChatPane, PaneAccount, PaneDirectory, InviteResult, PaneDelivery, PaneSendResult, PaneFocusResult, GateStatus, GateOption, GateOrigin, GateQuestion, GateAnswer, GateRow, GateSubscription, HerdInfo, HerdListRow, HerdJobInfo, HerdStatusData, } from "./commands.ts";
6
11
  export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
7
12
  export type { RelayEventType } from "./relay.ts";
package/dist/index.js CHANGED
@@ -288,6 +288,13 @@ function gateOpen(a, o = {}) {
288
288
  payload[k] = a[k];
289
289
  return rtCommand("gate:open", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
290
290
  }
291
+ function gateAsk(a, o = {}) {
292
+ const payload = { questions: a.questions };
293
+ for (const k of ["context", "kind", "subject", "sessionId", "paneId", "meta", "agent", "origin"])
294
+ if (a[k] !== undefined)
295
+ payload[k] = a[k];
296
+ return rtCommand("gate:ask", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
297
+ }
291
298
  function gateAnswer(a, o = {}) {
292
299
  const payload = { id: a.id, answers: a.answers, by: a.by };
293
300
  for (const k of ["session", "override"])
@@ -359,7 +366,8 @@ function herdMilestone(a, o = {}) {
359
366
  return rtCommand("herd:milestone", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
360
367
  }
361
368
  function herdAnswer(a, o = {}) {
362
- return rtCommand("herd:answer", { gate: a.gate }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
369
+ const payload = { gate: a.gate, ...a.sessionId !== undefined ? { sessionId: a.sessionId } : {} };
370
+ return rtCommand("herd:answer", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
363
371
  }
364
372
  function herdReport(a, o = {}) {
365
373
  return rtCommand("herd:report", { herd: a.herd, job: a.job, body: a.body }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 30000 });
@@ -477,6 +485,7 @@ var COMMAND_NAMES = [
477
485
  "discussions:resolve",
478
486
  "discussions:reply",
479
487
  "discussions:diffs",
488
+ "mr:comment-inline",
480
489
  "mr:action",
481
490
  "mr:fetch-job-detail",
482
491
  "mr:fetch-job-trace",
@@ -489,6 +498,7 @@ var COMMAND_NAMES = [
489
498
  "reconciler:status",
490
499
  "reconciler:clear",
491
500
  "gate:open",
501
+ "gate:ask",
492
502
  "gate:answer",
493
503
  "gate:wait",
494
504
  "gate:list",
@@ -530,6 +540,92 @@ var COMMAND_NAMES = [
530
540
  "bg:stop",
531
541
  "bg:release"
532
542
  ];
543
+ // src/gate-presentation.ts
544
+ var GATE_FORM_OPTION_CAP = 4;
545
+ function gatePresentation(args) {
546
+ if (!args.paneId || !args.sessionId)
547
+ return "wait";
548
+ return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP) ? "form" : "wait";
549
+ }
550
+ // src/gate-options.ts
551
+ var RECOMMENDED_SUFFIX = " (Recommended)";
552
+ var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
553
+ function isWordLikeLabel(label) {
554
+ return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
555
+ }
556
+ function capitalize(label) {
557
+ return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
558
+ }
559
+ function normalizeGateOptions(options) {
560
+ return options.map((o) => {
561
+ const recommended = o !== null && typeof o === "object" && o.recommended === true;
562
+ let value;
563
+ let label;
564
+ if (typeof o === "string") {
565
+ value = o;
566
+ label = o;
567
+ } else if (o !== null && typeof o === "object") {
568
+ const v = typeof o.value === "string" ? o.value : undefined;
569
+ const l = typeof o.label === "string" ? o.label : undefined;
570
+ value = v ?? l ?? String(o);
571
+ label = l ?? v ?? String(o);
572
+ } else {
573
+ value = String(o);
574
+ label = String(o);
575
+ }
576
+ label = capitalize(label);
577
+ if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
578
+ label += RECOMMENDED_SUFFIX;
579
+ return { value, label };
580
+ });
581
+ }
582
+ function normalizeGateQuestions(questions) {
583
+ return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
584
+ }
585
+ // src/gate-answers.ts
586
+ function unwrapGateAnswerValue(raw) {
587
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
588
+ return raw.value;
589
+ }
590
+ return raw;
591
+ }
592
+ function wrapperNoteIsValid(raw) {
593
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in raw)) {
594
+ return true;
595
+ }
596
+ const note = raw.note;
597
+ return note === undefined || typeof note === "string";
598
+ }
599
+ function validateGateAnswers(questions, answers) {
600
+ const byId = new Map(questions.map((q) => [q.id, q]));
601
+ for (const [qid, raw] of Object.entries(answers)) {
602
+ const question = byId.get(qid);
603
+ if (!question)
604
+ return `unknown question id: ${qid}`;
605
+ if (!wrapperNoteIsValid(raw))
606
+ return `question ${qid} note must be a string`;
607
+ const value = unwrapGateAnswerValue(raw);
608
+ const isArray = Array.isArray(value);
609
+ if (question.multi && !isArray)
610
+ return `question ${qid} expects an array (multi)`;
611
+ if (!question.multi && isArray)
612
+ return `question ${qid} expects a single value`;
613
+ const values = isArray ? value : [value];
614
+ if (!values.every((v) => typeof v === "string"))
615
+ return `question ${qid} value must be a string`;
616
+ if (question.options.length > 0) {
617
+ const members = question.options.map(gateOptionValue);
618
+ for (const v of values) {
619
+ if (!members.includes(v))
620
+ return `answer for "${qid}" is not one of its options: "${v}"`;
621
+ }
622
+ }
623
+ }
624
+ const missing = questions.map((q) => q.id).filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
625
+ if (missing.length > 0)
626
+ return `missing answer(s) for: ${missing.join(", ")}`;
627
+ return null;
628
+ }
533
629
  // src/relay.ts
534
630
  var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
535
631
  function subscribe(onEvent, opts = {}) {
@@ -840,7 +936,7 @@ var REGISTRY = [
840
936
  scopes: ["user"],
841
937
  default: [],
842
938
  merge: "replace",
843
- description: 'Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?, owner?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload\'s first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload\'s subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification\'s Open target; a gate rule should set it. The optional owner field (only literal "human" is valid) suppresses events whose payload.owner starts with "herd:", allowing gate rules to skip herd-owned events. A typical setup pairs a gate/opened rule with owner: "human" (human-owned gates notify) and a gate/escalated rule without owner (all escalations notify, whether human or herd). A fresh key, not an ownership-latch port, so a default is fine here.'
939
+ description: 'Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?, owner?, surface?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload\'s first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload\'s subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification\'s Open target; a gate rule should set it. The optional owner field (only literal "human" is valid) suppresses events whose payload.owner starts with "herd:", allowing gate rules to skip herd-owned events. The optional surface field (a string) matches only events whose payload.origin.surface equals the given value, allowing a rule to fire only for gates opened by a specific surface (e.g. surface: "board" matches only board-originated gates). A typical setup pairs a gate/opened rule with owner: "human" (human-owned gates notify) and a gate/escalated rule without owner (all escalations notify, whether human or herd). A board-specific rule can combine surface: "board" with a board URL to open the decision queue directly. A fresh key, not an ownership-latch port, so a default is fine here.'
844
940
  },
845
941
  {
846
942
  key: "rt.cron",
@@ -2262,6 +2358,8 @@ async function resolveNameToIdentity(name, reposJsonPath) {
2262
2358
  }
2263
2359
  export {
2264
2360
  validateValue,
2361
+ validateGateAnswers,
2362
+ unwrapGateAnswerValue,
2265
2363
  unsetSetting,
2266
2364
  subscribe,
2267
2365
  setSettingsWarnSink,
@@ -2289,6 +2387,8 @@ export {
2289
2387
  paneAccounts,
2290
2388
  openSmartPane,
2291
2389
  normalizeRemote,
2390
+ normalizeGateQuestions,
2391
+ normalizeGateOptions,
2292
2392
  listTeams,
2293
2393
  listSettings,
2294
2394
  listRuns,
@@ -2315,12 +2415,14 @@ export {
2315
2415
  gateUnsubscribe,
2316
2416
  gateSubscriptions,
2317
2417
  gateSubscribe,
2418
+ gatePresentation,
2318
2419
  gatePark,
2319
2420
  gateOptionValue,
2320
2421
  gateOptionLabel,
2321
2422
  gateOpen,
2322
2423
  gateList,
2323
2424
  gateClose,
2425
+ gateAsk,
2324
2426
  gateAnswer,
2325
2427
  formatPaneRef,
2326
2428
  explainSetting,
@@ -2366,6 +2468,7 @@ export {
2366
2468
  abandonRun,
2367
2469
  SCOPE_ORDER,
2368
2470
  REGISTRY,
2471
+ GATE_FORM_OPTION_CAP,
2369
2472
  GATE_BY_PANE,
2370
2473
  DEFAULT_WS_URL,
2371
2474
  DEFAULT_SOCK,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.20.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/client.ts CHANGED
@@ -472,6 +472,15 @@ export function gateOpen(
472
472
  return rtCommand<Commands["gate:open"]["data"]>("gate:open", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
473
473
  }
474
474
 
475
+ export function gateAsk(
476
+ a: Commands["gate:ask"]["payload"],
477
+ o: RtClientOptions = {},
478
+ ): Promise<RtResponse<Commands["gate:ask"]["data"]>> {
479
+ const payload: Record<string, unknown> = { questions: a.questions };
480
+ for (const k of ["context", "kind", "subject", "sessionId", "paneId", "meta", "agent", "origin"] as const) if (a[k] !== undefined) payload[k] = a[k];
481
+ return rtCommand<Commands["gate:ask"]["data"]>("gate:ask", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
482
+ }
483
+
475
484
  export function gateAnswer(
476
485
  a: Commands["gate:answer"]["payload"],
477
486
  o: RtClientOptions = {},
@@ -586,7 +595,8 @@ export function herdAnswer(
586
595
  a: Commands["herd:answer"]["payload"],
587
596
  o: RtClientOptions = {},
588
597
  ): Promise<RtResponse<Commands["herd:answer"]["data"]>> {
589
- return rtCommand<Commands["herd:answer"]["data"]>("herd:answer", { gate: a.gate }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
598
+ const payload = { gate: a.gate, ...(a.sessionId !== undefined ? { sessionId: a.sessionId } : {}) };
599
+ return rtCommand<Commands["herd:answer"]["data"]>("herd:answer", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 });
590
600
  }
591
601
 
592
602
  /** A disposable job's report also closes its pane, one herdr CLI call under the runner's own 15s budget. */
package/src/commands.ts CHANGED
@@ -97,12 +97,13 @@ export const GATE_BY_PANE = "pane";
97
97
  export type GateStatus = "open" | "answered" | "parked" | "closed";
98
98
  /** Reconciler's view of an agent's liveness; also the value `GateRow.executor` is stamped with. */
99
99
  export type ExecutorState = "live" | "blocked" | "hidden" | "gone" | "cleared" | "unknown";
100
- export type GateOption = string | { value: string; label: string };
100
+ export type GateOption = string | { value: string; label: string; recommended?: boolean };
101
101
  export interface GateOrigin {
102
102
  paneId?: string;
103
103
  tabId?: string;
104
104
  runId?: string;
105
105
  worktree?: string;
106
+ surface?: string;
106
107
  presentation?: "form" | "wait";
107
108
  }
108
109
  export interface GateQuestion { id: string; label: string; multi: boolean; options: GateOption[] }
@@ -112,7 +113,12 @@ export function gateOptionValue(o: GateOption): string {
112
113
  export function gateOptionLabel(o: GateOption): string {
113
114
  return typeof o === "string" ? o : (o.label || o.value);
114
115
  }
115
- export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number; overridden?: boolean }
116
+ /** `session` is the answering surface's own session id, recorded so the
117
+ push facility can tell a self-answer from a remote one and skip the
118
+ doorbell it would otherwise send back to the writer. Optional: a caller
119
+ that supplies none (the board status-bin answers `by: "pane"` with no
120
+ session) still matches self by `by === GATE_BY_PANE`. */
121
+ export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number; overridden?: boolean; session?: string }
116
122
  export interface GateRow {
117
123
  id: string; subject: string; kind: string;
118
124
  questions: GateQuestion[]; meta: Record<string, unknown> | null;
@@ -130,6 +136,15 @@ export interface GateRow {
130
136
  nudge: { session: string } | null;
131
137
  delivery: { outcome: "delivered" | "dead-pane" | "confirmed" | "stuck"; at: number } | null;
132
138
  released: boolean;
139
+ /** Set once the nudged pane has provably read the answer: either it
140
+ self-answered (stamped in the same transaction as the answer) or a
141
+ later `markConsumed` call recorded that it acted on a push. `null`
142
+ until then, so a sweep can tell an answered-but-unread row from a
143
+ settled one. Currently only stamped for herd-subject gates (the
144
+ self-answer path and `rt herd answer`'s nudged-session read); a
145
+ non-herd gate with a nudge stays `null` even after its pane
146
+ reconciles. */
147
+ consumedAt: number | null;
133
148
  owner: string | null;
134
149
  escalatedAt: number | null;
135
150
  /** Set by answer-time execution handling: an answered gate whose executor
@@ -154,10 +169,10 @@ export interface HerdInfo { id: string; repo: string; room: string; workspace: s
154
169
  /** A herd row as `herd:list` reports it: the registry row plus how many jobs hang off it. */
155
170
  export interface HerdListRow extends HerdInfo { jobs: number }
156
171
  export interface HerdJobInfo { herd: string; name: string; worktree: string; branch: string | null; tree: string | null; pane: string | null; agentSession: string | null; agentId: string | null; handle: string; status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed"; disposable: boolean; lastGate: string | null; lastReport: number | null; createdAt: number; updatedAt: number }
157
- /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. */
172
+ /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. `lastGateConsumed` is `null` when there is nothing to consume (no last gate, not answered, or not nudged), and otherwise reports whether the nudged pane has read its answer. */
158
173
  export interface HerdStatusData {
159
174
  herd: HerdInfo;
160
- jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null }>;
175
+ jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | "confirmed" | "stuck" | null; lastGateConsumed: boolean | null }>;
161
176
  unread: number;
162
177
  lifecycleConnected: boolean;
163
178
  hiddenUp: boolean | null;
@@ -642,6 +657,18 @@ export interface Commands {
642
657
  "discussions:reply": { payload: { repoName: string; iid: number; discussionId: string; body: string }; data: DiscussionsWriteData };
643
658
  "discussions:diffs": { payload: { repoName: string; iid: number }; data: DiscussionsDiffsData };
644
659
 
660
+ /** Positioned inline MR comment with server-side DiffNote verification:
661
+ posts, re-checks the created note's type from the creation response,
662
+ and on the silent general-note degrade deletes the stray note and
663
+ retries ONCE with freshly fetched diff_refs. The retry repairs only
664
+ the stale-diff-refs degrade; a caller-supplied position GitLab
665
+ rejects stays rejected, and after a second degrade both stray notes
666
+ are deleted and the call fails. `verified: true` means the check ran. */
667
+ "mr:comment-inline": {
668
+ payload: { repoName: string; iid: number; body: string; path: string; line: number; oldPath?: string; oldLine?: number };
669
+ data: { discussionId: string; noteId: number; verified: true };
670
+ };
671
+
645
672
  /** Wire reply is `{ok:true}` on success (no `data`); a failure is `{ok:false,error}`. */
646
673
  "mr:action": { payload: { repoName: string; iid: number; action: MRActionName; args?: unknown[] }; data: Record<string, never> };
647
674
  "mr:fetch-job-detail": { payload: { repoName: string; iid: number; jobId: number; pipelineId?: number }; data: MrJobDetail };
@@ -663,6 +690,32 @@ export interface Commands {
663
690
 
664
691
  // ─── Gate facility (BOARD-20/21) ─────────────────────────────────────────
665
692
  "gate:open": { payload: { subject: string; kind: string; questions: GateQuestion[]; meta?: Record<string, unknown>; agent?: string; pane?: string; nudge?: { session: string }; context?: string; origin?: GateOrigin }; data: { id: string; supersededId: string | null } };
693
+ /** Ceremony layer over gate:open: resolves subject from the caller's
694
+ session (an explicit subject always wins as the subject, but still
695
+ picks up run linkage from the session's own running run; else its
696
+ single running run as `run:<id>`; else its agent record's recorded
697
+ subject, or `agent:<id>` when it has none), computes
698
+ presentation via gatePresentation, supplies nudge/origin, and omits an
699
+ oversized context instead of rejecting it. `meta` and `agent` forward
700
+ to gate:open verbatim; `origin` passthrough fields fill gaps in the
701
+ ceremony's own origin (presentation, paneId, runId, run-derived
702
+ worktree always win over a caller-supplied value for the same key).
703
+ Delegates to gate:open for everything else (validation, supersede,
704
+ events, push). */
705
+ "gate:ask": {
706
+ payload: {
707
+ questions: GateQuestion[];
708
+ context?: string;
709
+ kind?: string;
710
+ subject?: string;
711
+ sessionId?: string;
712
+ paneId?: string;
713
+ meta?: Record<string, unknown>;
714
+ agent?: string;
715
+ origin?: { surface?: string; tabId?: string; worktree?: string };
716
+ };
717
+ data: { id: string; presentation: "form" | "wait"; subject: string; supersededId: string | null };
718
+ };
666
719
  /**
667
720
  * A CAS loss is a DEFINED OUTCOME, not an error: `ok:true` with
668
721
  * `conflict:true` and the WINNING row, so every consumer gets the winner
@@ -703,7 +756,7 @@ export interface Commands {
703
756
  "herd:gates": { payload: { herd: string }; data: { gates: GateRow[] } };
704
757
  "herd:ask": { payload: { herd: string; job: string; session: string; pane?: string; questions: GateQuestion[]; context?: string }; data: { gate: string } };
705
758
  "herd:milestone": { payload: { herd: string; job: string; session: string; pane?: string; artifact: string; summary?: string }; data: { gate: string; message: number } };
706
- "herd:answer": { payload: { gate: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
759
+ "herd:answer": { payload: { gate: string; sessionId?: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
707
760
  "herd:report": { payload: { herd: string; job: string; body: string }; data: { message: number } };
708
761
  /** `callerWorkspace` is the attending session's own HERDR_WORKSPACE_ID: the attached tab opens there, not in the herd's workspace. */
709
762
  "herd:attend": { payload: { herd: string; job: string; callerWorkspace: string }; data: { tab: string; pane: string } };
@@ -802,6 +855,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [
802
855
  "discussions:resolve",
803
856
  "discussions:reply",
804
857
  "discussions:diffs",
858
+ "mr:comment-inline",
805
859
  "mr:action",
806
860
  "mr:fetch-job-detail",
807
861
  "mr:fetch-job-trace",
@@ -814,6 +868,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [
814
868
  "reconciler:status",
815
869
  "reconciler:clear",
816
870
  "gate:open",
871
+ "gate:ask",
817
872
  "gate:answer",
818
873
  "gate:wait",
819
874
  "gate:list",
@@ -0,0 +1,54 @@
1
+ import type { GateQuestion, GateAnswer } from "./commands.ts";
2
+ import { gateOptionValue } from "./commands.ts";
3
+
4
+ export type GateAnswerWire = GateAnswer["answers"][string];
5
+
6
+ /** Both wire shapes carry the same value underneath: bare, or {value, note?}
7
+ when a panel attaches free text. Validation reads only the value. */
8
+ export function unwrapGateAnswerValue(raw: unknown): unknown {
9
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in (raw as Record<string, unknown>)) {
10
+ return (raw as { value: unknown }).value;
11
+ }
12
+ return raw;
13
+ }
14
+
15
+ function wrapperNoteIsValid(raw: unknown): boolean {
16
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in (raw as Record<string, unknown>))) {
17
+ return true;
18
+ }
19
+ const note = (raw as Record<string, unknown>).note;
20
+ return note === undefined || typeof note === "string";
21
+ }
22
+
23
+ /** Option membership is required whenever a question declares options,
24
+ checked against the unwrapped value (every element, for multi); an
25
+ empty options array stays free-form. Every question id must appear as
26
+ an answers key. Error strings are a wire contract; packages/rt-client/test/gate-answers.test.ts pins them verbatim. */
27
+ export function validateGateAnswers(
28
+ questions: GateQuestion[],
29
+ answers: Record<string, unknown>,
30
+ ): string | null {
31
+ const byId = new Map(questions.map((q) => [q.id, q]));
32
+ for (const [qid, raw] of Object.entries(answers)) {
33
+ const question = byId.get(qid);
34
+ if (!question) return `unknown question id: ${qid}`;
35
+ if (!wrapperNoteIsValid(raw)) return `question ${qid} note must be a string`;
36
+ const value = unwrapGateAnswerValue(raw);
37
+ const isArray = Array.isArray(value);
38
+ if (question.multi && !isArray) return `question ${qid} expects an array (multi)`;
39
+ if (!question.multi && isArray) return `question ${qid} expects a single value`;
40
+ const values = isArray ? (value as unknown[]) : [value];
41
+ if (!values.every((v) => typeof v === "string")) return `question ${qid} value must be a string`;
42
+ if (question.options.length > 0) {
43
+ const members = question.options.map(gateOptionValue);
44
+ for (const v of values as string[]) {
45
+ if (!members.includes(v)) return `answer for "${qid}" is not one of its options: "${v}"`;
46
+ }
47
+ }
48
+ }
49
+ const missing = questions
50
+ .map((q) => q.id)
51
+ .filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
52
+ if (missing.length > 0) return `missing answer(s) for: ${missing.join(", ")}`;
53
+ return null;
54
+ }
@@ -0,0 +1,72 @@
1
+ import type { GateOption, GateQuestion } from "./commands.ts";
2
+
3
+ /** The canonical stored/emitted option shape (contract C11). GateOption
4
+ (the input union) is unchanged; rows normalized by the daemon always
5
+ satisfy this. */
6
+ export interface GateOptionObject {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ /** Suffix gate-kit's stripRecommended (mattstack-apps repo,
12
+ packages/gate-kit/src/options.ts) parses off a label to render its own
13
+ "recommended" badge. This is the
14
+ wire representation of `recommended: true` -- the flag itself never
15
+ reaches the normalized output. */
16
+ const RECOMMENDED_SUFFIX = " (Recommended)";
17
+ const HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
18
+
19
+ /** A label is word-like -- eligible for auto-capitalization -- only when it
20
+ starts with a lowercase ASCII letter, has no digit/`/`/`:`/`\`/`@`
21
+ anywhere (those mark paths, ids, and verb:token pairs that must not be
22
+ reworded), and has no uppercase letter already (mixed-case labels like
23
+ "gitLab" are left as the caller spelled them). */
24
+ function isWordLikeLabel(label: string): boolean {
25
+ return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
26
+ }
27
+
28
+ function capitalize(label: string): string {
29
+ return isWordLikeLabel(label) ? label[0]!.toUpperCase() + label.slice(1) : label;
30
+ }
31
+
32
+ /** Bare string s becomes {value: s, label: s}; a well-formed {value,label}
33
+ object passes through untouched. Total over whatever actually arrives
34
+ on the wire, not just the declared GateOption union: a partial object
35
+ fills the missing field from the one present, and anything else
36
+ (null, a number, an object with neither field) is coerced via String()
37
+ into both fields. The resulting label is then capitalized when
38
+ word-like (see isWordLikeLabel), and an object form's `recommended:
39
+ true` lifts into a " (Recommended)" label suffix -- guarded against
40
+ double-appending -- since `recommended` itself does not survive into
41
+ the returned object; the suffix IS its wire representation. Every
42
+ returned entry is a full {value,label} pair -- callers may trust the
43
+ return type without re-checking it. Pure and order-preserving. */
44
+ export function normalizeGateOptions(options: GateOption[]): GateOptionObject[] {
45
+ return options.map((o) => {
46
+ const recommended = o !== null && typeof o === "object" && (o as { recommended?: unknown }).recommended === true;
47
+ let value: string;
48
+ let label: string;
49
+ if (typeof o === "string") {
50
+ value = o;
51
+ label = o;
52
+ } else if (o !== null && typeof o === "object") {
53
+ const v = typeof (o as { value?: unknown }).value === "string" ? (o as { value: string }).value : undefined;
54
+ const l = typeof (o as { label?: unknown }).label === "string" ? (o as { label: string }).label : undefined;
55
+ value = v ?? l ?? String(o);
56
+ label = l ?? v ?? String(o);
57
+ } else {
58
+ value = String(o);
59
+ label = String(o);
60
+ }
61
+ label = capitalize(label);
62
+ // An empty label stays empty rather than becoming just the suffix: a
63
+ // downstream `label || value` fallback (gate-kit) must still see label
64
+ // as absent, not as a non-empty "(Recommended)" that hides the value.
65
+ if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label)) label += RECOMMENDED_SUFFIX;
66
+ return { value, label };
67
+ });
68
+ }
69
+
70
+ export function normalizeGateQuestions(questions: GateQuestion[]): GateQuestion[] {
71
+ return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
72
+ }
@@ -0,0 +1,17 @@
1
+ import type { GateQuestion } from "./commands.ts";
2
+
3
+ export const GATE_FORM_OPTION_CAP = 4;
4
+
5
+ /** The ONE presentation rule (spec Phase 1): form iff an injectable pane
6
+ exists, a nudge target exists, and every question fits the native form's
7
+ per-question option cap. */
8
+ export function gatePresentation(args: {
9
+ paneId?: string | undefined;
10
+ sessionId?: string | undefined;
11
+ questions: GateQuestion[];
12
+ }): "form" | "wait" {
13
+ if (!args.paneId || !args.sessionId) return "wait";
14
+ return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP)
15
+ ? "form"
16
+ : "wait";
17
+ }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  reconcilerStatus,
49
49
  reconcilerClear,
50
50
  gateOpen,
51
+ gateAsk,
51
52
  gateAnswer,
52
53
  gateWait,
53
54
  gateList,
@@ -77,6 +78,11 @@ export {
77
78
  } from "./client.ts";
78
79
 
79
80
  export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
81
+ export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
82
+ export { normalizeGateOptions, normalizeGateQuestions } from "./gate-options.ts";
83
+ export type { GateOptionObject } from "./gate-options.ts";
84
+ export { unwrapGateAnswerValue, validateGateAnswers } from "./gate-answers.ts";
85
+ export type { GateAnswerWire } from "./gate-answers.ts";
80
86
  export type {
81
87
  Discussion,
82
88
  DemandDecl,
@@ -85,7 +85,7 @@ export const REGISTRY: readonly SettingDef[] = [
85
85
  scopes: ["user"],
86
86
  default: [],
87
87
  merge: "replace",
88
- description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?, owner?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload's first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload's subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification's Open target; a gate rule should set it. The optional owner field (only literal \"human\" is valid) suppresses events whose payload.owner starts with \"herd:\", allowing gate rules to skip herd-owned events. A typical setup pairs a gate/opened rule with owner: \"human\" (human-owned gates notify) and a gate/escalated rule without owner (all escalations notify, whether human or herd). A fresh key, not an ownership-latch port, so a default is fine here.",
88
+ description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?, owner?, surface?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload's first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload's subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification's Open target; a gate rule should set it. The optional owner field (only literal \"human\" is valid) suppresses events whose payload.owner starts with \"herd:\", allowing gate rules to skip herd-owned events. The optional surface field (a string) matches only events whose payload.origin.surface equals the given value, allowing a rule to fire only for gates opened by a specific surface (e.g. surface: \"board\" matches only board-originated gates). A typical setup pairs a gate/opened rule with owner: \"human\" (human-owned gates notify) and a gate/escalated rule without owner (all escalations notify, whether human or herd). A board-specific rule can combine surface: \"board\" with a board URL to open the decision queue directly. A fresh key, not an ownership-latch port, so a default is fine here.",
89
89
  },
90
90
  {
91
91
  key: "rt.cron",