harn-stdlib 0.10.1

Embedded Harn standard library source catalog
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
// Lanes: tool-surface narrowing keyed off a data-driven classification of
// the task, generalizing burin-code's `agent_lane_for_task`
// (`lib/runtime/agent-loop.harn`). Burin classifies a task ONCE, before the
// run starts, into a named lane (`"general"` vs a narrow `"explicit_patch"`
// lane keyed off detected target-file mentions) and hands that lane's tool
// allowlist to the whole agent_loop call — deliberately NOT reclassifying
// mid-transcript, because narrowing tool NAMES turn-to-turn risks the model
// hallucinating a call to a tool it remembers offering but that has since
// been hidden (burin's own design rationale). `lane_policy` ports that
// same one-shot-per-task shape.
//
// Enforcement reuses the EXISTING tool-surface-narrowing primitives shared
// with `std/agent/stance` (`__tool_surface_filter_registry` /
// `__tool_surface_policy_tools`) — no new Rust, no new hook surface. That
// shared seam is also why a tool a lane hides is never NAMED to the model:
// narrowing via `policy.tools` means a denied call is rejected by harn-vm's
// native tool-ceiling check, which reports only the ONE tool name the model
// just attempted — it never enumerates what else is unavailable.
//
// `lane_scope_classifier` additionally lowers lane rows onto the EXISTING
// `pre_turn_scope_classifier` seam (`std/llm/scope_classifier`, consulted by
// `agent_loop` every turn) — but purely for per-turn observability/telemetry
// over a long-running lane-scoped session. It always reports `in_scope` and
// `skip_main_turn: false`, so it never affects loop control; the tool
// surface is narrowed exclusively by `lane_policy`. Keeping the two apart
// avoids a dead mechanism: a `pre_turn_scope_classifier` verdict has no live
// wiring in `agent_loop` beyond the scope-alert skip check, so a lane
// classifier that ONLY lived there would never actually narrow anything.
// The classified lane rides the verdict's `evidence` string, not a custom
// field: the native `scope_classifier_verdict` event has a fixed shape and
// silently drops keys it does not recognize.
import { __tool_surface_filter_registry, __tool_surface_policy_tools } from "std/agent/postturn"
import { agent_tool_annotations } from "std/agent/tool_annotations"

/**
 * A single lane row. `tool_names` and `tool_kinds` are unioned to compute
 * the lane's allowed tool set (`tool_kinds` resolved against the registry's
 * `annotations.kind`/`annotations.tool_kind`, the same fail-safe annotation
 * lookup `std/agent/stance` uses). `match` declares which classification
 * strategies apply — `keywords` (case-insensitive substring OR-match against
 * the task) and/or `explicit_target_paths` (`{min?, max?}` bounds on the
 * count of detected file-path-like tokens in the task, mirroring burin's
 * target-count ladder). Exactly one row must set `default: true`; it is the
 * fallback used when no other row matches (and it never itself needs a
 * `match`).
 */
pub type LaneRow = {
  name: string,
  tool_names?: list<string>,
  tool_kinds?: list<string>,
  match?: dict,
  default?: bool,
}

/**
 * default_lane_rows() — the built-in two-lane table ported from burin's
 * `agent_lane_for_task`: `explicit_patch` (narrow: look/search/edit/run/
 * poll_command/wait_command/kill_command/read_command_output) when the task
 * names 1-4 explicit file targets, `general` (unrestricted) otherwise.
 *
 * NOTE: the `explicit_patch` `tool_names` here are a REFERENCE/example set drawn
 * from burin-code's product tool surface (`poll_command`/`wait_command`/
 * `kill_command`/`read_command_output` are burin tool names, not stdlib
 * primitives). A host with a different tool registry should NOT rely on these
 * exact names — supply your own rows to `lane_policy` (see `LaneRow`) naming the
 * tools your registry actually exposes. These defaults are retained as a
 * released, working out-of-the-box example.
 *
 * @effects: []
 * @errors: []
 * @api_stability: stable
 */
