@relayflows/sdk 2.0.19 → 2.0.21

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 (52) hide show
  1. package/dist/agent-reaper.d.ts +4 -0
  2. package/dist/agent-reaper.d.ts.map +1 -0
  3. package/dist/agent-reaper.js +53 -0
  4. package/dist/agent-reaper.js.map +1 -0
  5. package/dist/authored-flow-error.d.ts +6 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js.map +1 -1
  8. package/dist/authored-flow-executor.d.ts.map +1 -1
  9. package/dist/authored-flow-executor.js +8 -0
  10. package/dist/authored-flow-executor.js.map +1 -1
  11. package/dist/child-stop.d.ts +1 -1
  12. package/dist/child-stop.d.ts.map +1 -1
  13. package/dist/child-stop.js +68 -12
  14. package/dist/child-stop.js.map +1 -1
  15. package/dist/cli/answer.d.ts.map +1 -1
  16. package/dist/cli/answer.js +2 -1
  17. package/dist/cli/answer.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +2 -1
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/flow-requirements.d.ts +2 -2
  22. package/dist/flow-requirements.d.ts.map +1 -1
  23. package/dist/flow-requirements.js +182 -0
  24. package/dist/flow-requirements.js.map +1 -1
  25. package/dist/human-to.d.ts +45 -0
  26. package/dist/human-to.d.ts.map +1 -0
  27. package/dist/human-to.js +49 -0
  28. package/dist/human-to.js.map +1 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/worker-cli.d.ts +8 -0
  34. package/dist/worker-cli.d.ts.map +1 -1
  35. package/dist/worker-cli.js +63 -8
  36. package/dist/worker-cli.js.map +1 -1
  37. package/dist/worker-usage.d.ts +8 -0
  38. package/dist/worker-usage.d.ts.map +1 -1
  39. package/dist/worker-usage.js +19 -0
  40. package/dist/worker-usage.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/agent-reaper.ts +52 -0
  43. package/src/authored-flow-error.ts +5 -0
  44. package/src/authored-flow-executor.ts +8 -0
  45. package/src/child-stop.ts +62 -12
  46. package/src/cli/answer.ts +2 -1
  47. package/src/cli/run.ts +2 -1
  48. package/src/flow-requirements.ts +143 -2
  49. package/src/human-to.ts +71 -0
  50. package/src/index.ts +1 -0
  51. package/src/worker-cli.ts +59 -7
  52. package/src/worker-usage.ts +13 -0
