Files
nanoclaw/scripts/skill-policy.ts
T
gavrielc 40b65521c0 refactor(skills): retire nc:env-sync — the data/env/env mirror is dead
Upstream removed every setup-side writer of data/env/env (c82f062d) because
nothing has read it since the container mount was dropped — before this
branch even forked. Our engine codified the dead pattern as a directive:
every apply copied the full .env (live tokens included) into a file nothing
consumes.

- engine: env-sync handler removed from selfStatus + applyOne
- grammar: dropped from KNOWN; a new RETIRED table gives a targeted lint
  error ("delete the fence, the adapter reads .env directly") instead of a
  generic unknown-directive message
- skills: fence stripped from the 14 converted skills; orphaned "sync to the
  container" prose cleaned (incl. add-deltachat/add-wechat old-format prose
  upstream's sweep missed); teams troubleshooting entry rewritten
- policy/spec doc lists updated

Suite 827 passed | 1 skipped; all nc: skills lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:27:40 +03:00

131 lines
6.4 KiB
TypeScript

// Shared, UI-free driver policy for `nc:operator` blocks — presentation derived
// from DOCUMENT STRUCTURE, never from authored presentation attrs.
//
// The apply engine (scripts/skill-apply.ts) only DECLARES and EMITS: it renders
// an operator block's text and awaits the consumer's `onEvent` before evaluating
// the next directive. Whether that block needs a human BARRIER (a confirm before
// the next side-effecting step runs) and whether its text carries a URL worth
// offering to open are judgments about the skill DOCUMENT — the same judgment
// for every consumer that renders live (the setup wizard, an agent relaying over
// chat). This module is that judgment, built on the shared parser, with no
// clack/TTY baggage — a consumer imports it instead of duplicating the policy.
//
// Two exports:
// • gatePolicy(md) → per operator line: does the block need a confirm
// after rendering, and with which flavor of wording?
// • extractOfferUrl(text) → the first offerable URL in a RENDERED operator
// body (template placeholders excluded), or undefined.
import { parseDirectives, type Directive } from './skill-directives.js';
/**
* The wording flavor a barrier confirm should carry, derived from the barrier
* directive's effect:
* • 'readiness' — the next step is an `effect:step` (a pairing code, a QR
* device-link): the block describes FUTURE action ("a code is about to
* appear"), so the confirm asks for readiness before it starts.
* • 'completed' — anything else: the block describes work the human must have
* FINISHED (an Azure app that must exist before the manifest bakes it in),
* so the confirm asks whether the steps above are done.
*/
export type ConfirmFlavor = 'readiness' | 'completed';
export interface GateDecision {
/** Confirm after rendering this operator block? */
needsConfirm: boolean;
/** Wording flavor for the confirm — meaningful only when `needsConfirm`. */
flavor: ConfirmFlavor;
}
// A directive's `when:<var>=<value>` guard, parsed. Malformed (no `=`) ⇒ treated
// as unguarded — conservative, and lint is the authoring gate anyway.
function guardOf(d: Directive): { v: string; value: string } | undefined {
if (typeof d.attrs.when !== 'string') return undefined;
const eq = d.attrs.when.indexOf('=');
if (eq < 1) return undefined;
return { v: d.attrs.when.slice(0, eq).trim(), value: d.attrs.when.slice(eq + 1).trim() };
}
/**
* The natural-barrier gate policy: for each `nc:operator` directive, decide
* whether the consumer should hold for a human confirm after rendering it —
* keyed by the directive's opening-fence line (the `line` the engine's operator
* event carries).
*
* Rules (normative — the §5.1 seam spec):
* 1. Scan forward through subsequent directives, skipping ONLY those whose
* `when:` guard is INCOMPATIBLE with this operator's own guard — same var,
* different value. No guard, or an identical guard, is compatible
* (different-var guards are conservatively compatible too). This makes
* mutually-exclusive branches gate on their OWN next action.
* 2. Next compatible directive is another `operator` → no confirm — the
* chain's LAST operator carries the barrier. (Operators are NOT skipped-
* and-scanned-past: that would make the earlier block of a chain inherit
* the later block's barrier and double-confirm.)
* 3. Next compatible directive is a `prompt` → no confirm (the prompt is the
* barrier — the human can't paste a token before doing the steps).
* 4. No such directive (end of document) → no confirm (a final handoff block).
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`,
* `json-merge`) → confirm, with the flavor derived from that barrier
* directive's effect (`effect:step` → readiness, else completed).
*
* Known limitation (lint-warned upstream): an UNGUARDED operator followed by
* guarded directives of more than one branch value keys its decision off a
* directive that may be runtime-skipped. No in-tree skill authors this.
*/
export function gatePolicy(md: string): Map<number, GateDecision> {
const directives = parseDirectives(md);
const out = new Map<number, GateDecision>();
directives.forEach((d, i) => {
if (d.kind !== 'operator') return;
const own = guardOf(d);
let barrier: Directive | undefined;
for (let j = i + 1; j < directives.length; j++) {
const g = guardOf(directives[j]);
if (own && g && own.v === g.v && own.value !== g.value) continue; // incompatible branch — skip
barrier = directives[j];
break;
}
if (!barrier || barrier.kind === 'operator' || barrier.kind === 'prompt') {
out.set(d.line, { needsConfirm: false, flavor: 'completed' });
} else {
out.set(d.line, { needsConfirm: true, flavor: barrier.attrs.effect === 'step' ? 'readiness' : 'completed' });
}
});
return out;
}
// A URL candidate in prose. The char class stops at whitespace, `)`, `>`, `]` —
// deliberately NOT at `<`, so a template placeholder like
// `https://<your-public-host>/webhook/slack` yields a candidate that still
// CONTAINS `<` and gets excluded below (instead of a nonsense truncated offer).
const URL_CANDIDATE = /https?:\/\/[^\s)>\]]+/g;
/**
* The first *offerable* URL in a rendered operator body, or undefined.
*
* A candidate matches `https?://…` up to whitespace/`)`/`>`/`]`, then:
* • trailing sentence punctuation is stripped (so "In https://portal.azure.com,
* search…" offers the clean URL, not one with a comma glued on);
* • candidates containing `<` or `{{` are EXCLUDED — template placeholders
* (`https://<your-public-host>/…`) and unsubstituted `{{vars}}` are authored
* shapes, not real destinations;
* • the survivor must parse via `new URL()` with a non-empty host.
*
* Scheme-less mentions (`api.slack.com/apps`) and non-http schemes (`sgnl://…`)
* never match — the offer is only for pages a browser can open.
*/
export function extractOfferUrl(text: string): string | undefined {
for (const m of text.matchAll(URL_CANDIDATE)) {
const candidate = m[0].replace(/[.,;:!?'"]+$/, '');
if (candidate.includes('<') || candidate.includes('{{')) continue;
try {
if (!new URL(candidate).hostname) continue;
} catch {
continue;
}
return candidate;
}
return undefined;
}