pub fn default_lane_rows() {
  return [
    {
      match: {explicit_target_paths: {max: 4, min: 1}},
      name: "explicit_patch",
      tool_names: [
        "look",
        "search",
        "edit",
        "run",
        "poll_command",
        "wait_command",
        "kill_command",
        "read_command_output",
      ],
    },
    {default: true, name: "general"},
  ]
}

fn __lane_row_name(row) {
  return to_string(row?.name ?? "")
}

fn __lane_validate_rows(rows) {
  if type_of(rows) != "list" || len(rows) == 0 {
    throw "lane_policy: rows must be a non-empty list of lane rows"
  }
  let seen_default = false
  let names = []
  for row in rows {
    if type_of(row) != "dict" {
      throw "lane_policy: each lane row must be a dict"
    }
    const name = __lane_row_name(row)
    if name == "" {
      throw "lane_policy: each lane row needs a non-empty `name`"
    }
    if contains(names, name) {
      throw "lane_policy: duplicate lane row name \"" + name + "\""
    }
    names = names.push(name)
    if row?.default ?? false {
      seen_default = true
    }
  }
  if !seen_default {
    throw "lane_policy: exactly one lane row must set `default: true` (the fallback lane)"
  }
  return rows
}

/**
 * agent_lane_explicit_target_paths(task) — detect file-path-like tokens in
 * `task` (a word containing a dot-extension, optionally with slashes), the
 * same signal burin's `explicit_lane_targets_for_task_with_options` uses to
 * decide whether a task is a bounded, single-area patch. Exposed for tests
 * and custom classifiers.
 *
 * @effects: []
 * @errors: []
 * @api_stability: stable
 */
pub fn agent_lane_explicit_target_paths(task) {
  const found = regex_match("[\\w][\\w./-]*\\.[A-Za-z]{1,5}\\b", to_string(task ?? ""))
  if type_of(found) != "list" {
    return []
  }
  let out = []
  for token in found {
    if !contains(out, token) {
      out = out.push(token)
    }
  }
  return out
}

fn __lane_row_matches(row, task, task_lower) {
  const match_spec = row?.match
  if type_of(match_spec) != "dict" {
    return false
  }
  const keywords = match_spec?.keywords
  if type_of(keywords) == "list" && len(keywords) > 0 {
    for kw in keywords {
      if contains(task_lower, lowercase(to_string(kw))) {
        return true
      }
    }
  }
  const target_rule = match_spec?.explicit_target_paths
  if type_of(target_rule) == "dict" {
    const count = len(agent_lane_explicit_target_paths(task))
    const min_count = target_rule?.min ?? 1
    const max_count = target_rule?.max ?? 999999999
    if count >= min_count && count <= max_count {
      return true
    }
  }
  return false
}

/**
 * agent_lane_match(rows, task) — pure heuristic classification: walks
 * `rows` IN ORDER and returns the first non-default row whose `match`
 * strategy accepts `task`; falls back to the row marked `default: true`
 * when nothing matches. Lanes are DATA rows, not `if` branches — add a
 * lane by appending a row.
 *
 * @effects: []
 * @errors: []
 * @api_stability: stable
 */
pub fn agent_lane_match(rows, task) {
  const task_lower = lowercase(to_string(task ?? ""))
  let fallback = nil
  for row in rows {
    if row?.default ?? false {
      fallback = row
      continue
    }
    if __lane_row_matches(row, task, task_lower) {
      return row
    }
  }
  return fallback ?? rows[0]
}

fn __lane_normalize_verdict(row, reason) {
  return {lane: __lane_row_name(row), matched: true, reason: reason}
}

/**
 * agent_lane_classify(rows, task, options?) — classify `task` into a lane.
 * `options.classifier` may override the heuristic with a custom callable
 * `(task, rows) -> lane_name_string | nil`; an unrecognized or `nil` result
 * falls back to `agent_lane_match`. Returns `{lane, matched, reason}`.
 *
 * @effects: []
 * @errors: []
 * @api_stability: stable
 */