@@ -1 +1 @@
1
- {"version":3,"file":"worker-usage.d.ts","sourceRoot":"","sources":["../src/worker-usage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAoBvD,4EAA4E;AAC5E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,eAAe,CAUvG;AAED,iFAAiF;AACjF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,eAAe,GAAG,eAAe,CAK5E;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CAM3F"}
1
+ {"version":3,"file":"worker-usage.d.ts","sourceRoot":"","sources":["../src/worker-usage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAoBvD,4EAA4E;AAC5E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,eAAe,CAUvG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAMjF;AAED,iFAAiF;AACjF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,eAAe,GAAG,eAAe,CAK5E;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CAM3F"}
@@ -31,6 +31,25 @@ export function decodeProviderResult(result, kind) {
31
31
  .flatMap(f => f.type === 'item.completed' && record(f.item) && f.item.type === 'agent_message' && typeof f.item.text === 'string' ? [f.item.text] : []).join('\n');
32
32
  return usageResult(result, terminal.usage, text);
33
33
  }
34
+ /**
35
+ * Claude's terminal `stream-json` frame, recognised line by line as it
36
+ * streams. `failed` mirrors the exit status Claude gives that result: an
37
+ * error flag or a non-success subtype is a failed run.
38
+ */
39
+ export function claudeResultOutcome(line) {
40
+ if (!line.includes('"result"'))
41
+ return undefined;
42
+ let frame;
43
+ try {
44
+ frame = JSON.parse(line);
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ if (!record(frame) || frame.type !== 'result')
50
+ return undefined;
51
+ return { failed: frame.is_error === true || (typeof frame.subtype === 'string' && frame.subtype !== 'success') };
52
+ }
34
53
  /** Optional wrapper result envelope; existing opaque text output stays valid. */
35
54
  export function decodeWrapperResult(result) {
36
55
  let value;
@@ -1 +1 @@
1
- {"version":3,"file":"worker-usage.js","sourceRoot":"","sources":["../src/worker-usage.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"worker-usage.js","sourceRoot":"","sources":["../src/worker-usage.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayflows/sdk",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "TypeScript-first authoring SDK for Relayflows. Compiles specs; speaks the journal protocol.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "@modelcontextprotocol/sdk": "^1.30.0",
50
50
  "@relayfile/adapter-core": "0.5.26",
51
51
  "@relayfile/relay-helpers": "0.4.11",
52
- "@relayflows/surface": "2.0.19",
52
+ "@relayflows/surface": "2.0.21",
53
53
  "@types/js-yaml": "^4.0.9",
54
54
  "ai-hist": "0.4.1",
55
55
  "ajv": "^8.17.1",
@@ -0,0 +1,52 @@
1
+ import type { ChildStop } from './child-stop.js';
2
+
3
+ /**
4
+ * An agent CLI runs in a process group of its own, so nothing that ends this
5
+ * process reaches it by default: a signal to our pid, or to our group, leaves
6
+ * it running, reparented to init. That is how a `claude` outlived a Cloud run
7
+ * that was killed at its deadline, for hours. While any agent is live this
8
+ * module kills every live agent tree on the way out:
9
+ *
10
+ * - on `'exit'`, which covers normal exit and `process.exit()`;
11
+ * - on SIGINT, SIGTERM and SIGHUP, but only where the default action would
12
+ * have ended the process anyway — this listener is the only one. It then
13
+ * removes itself and re-raises the signal, so the process still dies of it.
14
+ * A host that installed its own listener owns that signal; it aborts the
15
+ * lease signal, and the abort already stops the tree.
16
+ *
17
+ * Nothing survives a SIGKILL of this process; that needs a supervisor.
18
+ */
19
+ const live = new Set<ChildStop>();
20
+ const FATAL_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
21
+
22
+ function killAll(): void {
23
+ for (const stop of live) stop.kill();
24
+ live.clear();
25
+ }
26
+
27
+ function onFatalSignal(signal: NodeJS.Signals): void {
28
+ if (process.listenerCount(signal) !== 1) return;
29
+ killAll();
30
+ uninstall();
31
+ process.kill(process.pid, signal);
32
+ }
33
+
34
+ function install(): void {
35
+ process.on('exit', killAll);
36
+ for (const signal of FATAL_SIGNALS) process.on(signal, onFatalSignal);
37
+ }
38
+
39
+ function uninstall(): void {
40
+ process.off('exit', killAll);
41
+ for (const signal of FATAL_SIGNALS) process.off(signal, onFatalSignal);
42
+ }
43
+
44
+ /** Kill this agent tree if the process ends first. Returns the release. */
45
+ export function reapOnExit(stop: ChildStop): () => void {
46
+ if (live.size === 0) install();
47
+ live.add(stop);
48
+ return () => {
49
+ if (!live.delete(stop)) return;
50
+ if (live.size === 0) uninstall();
51
+ };
52
+ }
@@ -1,3 +1,4 @@
1
+ import type { HumanRecipient } from './human-to.js';
1
2
  import type {
2
3
  CompletionReason as ProtocolCompletionReason,
3
4
  RunCompletionReason as ProtocolRunCompletionReason,
@@ -28,6 +29,8 @@ export type AuthoredFlowExecutionErrorCode =
28
29
  | 'human_parked'
29
30
  /** A recorded answer to `f.human` was not `{ answer: boolean }`. */
30
31
  | 'human_answer_invalid'
32
+ /** `f.human`'s `to` is not one of the documented recipient forms (human-to.ts). */
33
+ | 'human_to_invalid'
31
34
  | 'unsupported_header'
32
35
  | 'unsettled_derived_work'
33
36
  | 'unsupported_promise_lifecycle'
@@ -52,6 +55,8 @@ export interface AuthoredHumanWait {
52
55
  readonly waitId: string;
53
56
  readonly question: string;
54
57
  readonly to: string;
58
+ /** `to` parsed into its delivery form (human-to.ts); Cloud delivers by it. */
59
+ readonly recipient?: HumanRecipient;
55
60
  }
56
61
 
57
62
  /**
@@ -36,6 +36,7 @@ import {
36
36
  type AuthoredFlowExecutionErrorCode,
37
37
  } from './authored-flow-error.js';
38
38
  import { readHumanAnswer } from './authored-human.js';
39
+ import { parseHumanTo } from './human-to.js';
39
40
  import {
40
41
  AuthoredFlowOperation,
41
42
  stopAuthoredOperations,
@@ -431,6 +432,13 @@ export async function executeAuthoredFlow<Input = undefined>(
431
432
  if (typeof humanOptions?.to !== 'string' || humanOptions.to.trim() === '') {
432
433
  throw new AuthoredFlowExecutionError('human_answer_invalid', 'f.human requires { to } naming who answers');
433
434
  }
435
+ // Refused before an ordinal is consumed or anything is journaled: a
436
+ // malformed `to` would otherwise park the run on a question Cloud can
437
+ // deliver to no one (docs/SURFACE.md §5 lists the four forms).
438
+ const parsedTo = parseHumanTo(humanOptions.to);
439
+ if (!parsedTo.ok) {
440
+ throw new AuthoredFlowExecutionError('human_to_invalid', `f.human to ${parsedTo.reason}`);
441
+ }
434
442
  const id = `human-${nextStep++}`;
435
443
  const to = humanOptions.to;
436
444
  // Hoisted like `llmOp`/`runOp`: the start closure reads the caller's
package/src/child-stop.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChildProcess } from 'node:child_process';
1
+ import { execFileSync, type ChildProcess } from 'node:child_process';
2
2
 
3
3
  /**
4
4
  * Grace between the graceful signal and the force kill. A tree that ignores
@@ -78,25 +78,40 @@ export function childStop(
78
78
  if (forceTimer !== undefined) clearTimeout(forceTimer);
79
79
  forceTimer = undefined;
80
80
  };
81
- /** Whether anything is still in the group. See `maySettleOnChildExit`. */
81
+ // Descendants that moved into process groups of their own, captured at the
82
+ // first stop. Claude Code runs a `run_in_background` Bash command that way,
83
+ // so a group signal alone leaves it running after the agent is gone. They
84
+ // must be found while the direct child is still their ancestor: once it
85
+ // dies they are reparented and no longer traceable to this spawn.
86
+ let escapedGroups: readonly number[] | undefined;
87
+ /**
88
+ * Whether anything the stop reaches is still alive: the child's own group or
89
+ * any group a descendant escaped into. See `maySettleOnChildExit`.
90
+ */
82
91
  const groupAnswers = (): boolean => {
83
92
  // With no group of our own a stop never reached past the direct child, so
84
93
  // that child's exit IS the whole of our reach and there is nothing left to
85
94
  // ask about.
86
95
  if (!ownsGroup || pid === undefined) return false;
87
- try {
88
- process.kill(-pid, 0);
89
- return true;
90
- } catch (error) {
91
- // Only `ESRCH` proves the group is empty. `EPERM` proves the opposite —
92
- // something is in there that we may not signal — and any other errno
93
- // proves nothing at all, so both must read as alive: an unproven group is
94
- // not a reason to spare a survivor.
95
- return (error as NodeJS.ErrnoException).code !== 'ESRCH';
96
- }
96
+ return [pid, ...(escapedGroups ?? [])].some(group => {
97
+ try {
98
+ process.kill(-group, 0);
99
+ return true;
100
+ } catch (error) {
101
+ // Only `ESRCH` proves the group is empty. `EPERM` proves the opposite
102
+ // — something is in there that we may not signal — and any other
103
+ // errno proves nothing at all, so both must read as alive: an unproven
104
+ // group is not a reason to spare a survivor.
105
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH';
106
+ }
107
+ });
97
108
  };
98
109
  const signalTree = (name: NodeJS.Signals): void => {
99
110
  if (ownsGroup && pid !== undefined) {
111
+ escapedGroups ??= descendantGroups(pid);
112
+ for (const group of escapedGroups) {
113
+ try { process.kill(-group, name); } catch { /* already gone */ }
114
+ }
100
115
  // `-pid` addresses the group this detached child leads, which is every
101
116
  // descendant that has not left it. It throws only once the whole group
102
117
  // is gone — the outcome we were asking for — so fall through and let the
@@ -136,3 +151,38 @@ export function childStop(
136
151
  },
137
152
  };
138
153
  }
154
+
155
+ /**
156
+ * Process groups, other than `rootPid`'s own and this process's, that hold a
157
+ * descendant of `rootPid`. Reads `ps` for pid, parent and group only — never a
158
+ * command line. Where `ps` cannot answer, the answer is none: the group signal
159
+ * still reaches everything that stayed in the group.
160
+ */
161
+ function descendantGroups(rootPid: number): number[] {
162
+ let table: string;
163
+ try {
164
+ table = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], {
165
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000,
166
+ });
167
+ } catch {
168
+ return [];
169
+ }
170
+ const children = new Map<number, number[]>();
171
+ const groupOf = new Map<number, number>();
172
+ for (const line of table.split('\n')) {
173
+ const fields = line.trim().split(/\s+/).map(Number);
174
+ if (fields.length !== 3 || !fields.every(Number.isSafeInteger)) continue;
175
+ const [row, parent, group] = fields as [number, number, number];
176
+ groupOf.set(row, group);
177
+ children.set(parent, [...(children.get(parent) ?? []), row]);
178
+ }
179
+ const spared = new Set([rootPid, groupOf.get(process.pid)]);
180
+ const groups = new Set<number>();
181
+ const pending = [...(children.get(rootPid) ?? [])];
182
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
183
+ const group = groupOf.get(next);
184
+ if (group !== undefined && group > 1 && !spared.has(group)) groups.add(group);
185
+ pending.push(...(children.get(next) ?? []));
186
+ }
187
+ return [...groups];
188
+ }
package/src/cli/answer.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseHumanRecipient } from '../human-to.js';
1
2
  import { userInfo } from 'node:os';
2
3
  import { readAuthoredRootMetadata } from '../authored-root.js';
3
4
  import { HUMAN_WAIT_ID, humanAnswerPayload, readHumanAnswer, readOpenHumanWaits, resumeCommand } from '../authored-human.js';
@@ -75,7 +76,7 @@ export async function answerFlow(
75
76
  report: {
76
77
  ...base, ok: true, runId, socketPath, status: snapshot.status,
77
78
  answer: { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) },
78
- humanWait: { waitId, question: wait.question, to: wait.to },
79
+ humanWait: { waitId, question: wait.question, to: wait.to, recipient: parseHumanRecipient(wait.to) },
79
80
  next: resumeCommand(runId, dataDir, root?.localAgentStream !== undefined),
80
81
  diagnostics: [],
81
82
  },
package/src/cli/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseHumanRecipient } from '../human-to.js';
1
2
  import { parseDigestReference } from '../bundle-transport.js';
2
3
  import { prepareDigestRun } from './run-digest.js';
3
4
  import { reuseSummary } from './reuse.js';
@@ -331,7 +332,7 @@ export function authoredHumanParked(
331
332
  socketPath,
332
333
  status: 'parked',
333
334
  parkedStep: { id: 'authored-root', type: 'agent' },
334
- humanWait: error.wait,
335
+ humanWait: { ...error.wait, recipient: parseHumanRecipient(error.wait.to) },
335
336
  diagnostics: [...base.diagnostics, {
336
337
  severity: 'parked',
337
338
  kind: 'run_parked',
@@ -1,3 +1,4 @@
1
+ import { humanRecipientProvider } from './human-to.js';
1
2
  import { helperProviders } from '@relayflows/surface/runtime';
2
3
  import type { TriggerSource } from '@relayflows/surface';
3
4
  import { providerDeclaration } from './provider-trigger-contract.js';
@@ -26,8 +27,8 @@ export interface FlowIntegrationRequirement {
26
27
  /** Cloud integration provider id (`slack`, `github`, `linear`, …). */
27
28
  provider: string;
28
29
  /** `tools`: a header declaration; `source`: a trigger or deploy target; `helper`: body use without a flag, or a YAML helper step. */
29
- from: 'tools' | 'source' | 'helper';
30
- /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`. */
30
+ from: 'tools' | 'source' | 'helper' | 'human';
31
+ /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, `f.human to`. */
31
32
  detail: string;
32
33
  }
33
34
 
@@ -119,6 +120,14 @@ export function flowRequirements(
119
120
  if (helperReference(root, namespace).test(text)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
120
121
  }
121
122
  for (const use of workerCalls(root, text)) need(use.cli === undefined ? fallback : harnessFromCli(use.cli), use.detail);
123
+ // `f.human(q, { to: "slack:#eng" })` is delivered by Cloud through that
124
+ // provider, so the deploy must have it connected. Only a literal `to`
125
+ // can be read here; a computed one (`input.approver`) is resolved by
126
+ // Cloud at park time against the run's own trigger channel.
127
+ for (const to of humanRecipients(root, text)) {
128
+ const provider = humanRecipientProvider(to);
129
+ if (provider !== undefined) declare({ provider, from: 'human', detail: 'f.human to' });
130
+ }
122
131
  }
123
132
  }
124
133
 
@@ -180,6 +189,138 @@ function helperReference(root: string, namespace: string): RegExp {
180
189
  return new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`, 'u');
181
190
  }
182
191
 
192
+ /**
193
+ * The literal `to` of each `f.human(question, { to: "…" })` call in the body.
194
+ *
195
+ * Bounded to the call's OWN argument list — the text between its `(` and the
196
+ * matching `)`, string- and nesting-aware — and within that to the top level
197
+ * of its options object, so a `to:` in a later call, in a nested object, in
198
+ * the question string, or in an unrelated `{ to }` of the surrounding code is
199
+ * never read as this call's recipient. Only a plain string literal counts; a
200
+ * template with interpolation or an identifier is a computed `to`, resolved
201
+ * by Cloud at park time.
202
+ */
203
+ function humanRecipients(root: string, body: string): string[] {
204
+ const call = new RegExp(`(?:^|[^\\w$.])${root}\\s*\\.\\s*human\\s*\\(`, 'gu');
205
+ const found: string[] = [];
206
+ for (const match of body.matchAll(call)) {
207
+ const open = match.index! + match[0].length - 1;
208
+ const close = matchingClose(body, open);
209
+ if (close === -1) continue;
210
+ const args = body.slice(open + 1, close);
211
+ const options = secondArgumentObject(args);
212
+ if (options === undefined) continue;
213
+ const to = topLevelStringProperty(options, 'to');
214
+ if (to !== undefined) found.push(to);
215
+ }
216
+ return found;
217
+ }
218
+
219
+ /**
220
+ * Skip the comment or string starting at `i`, returning the index just past
221
+ * it; `i` itself when nothing skippable starts there; -1 when unterminated.
222
+ * Every walker below steps through this, so a `,`, `to:` or bracket inside a
223
+ * comment or string is never read as syntax.
224
+ */
225
+ function skipCommentOrString(text: string, i: number): number {
226
+ const ch = text[i]!;
227
+ const next = text[i + 1];
228
+ if (ch === '/' && next === '/') { const end = text.indexOf('\n', i); return end === -1 ? text.length : end + 1; }
229
+ if (ch === '/' && next === '*') { const end = text.indexOf('*/', i + 2); return end === -1 ? -1 : end + 2; }
230
+ if (ch === '"' || ch === "'" || ch === '`') { const end = stringEnd(text, i); return end === -1 ? -1 : end + 1; }
231
+ return i;
232
+ }
233
+
234
+ /** Index of the `)`/`}`/`]` closing the bracket at `open`, skipping strings, templates and comments; -1 if unbalanced. */
235
+ function matchingClose(text: string, open: number): number {
236
+ const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
237
+ const stack: string[] = [pairs[text[open]!]!];
238
+ let i = open + 1;
239
+ while (i < text.length && stack.length > 0) {
240
+ const skipped = skipCommentOrString(text, i);
241
+ if (skipped === -1) return -1;
242
+ if (skipped !== i) { i = skipped; continue; }
243
+ const ch = text[i]!;
244
+ if (ch in pairs) stack.push(pairs[ch]!);
245
+ else if (ch === ')' || ch === '}' || ch === ']') { if (stack.pop() !== ch) return -1; }
246
+ i += 1;
247
+ }
248
+ return stack.length === 0 ? i - 1 : -1;
249
+ }
250
+
251
+ /** Index of the quote closing the string opening at `start` (template `${…}` skipped); -1 if unterminated. */
252
+ function stringEnd(text: string, start: number): number {
253
+ const quote = text[start]!;
254
+ let i = start + 1;
255
+ while (i < text.length) {
256
+ const ch = text[i]!;
257
+ if (ch === '\\') { i += 2; continue; }
258
+ if (ch === quote) return i;
259
+ if (quote === '`' && ch === '$' && text[i + 1] === '{') {
260
+ const end = matchingClose(text, i + 1);
261
+ if (end === -1) return -1;
262
+ i = end + 1;
263
+ continue;
264
+ }
265
+ i += 1;
266
+ }
267
+ return -1;
268
+ }
269
+
270
+ /** The `{ … }` that is the call's second top-level argument, or undefined. */
271
+ function secondArgumentObject(args: string): string | undefined {
272
+ let depth = 0;
273
+ let i = 0;
274
+ let commas = 0;
275
+ while (i < args.length) {
276
+ const skipped = skipCommentOrString(args, i);
277
+ if (skipped === -1) return undefined;
278
+ if (skipped !== i) { i = skipped; continue; }
279
+ const ch = args[i]!;
280
+ if (ch === '(' || ch === '{' || ch === '[') {
281
+ if (depth === 0 && commas === 1 && ch === '{') {
282
+ const close = matchingClose(args, i);
283
+ return close === -1 ? undefined : args.slice(i, close + 1);
284
+ }
285
+ depth += 1;
286
+ } else if (ch === ')' || ch === '}' || ch === ']') depth -= 1;
287
+ else if (ch === ',' && depth === 0) commas += 1;
288
+ i += 1;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ /** The plain string literal value of `name:` at the top level of an object literal, else undefined. */
294
+ function topLevelStringProperty(object: string, name: string): string | undefined {
295
+ const key = new RegExp(`^(?:${name}|'${name}'|"${name}")\\s*:\\s*`, 'u');
296
+ let depth = 0;
297
+ let i = 1; // past the opening brace
298
+ const end = object.length - 1;
299
+ while (i < end) {
300
+ const skipped = skipCommentOrString(object, i);
301
+ if (skipped === -1) return undefined;
302
+ if (skipped !== i) { i = skipped; continue; }
303
+ const ch = object[i]!;
304
+ if (ch === '(' || ch === '{' || ch === '[') { depth += 1; i += 1; continue; }
305
+ if (ch === ')' || ch === '}' || ch === ']') { depth -= 1; i += 1; continue; }
306
+ if (depth === 0 && (i === 1 || /[\s,{/]/u.test(object[i - 1]!))) {
307
+ const match = key.exec(object.slice(i));
308
+ if (match !== null) {
309
+ const valueStart = i + match[0].length;
310
+ const quote = object[valueStart];
311
+ if (quote !== '"' && quote !== "'" && quote !== '`') return undefined;
312
+ const valueEnd = stringEnd(object, valueStart);
313
+ if (valueEnd === -1) return undefined;
314
+ const raw = object.slice(valueStart + 1, valueEnd);
315
+ // An interpolated template is computed; a plain one is a literal.
316
+ return quote === '`' && /\$\{/u.test(raw) ? undefined : raw.replace(/\\(.)/gu, '$1');
317
+ }
318
+ }
319
+ i += 1;
320
+ }
321
+ return undefined;
322
+ }
323
+
183
324
  interface WorkerCall { detail: string; cli?: string }
184
325
 
185
326
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Who an `f.human(question, { to })` question is for, parsed from the
3
+ * author's `to` string. This is the delivery contract Cloud reads (the local
4
+ * kit records it, Cloud delivers it), so the forms are fixed here and
5
+ * documented in docs/SURFACE.md §5 Human gates:
6
+ *
7
+ * "slack:#eng" → post in the Slack channel; anyone there may answer
8
+ * "slack:@khaliq" → DM the Slack user; only they may answer
9
+ * "github:@khaliq" → comment on the triggering issue / pull request,
10
+ * mentioning the handle; only they may answer
11
+ * "khaliq" → the deploy's approver, on whichever channel the run
12
+ * was triggered from (Cloud resolves; the local kit
13
+ * only records it)
14
+ *
15
+ * Anything else — an unknown provider (`email:x`), a provider with no target
16
+ * (`slack:`), a GitHub channel (`github:#eng`), a target with whitespace or
17
+ * other characters no handle carries — is refused as `human_to_invalid`
18
+ * rather than turned into a delivery target that would silently reach no one.
19
+ */
20
+ export type HumanRecipient =
21
+ | { readonly provider: 'slack'; readonly kind: 'channel' | 'user'; readonly target: string }
22
+ | { readonly provider: 'github'; readonly kind: 'user'; readonly target: string }
23
+ | { readonly provider: 'approver'; readonly kind: 'user'; readonly target: string };
24
+
25
+ export type HumanRecipientParse =
26
+ | { readonly ok: true; readonly recipient: HumanRecipient }
27
+ | { readonly ok: false; readonly reason: string };
28
+
29
+ /** A Slack channel name, or a Slack / GitHub / approver handle: one word, no spaces or quotes. */
30
+ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/u;
31
+ const PROVIDER_FORM = /^([A-Za-z][A-Za-z0-9_-]*)\s*:(.*)$/su;
32
+
33
+ export function parseHumanTo(to: string): HumanRecipientParse {
34
+ const trimmed = to.trim();
35
+ if (trimmed === '') return { ok: false, reason: 'to is empty' };
36
+ const match = PROVIDER_FORM.exec(trimmed);
37
+ if (match === null) {
38
+ const target = trimmed.replace(/^@/u, '');
39
+ if (!TARGET.test(target)) return { ok: false, reason: `"${trimmed}" is not a handle (one word: letters, digits, . _ -)` };
40
+ return { ok: true, recipient: { provider: 'approver', kind: 'user', target } };
41
+ }
42
+ const provider = match[1]!.toLowerCase();
43
+ const rest = match[2]!.trim();
44
+ if (provider !== 'slack' && provider !== 'github') {
45
+ return { ok: false, reason: `"${provider}:" is not a delivery provider; use "slack:#channel", "slack:@user", "github:@user" or a bare approver handle` };
46
+ }
47
+ if (rest === '') return { ok: false, reason: `"${provider}:" names no ${provider === 'slack' ? 'channel or user' : 'user'}` };
48
+ if (rest.startsWith('#')) {
49
+ if (provider === 'github') return { ok: false, reason: `"${trimmed}": GitHub has no channels; use "github:@user"` };
50
+ const target = rest.slice(1);
51
+ if (!TARGET.test(target)) return { ok: false, reason: `"${trimmed}" is not a Slack channel name` };
52
+ return { ok: true, recipient: { provider: 'slack', kind: 'channel', target } };
53
+ }
54
+ const target = rest.replace(/^@/u, '');
55
+ if (!TARGET.test(target)) return { ok: false, reason: `"${trimmed}" is not a ${provider === 'slack' ? 'Slack' : 'GitHub'} handle` };
56
+ return { ok: true, recipient: { provider, kind: 'user', target } };
57
+ }
58
+
59
+ /** The parsed recipient of a `to` already accepted by `f.human` (`parseHumanTo` ok). */
60
+ export function parseHumanRecipient(to: string): HumanRecipient {
61
+ const parsed = parseHumanTo(to);
62
+ if (!parsed.ok) throw new Error(`f.human to ${parsed.reason}`);
63
+ return parsed.recipient;
64
+ }
65
+
66
+ /** The Cloud integration a recipient needs connected: none for the approver, none for a malformed `to`. */
67
+ export function humanRecipientProvider(to: string): 'slack' | 'github' | undefined {
68
+ const parsed = parseHumanTo(to);
69
+ if (!parsed.ok || parsed.recipient.provider === 'approver') return undefined;
70
+ return parsed.recipient.provider;
71
+ }
package/src/index.ts CHANGED
@@ -86,6 +86,7 @@ export {
86
86
  flowRequirements, describeFlowRequirements, harnessFromCli, FLOW_HARNESSES,
87
87
  type FlowRequirements, type FlowRequirementsContext, type FlowIntegrationRequirement, type FlowHarnessRequirement, type FlowHarness,
88
88
  } from './flow-requirements.js';
89
+ export { parseHumanTo, parseHumanRecipient, humanRecipientProvider, type HumanRecipient, type HumanRecipientParse } from './human-to.js';
89
90
 
90
91
  export { canonicalize, specHash } from './canonical.js';
91
92
  export {