pub fn agent_lane_classify(rows, task, options = nil) {
  const opts = options ?? {}
  const classifier = opts?.classifier
  if classifier != nil {
    const picked = try {
      classifier(task, rows)
    } catch (e) {
      nil
    }
    if picked != nil {
      for row in rows {
        if __lane_row_name(row) == to_string(picked) {
          return __lane_normalize_verdict(row, "custom_classifier")
        }
      }
    }
  }
  const row = agent_lane_match(rows, task)
  return __lane_normalize_verdict(
    row,
    if row?.default ?? false {
      "default_lane"
    } else {
      "matched_row"
    },
  )
}

fn __lane_registry_entries(registry) {
  if registry == nil {
    return []
  }
  if type_of(registry) == "dict" {
    return registry?.tools ?? []
  }
  if type_of(registry) == "list" {
    return registry
  }
  return []
}

fn __lane_entry_name(entry) {
  return to_string(entry?.name ?? entry?.function?.name ?? "")
}

fn __lane_entry_kind(entry, policy) {
  const annotations = agent_tool_annotations(entry, policy, __lane_entry_name(entry))
  return to_string(annotations?.kind ?? annotations?.tool_kind ?? "")
}

fn __lane_push_unique(names, value) {
  const n = to_string(value)
  if n != "" && !contains(names, n) {
    return names.push(n)
  }
  return names
}

/**
 * The lane's OWN allowed tool set: the union of its explicit `tool_names` and
 * the registry entries matching its `tool_kinds`. Deliberately does NOT fold
 * in `hard_keep` — an empty result here means the lane performs no narrowing
 * (the `general`/default case), and `hard_keep` must not turn that
 * non-narrowing lane into a restricted one. `lane_policy` unions `hard_keep`
 * only after confirming the lane actually narrows.
 */
fn __lane_own_tool_names(row, registry, policy) {
  let names = []
  const explicit = row?.tool_names
  if type_of(explicit) == "list" {
    for raw in explicit {
      names = __lane_push_unique(names, raw)
    }
  }
  const kinds = row?.tool_kinds
  if type_of(kinds) == "list" && len(kinds) > 0 {
    for entry in __lane_registry_entries(registry) {
      const kind = __lane_entry_kind(entry, policy)
      if kind != "" && contains(kinds, kind) {
        names = __lane_push_unique(names, __lane_entry_name(entry))
      }
    }
  }
  return names
}

/**
 * lane_policy(rows, task, agent_options?, options?) — classify `task` into a
 * lane from `rows` (DATA, see `LaneRow`), then narrow
 * `agent_options.tools`/`agent_options.policy` down to that lane's allowed
 * tool set for the WHOLE `agent_loop` run — a one-shot decision, matching
 * burin's `agent_lane_for_task` (see module docs for why lanes are not
 * reclassified per turn).
 *
 * A row with neither `tool_names` nor `tool_kinds` performs no narrowing —
 * this is how the built-in `general` lane stays unrestricted, and it stays
 * unrestricted even when `options.hard_keep` is set. `options.hard_keep`
 * names tools that additionally survive a lane that IS narrowing (a union
 * with the matched lane's own tools); it never itself causes narrowing, so a
 * non-narrowing lane is not turned into a restricted one. `options.classifier`
 * is forwarded to `agent_lane_classify`.
 *
 * The result carries `_lane_verdict` (`{lane, matched, reason, tool_names?}`)
 * for tests/telemetry; it is metadata only, harmless to leave on `agent_loop`
 * options.
 *
 * @effects: []
 * @errors: [runtime]
 * @api_stability: stable
 * @example: agent_loop(task, nil, lane_policy(default_lane_rows(), task, {provider: "anthropic", tools: my_tools}))
 */
pub fn lane_policy(rows, task, agent_options = nil, options = nil) {
  const validated = __lane_validate_rows(rows)
  const opts = if type_of(agent_options) == "dict" {
    agent_options
  } else {
    {}
  }
  const extra = options ?? {}
  const hard_keep = extra?.hard_keep ?? []
  const verdict = agent_lane_classify(validated, task, extra)
  let matched_row = nil
  for row in validated {
    if __lane_row_name(row) == verdict.lane {
      matched_row = row
    }
  }
  const resolved_row = matched_row ?? validated[0]
  const lane_names = __lane_own_tool_names(resolved_row, opts?.tools, opts?.policy)
  // A lane that contributes no tools of its own performs NO narrowing (the
  // `general`/default case) — leave the surface untouched. hard_keep only
  // augments a lane that IS narrowing; with nothing narrowed there is nothing
  // to keep relative to, so it must not restrict an otherwise-open surface.
  if len(lane_names) == 0 {
    return opts + {_lane_verdict: verdict}
  }
  let names = lane_names
  for raw in hard_keep {
    names = __lane_push_unique(names, raw)
  }
  return opts
    + {
    _lane_verdict: verdict + {tool_names: names},
    policy: __tool_surface_policy_tools(opts?.policy, names),
    tools: __tool_surface_filter_registry(opts?.tools, names),
  }
}

/**
 * lane_scope_classifier(rows, options?) — build a `pre_turn_scope_classifier`
 * config (see `std/llm/scope_classifier`) that reclassifies the current
 * message into a lane EVERY turn purely for observability: it emits the
 * standard `scope_classifier_verdict` event on every turn, and always
 * returns `label: "in_scope"`/`skip_main_turn: false` — it never skips a
 * turn and never narrows the tool surface (that is `lane_policy`'s job).
 * The classified lane surfaces in the verdict's free-text `evidence` field
 * (`"lane=<name> reason=<reason>"`) rather than a dedicated `lane` key: the
 * native `scope_classifier_verdict` event has a fixed Rust-side shape and
 * silently drops unrecognized keys, so `evidence` (already part of that
 * shape) is the only channel a custom classifier can use to carry extra
 * information through live. Spread the result into `agent_loop` options
 * alongside `lane_policy` when a caller wants a live per-turn audit trail
 * of lane drift over a long-running lane-scoped session.
 *
 * @effects: []
 * @errors: []
 * @api_stability: stable
 * @example: agent_loop(task, nil, lane_policy(rows, task, opts) + lane_scope_classifier(rows))
 */
pub fn lane_scope_classifier(rows, options = nil) {
  const validated = __lane_validate_rows(rows)
  const extra = options ?? {}
  const classifier = { payload ->
    const observed_task = to_string(payload?.user_message ?? payload?.task ?? "")
    const verdict = agent_lane_classify(validated, observed_task, extra)
    return {
      confidence: 1.0,
      evidence: "lane=" + verdict.lane + " reason=" + verdict.reason,
      label: "in_scope",
      skip_main_turn: false,
    }
  }
  return {pre_turn_scope_classifier: classifier}
}

// -------------------------------------------------------------------------------------------------

// Per-stage tool-gate (ADDITIVE, default-off). Absorbs the generic core of
// burin-code's `tool-gating.harn`. Where `lane_policy` above is a per-TASK
// one-shot ceiling that HIDES tools silently at loop start (#2506) and never
// reclassifies mid-transcript, this seam is a per-(lane, STAGE) gate whose
// scope rides a machine-readable marker in the workflow node's task LABEL, is
// recovered MID-TRANSCRIPT (from the latest user prompt, not only at loop
// start), and DENIES a disallowed tool call with a suggestion instead of
// hiding it. It is wholly separate from `lane_policy` and the classifier: a
// caller that never renders a gate marker sees byte-identical behavior.
//
// Division of labor with burin: the lane×stage capability TABLE (which kinds a
// `grounded_patch:implement` stage may run) and the tool->kind resolver stay in
// the harness — they are its domain. This module owns the generic mechanism:
// marker ENCODE (`stage_gate_label`), marker DECODE (`stage_gate_parse` /
// `stage_gate_from_session`), the allow DECISION (`stage_gate_allows`), and the
// denial-with-suggestion UX text (`stage_gate_denial`). Kind resolution is
// injected via an `options.kind_of` callback so the seam never hardwires a
// particular tool registry.

// -------------------------------------------------------------------------------------------------

const __STAGE_GATE_PREFIX = "[lane_gate "

// The marker is space-delimited (`lane=… stage=… kinds=… tools=…`), token lists
// are comma-joined, `=` separates key from value, and `]` terminates the marker.
// A component carrying any of these four bytes would silently truncate or split
// on `stage_gate_parse`, so `stage_gate_label` rejects it loudly instead.
const __STAGE_GATE_DELIMITERS = [" ", "=", ",", "]"]

fn __stage_gate_reject_delimiters(field, value) {
  for delimiter in __STAGE_GATE_DELIMITERS {
    if value.index_of(delimiter) >= 0 {
      throw "stage_gate_label: " + field + " may not contain the marker delimiter '"
        + delimiter
        + "' (it would corrupt the stage_gate_parse round-trip); got: \""
        + value
        + "\""
    }
  }
}

fn __stage_gate_token_list(values) {
  let out = []
  for value in values ?? [] {
    const rendered = to_string(value ?? "").trim()
    if rendered != "" && !contains(out, rendered) {
      out = out.push(rendered)
    }
  }
  return out
}

fn __stage_gate_token_string(values) {
  const normalized = __stage_gate_token_list(values)
  if len(normalized) == 0 {
    return "-"
  }
  return join(normalized, ",")
}

fn __stage_gate_parse_token_list(raw) {
  const normalized = to_string(raw ?? "").trim()
  if normalized == "" || normalized == "-" {
    return []
  }
  // Split on the shared comma delimiter (__STAGE_GATE_DELIMITERS[2]) rather than a
  // re-hardcoded literal, so the parser can never drift from the delimiter set the
  // encoder rejects components against.
  return __stage_gate_token_list(normalized.split(__STAGE_GATE_DELIMITERS[2]))
}

/**
 * Resolve and validate the marker prefix for a stage-gate op. Defaults to the
 * built-in `__STAGE_GATE_PREFIX`. A host may inject its own (e.g. burin-code's
 * `"[burin_gate "`) via the `prefix` option so its existing markers round-trip
 * without an adapter. A custom prefix must be a well-formed marker opener: it must
 * start with `"["` and end with a space, and must not contain any of the structural
 * delimiters the parser splits on (`"="`, `","`, `"]"`), which would corrupt the
 * stage_gate_parse round-trip. Validated loudly at call time so a mistyped prefix
 * fails here rather than silently producing an unparseable marker. The trailing
 * space is deliberately NOT rejected even though it is a marker delimiter — it is
 * the required separator between the prefix and the first `key=value` token.
 */
fn __stage_gate_prefix(options) -> string {
  const raw = options?.prefix
  if raw == nil {
    return __STAGE_GATE_PREFIX
  }
  const prefix = to_string(raw)
  if !starts_with(prefix, "[") {
    throw "stage_gate: `prefix` must start with '[' (got: \"" + prefix + "\")"
  }
  if !ends_with(prefix, " ") {
    throw "stage_gate: `prefix` must end with a space (got: \"" + prefix + "\")"
  }
  for delimiter in [__STAGE_GATE_DELIMITERS[1], __STAGE_GATE_DELIMITERS[2], __STAGE_GATE_DELIMITERS[3]] {
    if prefix.index_of(delimiter) >= 0 {
      throw "stage_gate: `prefix` may not contain the marker delimiter '" + delimiter
        + "' (it would corrupt the stage_gate_parse round-trip); got: \""
        + prefix
        + "\""
    }
  }
  return prefix
}

fn __stage_gate_callable(value) {
  const kind = type_of(value)
  return kind == "closure" || kind == "function" || kind == "fn"
}

fn __stage_gate_validate_options(options) {
  const kind_of = options?.kind_of
  if kind_of != nil && !__stage_gate_callable(kind_of) {
    throw "stage_gate: `kind_of` must be a callable (tool_name) -> kind string"
  }
  const suggest = options?.suggest
  if suggest != nil && !__stage_gate_callable(suggest) && type_of(suggest) != "string" {
    throw "stage_gate: `suggest` must be a string or a callable (gate) -> string"
  }
  return options
}

fn __stage_gate_kind_of(options, tool_name) {
  const resolver = options?.kind_of
  if resolver == nil {
    return ""
  }
  const kind = try {
    resolver(tool_name)
  } catch (e) {
    ""
  }
  return to_string(kind ?? "")
}

/**
 * stage_gate_label(base_label, gate, options?) — encode a lane/stage gate into a
 * task label so a downstream PreToolUse enforcement seam can recover the active
 * workflow scope from the prompt itself. `gate` is `{lane, stage,
 * allowed_kinds?, allowed_tools?}`. Returns `base_label` UNCHANGED when either
 * `lane` or `stage` is blank — so an ungated node's label is byte-identical to
 * what it would be without this seam. Throws when any component (`lane`,
 * `stage`, or an `allowed_kinds`/`allowed_tools` token) contains a marker
 * delimiter (space, `=`, `,`, `]`) that `stage_gate_parse` could not recover.
 * `options.prefix` overrides the default `"[lane_gate "` marker prefix (a host
 * must pass the SAME prefix to `stage_gate_parse`/`stage_gate_from_session`);
 * omitting it renders the byte-identical default marker.
 *
 * @effects: []
 * @errors: [runtime]
 * @api_stability: stable
 * @example: stage_gate_label(task, {lane: "repair_local", stage: "repair", allowed_tools: ["edit", "look"]})
 */
pub fn stage_gate_label(base_label, gate, options = nil) {
  const prefix = __stage_gate_prefix(options ?? {})
  const base = to_string(base_label ?? "").trim()
  const spec = if type_of(gate) == "dict" {
    gate
  } else {
    {}
  }
  const lane = to_string(spec?.lane ?? "").trim()
  const stage = to_string(spec?.stage ?? "").trim()
  if lane == "" || stage == "" {
    return base
  }
  __stage_gate_reject_delimiters("lane", lane)
  __stage_gate_reject_delimiters("stage", stage)
  const kinds = __stage_gate_token_list(spec?.allowed_kinds ?? [])
  const tools = __stage_gate_token_list(spec?.allowed_tools ?? [])
  for kind_token in kinds {
    __stage_gate_reject_delimiters("allowed_kinds token", kind_token)
  }
  for tool_token in tools {
    __stage_gate_reject_delimiters("allowed_tools token", tool_token)
  }
  const marker = prefix + "lane=" + lane + " stage=" + stage + " kinds="
    + __stage_gate_token_string(kinds)
    + " tools="
    + __stage_gate_token_string(tools)
    + "]"
  if base == "" {
    return marker
  }
  return base + " " + marker
}

/**
 * stage_gate_parse(text, options?) — recover a gate from any text carrying a
 * marker rendered by `stage_gate_label`. Returns `{lane, stage, allowed_kinds,
 * allowed_tools}`, or an EMPTY dict `{}` when no well-formed marker is present.
 * An empty gate never denies anything (see `stage_gate_allows`), so text
 * without a marker is inert. `options.prefix` must match the prefix passed to
 * `stage_gate_label` (default `"[lane_gate "`); a marker under a different prefix
 * is not recognized.
 *
 * @effects: []
 * @errors: [runtime]
 * @api_stability: stable
 */
pub fn stage_gate_parse(text, options = nil) {
  const prefix = __stage_gate_prefix(options ?? {})
  // Scan with the SAME structural delimiter set the encoder rejects components
  // against (__STAGE_GATE_DELIMITERS = [space, "=", ",", "]"]), referenced by
  // index rather than re-hardcoded, so encoder and parser can never drift.
  const delim_space = __STAGE_GATE_DELIMITERS[0]
  const delim_eq = __STAGE_GATE_DELIMITERS[1]
  const delim_close = __STAGE_GATE_DELIMITERS[3]
  const body = to_string(text ?? "")
  const start = body.index_of(prefix)
  if start < 0 {
    return {}
  }
  const body_start = start + len(prefix)
  const tail = body.substring(body_start, len(body))
  const end = tail.index_of(delim_close)
  if end < 0 {
    return {}
  }
  const marker = tail.substring(0, end).trim()
  let lane = ""
  let stage = ""
  let kinds = []
  let tools = []
  for token in marker.split(delim_space) {
    const piece = token.trim()
    const eq = piece.index_of(delim_eq)
    if eq <= 0 {
      continue
    }
    const key = piece.substring(0, eq).trim()
    const value = piece.substring(eq + 1, len(piece)).trim()
    if key == "lane" {
      lane = value
    }
    if key == "stage" {
      stage = value
    }
    if key == "kinds" {
      kinds = __stage_gate_parse_token_list(value)
    }
    if key == "tools" {
      tools = __stage_gate_parse_token_list(value)
    }
  }
  if lane == "" || stage == "" {
    return {}
  }
  return {allowed_kinds: kinds, allowed_tools: tools, lane: lane, stage: stage}
}

fn __stage_gate_latest_user_prompt(messages) {
  let prompt = ""
  for message in messages ?? [] {
    if to_string(message?.role ?? "") != "user" {
      continue
    }
    const content = to_string(message?.content ?? "").trim()
    if content != "" {
      prompt = content
    }
  }
  return prompt
}

/**
 * stage_gate_from_session(session_id, options?) — recover the active gate
 * MID-TRANSCRIPT by inspecting the latest user prompt in a live session's
 * transcript (the marker-recovery half of the PreToolUse enforcement seam). Reads
 * the existing `agent_session_snapshot`/`transcript_messages` seam — no new host
 * surface. Returns an empty dict `{}` when the session is unknown or carries no
 * marker. `options.prefix` is forwarded to `stage_gate_parse` and must match the
 * prefix the label was rendered with (default `"[lane_gate "`).
 *
 * @effects: [host]
 * @errors: [runtime]
 * @api_stability: stable
 */
pub fn stage_gate_from_session(session_id, options = nil) {
  const sid = to_string(session_id ?? "").trim()
  if sid == "" {
    return {}
  }
  const snapshot = agent_session_snapshot(sid)
  if snapshot == nil {
    return {}
  }
  const messages = try {
    transcript_messages(snapshot)
  } catch (e) {
    snapshot?.messages ?? []
  }
  const prompt = __stage_gate_latest_user_prompt(messages)
  if prompt == "" {
    return {}
  }
  return stage_gate_parse(prompt, options)
}

/**
 * stage_gate_allows(gate, tool_name, options?) — the allow DECISION for a
 * per-(lane, stage) gate. Returns `true` (never denies) when `gate` carries no
 * lane/stage or no tool is named — so an inactive gate is default-off. A tool
 * named in `allowed_tools` always passes. A gate with `allowed_kinds` consults
 * `options.kind_of` (a `(tool_name) -> kind` callback) and passes when the
 * resolved kind is allowed; when no `kind_of` is supplied the gate FAILS OPEN
 * (a gate must never silently block on missing wiring). A tools-only gate with
 * an empty tool list gates nothing.
 *
 * @effects: []
 * @errors: [runtime]
 * @api_stability: stable
 */
pub fn stage_gate_allows(gate, tool_name, options = nil) {
  const spec = if type_of(gate) == "dict" {
    gate
  } else {
    {}
  }
  const opts = __stage_gate_validate_options(options ?? {})
  const name = to_string(tool_name ?? "").trim()
  if to_string(spec?.lane ?? "") == "" || to_string(spec?.stage ?? "") == "" || name == "" {
    return true
  }
  const allowed_tools = __stage_gate_token_list(spec?.allowed_tools ?? [])
  if contains(allowed_tools, name) {
    return true
  }
  const allowed_kinds = __stage_gate_token_list(spec?.allowed_kinds ?? [])
  if len(allowed_kinds) == 0 {
    return len(allowed_tools) == 0
  }
  const kind = __stage_gate_kind_of(opts, name)
  if kind == "" {
    return true
  }
  return contains(allowed_kinds, kind)
}

fn __stage_gate_render_allowed(gate) {
  const kinds = __stage_gate_token_list(gate?.allowed_kinds ?? [])
  const tools = __stage_gate_token_list(gate?.allowed_tools ?? [])
  let parts = []
  if len(kinds) > 0 {
    parts = parts.push("kinds=" + join(kinds, ","))
  }
  if len(tools) > 0 {
    parts = parts.push("tools=" + join(tools, ","))
  }
  if len(parts) == 0 {
    return "none"
  }
  return join(parts, " ")
}

fn __stage_gate_default_suggestion(gate) {
  const tools = __stage_gate_token_list(gate?.allowed_tools ?? [])
  if len(tools) > 0 {
    return "use " + join(tools, " or ")
  }
  const kinds = __stage_gate_token_list(gate?.allowed_kinds ?? [])
  if len(kinds) > 0 {
    return "use one of the declared " + join(kinds, "/") + " tools for this stage"
  }
  return "use one of the declared tools for this stage"
}

fn __stage_gate_suggestion_text(gate, options) {
  const suggest_override = options?.suggest
  if type_of(suggest_override) == "string" && suggest_override != "" {
    return suggest_override
  }
  if __stage_gate_callable(suggest_override) {
    const custom = try {
      suggest_override(gate)
    } catch (e) {
      nil
    }
    if type_of(custom) == "string" && custom != "" {
      return custom
    }
  }
  return __stage_gate_default_suggestion(gate)
}

/**
 * stage_gate_denial(gate, tool_name, options?) — the denial-with-suggestion UX
 * text a PreToolUse hook surfaces when `stage_gate_allows` rejects a call
 * (rather than the tool being silently hidden). Names the lane, stage, tool,
 * resolved kind, the allowed set, and a recovery suggestion. `options.kind_of`
 * resolves the tool kind for the report; `options.suggest` (a string or a
 * `(gate) -> string` callback) overrides the default suggestion.
 *
 * @effects: []
 * @errors: [runtime]
 * @api_stability: stable
 * @example: stage_gate_denial(gate, "git", {kind_of: { name -> tool_kind(name) }})
 */
pub fn stage_gate_denial(gate, tool_name, options = nil) {
  const spec = if type_of(gate) == "dict" {
    gate
  } else {
    {}
  }
  const opts = __stage_gate_validate_options(options ?? {})
  const name = to_string(tool_name ?? "").trim()
  const lane = if to_string(spec?.lane ?? "") != "" {
    to_string(spec.lane)
  } else {
    "unknown"
  }
  const stage = if to_string(spec?.stage ?? "") != "" {
    to_string(spec.stage)
  } else {
    "unknown"
  }
  const kind = __stage_gate_kind_of(opts, name)
  const allowed = __stage_gate_render_allowed(spec)
  const suggestion = __stage_gate_suggestion_text(spec, opts)
  return "tool_not_available_in_stage lane=" + lane + " stage=" + stage + " tool=" + name + " kind="
    + kind
    + " allowed="
    + allowed
    + " suggestion="
    + suggestion
}