mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
* feat(hooks): opt-in skill routing and contract Stop hooks, gstack-skill-contract and gstack-skill-route (GST-1088 lane A) Both switches default off. UserPromptSubmit routing hook scores prompts against installed triggers; Stop hook blocks once when a contract skill ends without its outputs, failing open on every state error. setup gains --[no-]skill-routing and --[no-]skill-contracts mirroring the timeline gate. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(hooks): stop-hook test imports the state error line it pins (test-of-test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hooks): route only the four discovery targets; register the skill-contract fixture as free (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hooks): review round 1 on the contract Stop hook: record after stop_hook_active, read last_assistant_message, per-agent completion, met runs stay met, installed-only SKILL.md loads, program-based check commands, safe specialist names, report rechecks the saved run (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hooks): review round 2 notes: check commands behind wrappers count, the shell test builtin does not; met_after_block is final (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(hooks): one DEFAULT_MATCHERS table for the checker markers; N = 0 selects no specialists (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(hooks): lane C fixed markers: agent_dispatch prefers its marker line, parenthesized notes are not specialist names (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(hooks): an early exit (STATUS: BLOCKED, Nothing to review) meets the contract (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hooks): early exit only from the final message, line-anchored (GST-1088 lane A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
597 lines
33 KiB
TypeScript
597 lines
33 KiB
TypeScript
/**
|
|
* skill-contract — the skill contract checker (GST-1088, lane A).
|
|
*
|
|
* A targeted skill declares the outputs that must exist before it is done, in
|
|
* its SKILL.md frontmatter (schema frozen in plan-v2):
|
|
*
|
|
* contract: { version: 1, items: [ { id, kind, ... } ] }
|
|
*
|
|
* checkContract() is a pure function of a Claude Code transcript (the JSONL a
|
|
* hook payload's transcript_path names): the model never self-records. Waivers
|
|
* are read the same way, from the `gstack-skill-contract waive` Bash calls in
|
|
* that transcript, and count as misses for follow. The Stop hook
|
|
* (hosts/claude/hooks/skill-contract-stop-hook.ts) and `gstack-skill-contract`
|
|
* (check, report, waive) are its two callers; evals can reuse `check`.
|
|
* Records live under $GSTACK_STATE_ROOT/skill-contracts/<session id>.json,
|
|
* written atomically. Docs: docs/skill-routing-and-contracts.md.
|
|
*/
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { atomicWriteSync } from './fs-atomic';
|
|
import { readFrontmatter, scanInstalledSkills } from './skill-route';
|
|
import { resolveStateRoot } from './state-root';
|
|
|
|
export const TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024;
|
|
export const ITEM_KINDS = ['report_labels', 'check_after_last_edit', 'selected_specialists_dispatched', 'agent_dispatch'] as const;
|
|
export type ItemKind = typeof ITEM_KINDS[number];
|
|
const ITEM_ID = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
|
|
const MAX_ITEMS = 20;
|
|
|
|
/** Stable exit codes of gstack-skill-contract; each has an anchor in docs/skill-routing-and-contracts.md. */
|
|
export const EXIT = { MET: 0, UNMET: 1, USAGE: 2, STATE_ERROR: 3, UNKNOWN_SHAPE: 4, NO_CONTRACT: 5, NOT_LOADED: 6 } as const;
|
|
export const STATE_ERROR_DOC = 'docs/skill-routing-and-contracts.md#state-error';
|
|
|
|
export interface ContractItem {
|
|
id: string;
|
|
kind: ItemKind;
|
|
heading?: string;
|
|
labels?: string[];
|
|
match?: string;
|
|
marker?: string;
|
|
empty_marker?: string;
|
|
fix?: string;
|
|
summary?: string;
|
|
}
|
|
export interface Contract { version: 1; items: ContractItem[] }
|
|
|
|
export class StateError extends Error {
|
|
constructor(readonly file: string, readonly why: string) { super(`${file}: ${why}`); }
|
|
}
|
|
export class UnknownShape extends Error {}
|
|
|
|
export function stateErrorLine(file: string, why: string): string {
|
|
return `gstack-skill-contract: SKILL_CONTRACT_STATE_ERROR ${file}: ${why}; stop allowed, miss not recorded (see ${STATE_ERROR_DOC})`;
|
|
}
|
|
|
|
const isObject = (v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
const errText = (e: unknown) => (e as NodeJS.ErrnoException)?.code ?? (e instanceof Error ? e.message : String(e));
|
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
/** The `contract:` key and its indented lines, or null when the frontmatter has none. */
|
|
export function contractBlock(frontmatter: string): string | null {
|
|
const lines = frontmatter.split('\n');
|
|
const start = lines.findIndex(l => /^contract:/.test(l));
|
|
if (start < 0) return null;
|
|
let end = start + 1;
|
|
while (end < lines.length && (!lines[end].trim() || /^\s/.test(lines[end]))) end++;
|
|
return lines.slice(start, end).join('\n');
|
|
}
|
|
|
|
export function parseContract(frontmatter: string): { contract: Contract } | { error: string } | null {
|
|
const block = contractBlock(frontmatter);
|
|
if (block === null) return null;
|
|
let doc: unknown;
|
|
try {
|
|
doc = Bun.YAML.parse(block);
|
|
} catch (e) {
|
|
return { error: `contract block is not valid YAML (${errText(e)})` };
|
|
}
|
|
return validateContract(isObject(doc) ? doc.contract : undefined);
|
|
}
|
|
|
|
const STRING_FIELDS = ['heading', 'match', 'marker', 'empty_marker', 'fix', 'summary'] as const;
|
|
const REGEX_FIELDS = ['match'] as const;
|
|
|
|
/**
|
|
* Every default matcher string the checker uses, in one table: edit it here. A contract item's own marker or
|
|
* empty_marker replaces its row for that item. Markers are literal text in which a whole-word N is a number.
|
|
*/
|
|
export const DEFAULT_MATCHERS = {
|
|
/** selected_specialists_dispatched: the selection line. The names are the rest of the line; N = 0 selects none. */
|
|
specialistSelection: 'Dispatching N specialists:',
|
|
/** selected_specialists_dispatched: a line that means no specialist was selected. */
|
|
specialistsSkipped: 'specialists skipped',
|
|
/** Any contract: a line of the turn's final message, starting with this, means the skill ended the run early (met). A final STATUS: BLOCKED does too. */
|
|
earlyExit: 'Nothing to review',
|
|
} as const;
|
|
|
|
/**
|
|
* A marker as a pattern: literal text, case-insensitive, any run of spaces matching any whitespace, and a whole-word
|
|
* N matching a number (the first one captured as count). The names are the rest of that line.
|
|
*/
|
|
export function markerPattern(marker: string): RegExp {
|
|
const literal = (s: string) => s.split(/\s+/).map(escapeRe).join('\\s+');
|
|
const body = marker.split(/\bN\b/).map(literal).map((s, i) => (i === 0 ? s : `${i === 1 ? '(?<count>\\d+)' : '\\d+'}${s}`)).join('');
|
|
return new RegExp(`${body}\\s*(?<names>[^\\n]*)`, 'i');
|
|
}
|
|
|
|
export function validateContract(raw: unknown): { contract: Contract } | { error: string } {
|
|
if (!isObject(raw)) return { error: 'contract must be a mapping' };
|
|
if (raw.version !== 1) return { error: `contract version ${JSON.stringify(raw.version)} is not supported (expected 1)` };
|
|
if (!Array.isArray(raw.items) || !raw.items.length || raw.items.length > MAX_ITEMS) {
|
|
return { error: `contract items must be a list of 1 to ${MAX_ITEMS} items` };
|
|
}
|
|
const items: ContractItem[] = [];
|
|
for (const [n, item] of raw.items.entries()) {
|
|
if (!isObject(item)) return { error: `items[${n}] must be a mapping` };
|
|
const id = item.id;
|
|
if (typeof id !== 'string' || !ITEM_ID.test(id)) return { error: `items[${n}].id must match ${ITEM_ID}` };
|
|
if (items.some(i => i.id === id)) return { error: `duplicate item id ${id}` };
|
|
if (!(ITEM_KINDS as readonly string[]).includes(item.kind)) {
|
|
return { error: `${id}: unknown kind ${JSON.stringify(item.kind)} (expected one of ${ITEM_KINDS.join(', ')})` };
|
|
}
|
|
const out: ContractItem = { id, kind: item.kind };
|
|
for (const field of STRING_FIELDS) {
|
|
if (item[field] === undefined) continue;
|
|
if (typeof item[field] !== 'string' || !item[field].trim()) return { error: `${id}: ${field} must be a non-empty string` };
|
|
out[field] = item[field].trim();
|
|
}
|
|
for (const field of REGEX_FIELDS) {
|
|
if (out[field] === undefined) continue;
|
|
try { new RegExp(out[field] as string, 'i'); } catch { return { error: `${id}: ${field} is not a valid regular expression` }; }
|
|
}
|
|
if (out.kind === 'report_labels') {
|
|
if (!out.heading) return { error: `${id}: report_labels needs a heading` };
|
|
if (!Array.isArray(item.labels) || !item.labels.length || item.labels.some((l: unknown) => typeof l !== 'string' || !l.trim())) {
|
|
return { error: `${id}: report_labels needs labels, a list of non-empty strings` };
|
|
}
|
|
out.labels = item.labels.map((l: string) => l.trim());
|
|
}
|
|
if (out.kind === 'agent_dispatch' && !out.match) return { error: `${id}: agent_dispatch needs match` };
|
|
items.push(out);
|
|
}
|
|
return { contract: { version: 1, items } };
|
|
}
|
|
|
|
/** One plain phrase per item, for the routing hint's "Done means" variant. */
|
|
export function itemSummary(item: ContractItem): string {
|
|
if (item.summary) return item.summary;
|
|
switch (item.kind) {
|
|
case 'report_labels': return `a ${item.heading} with ${(item.labels ?? []).join(', ')}`;
|
|
case 'check_after_last_edit': return 'a test or repro run after your last edit';
|
|
case 'selected_specialists_dispatched': return 'one Agent dispatch per selected specialist';
|
|
case 'agent_dispatch': return `an Agent dispatch matching /${item.match}/`;
|
|
}
|
|
}
|
|
|
|
export type TranscriptEvent =
|
|
| { kind: 'prompt' | 'meta' | 'text'; line: number; text: string }
|
|
| { kind: 'tool_use'; line: number; id: string; name: string; input: Record<string, any> }
|
|
| { kind: 'tool_result'; line: number; id: string; text: string; isError: boolean };
|
|
export interface Transcript { events: TranscriptEvent[]; sessionId: string | null }
|
|
|
|
const META_PREFIX = /^\s*<(?:local-command-stdout|local-command-stderr|system-reminder|task-notification|user-prompt-submit-hook|bash-input|bash-stdout|bash-stderr)\b/;
|
|
|
|
function blockText(content: unknown): string {
|
|
if (typeof content === 'string') return content;
|
|
if (!Array.isArray(content)) return '';
|
|
return content.filter(b => isObject(b) && typeof b.text === 'string').map(b => b.text).join('\n');
|
|
}
|
|
|
|
/**
|
|
* Main-thread events of a Claude Code transcript JSONL. Sidechain (subagent) records are skipped; unparseable
|
|
* lines (a partial last line) are skipped. Throws UnknownShape when the file is not a transcript this parser knows.
|
|
*/
|
|
export function parseTranscript(text: string): Transcript {
|
|
const events: TranscriptEvent[] = [];
|
|
let sessionId: string | null = null;
|
|
let lines = 0;
|
|
let objects = 0;
|
|
let messages = 0;
|
|
for (const [idx, raw] of text.split('\n').entries()) {
|
|
if (!raw.trim()) continue;
|
|
lines++;
|
|
let rec: unknown;
|
|
try { rec = JSON.parse(raw); } catch { continue; }
|
|
if (!isObject(rec)) continue;
|
|
objects++;
|
|
if (rec.type !== 'user' && rec.type !== 'assistant') continue;
|
|
const line = idx + 1;
|
|
if (!isObject(rec.message)) throw new UnknownShape(`line ${line}: ${rec.type} record without a message object`);
|
|
const content = rec.message.content;
|
|
if (typeof content !== 'string' && !Array.isArray(content)) throw new UnknownShape(`line ${line}: message content is neither text nor a block list`);
|
|
messages++;
|
|
if (rec.isSidechain === true) continue;
|
|
if (typeof rec.sessionId === 'string') sessionId = rec.sessionId;
|
|
const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : content;
|
|
for (const block of blocks) {
|
|
if (!isObject(block)) continue;
|
|
if (rec.type === 'assistant') {
|
|
if (block.type === 'text' && typeof block.text === 'string') events.push({ kind: 'text', line, text: block.text });
|
|
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
|
events.push({ kind: 'tool_use', line, id: String(block.id ?? ''), name: block.name, input: isObject(block.input) ? block.input : {} });
|
|
}
|
|
} else if (block.type === 'tool_result') {
|
|
events.push({ kind: 'tool_result', line, id: String(block.tool_use_id ?? ''), text: blockText(block.content), isError: block.is_error === true });
|
|
} else if (block.type === 'text' && typeof block.text === 'string') {
|
|
events.push({ kind: rec.isMeta === true || META_PREFIX.test(block.text) ? 'meta' : 'prompt', line, text: block.text });
|
|
}
|
|
}
|
|
}
|
|
if (lines > 0 && objects === 0) throw new UnknownShape('no line is a JSON object');
|
|
if (objects > 0 && messages === 0) throw new UnknownShape('no user or assistant message records');
|
|
return { events, sessionId };
|
|
}
|
|
|
|
/**
|
|
* The transcript with the Stop payload's last_assistant_message as its final text, when the file does not end on it
|
|
* yet: Claude Code can fire Stop before it writes the turn's last message.
|
|
*/
|
|
export function withFinalMessage(t: Transcript, message: unknown): Transcript {
|
|
if (typeof message !== 'string' || !message.trim()) return t;
|
|
const lastPrompt = t.events.map(e => e.kind).lastIndexOf('prompt');
|
|
const lastText = t.events.slice(lastPrompt + 1).filter(e => e.kind === 'text').pop() as { text: string } | undefined;
|
|
if (lastText?.text.trim() === message.trim()) return t;
|
|
const line = (t.events[t.events.length - 1]?.line ?? 0) + 1;
|
|
return { ...t, events: [...t.events, { kind: 'text', line, text: message }] };
|
|
}
|
|
|
|
export function readTranscriptFile(file: string): string {
|
|
try {
|
|
const size = fs.statSync(file).size;
|
|
if (size > TRANSCRIPT_MAX_BYTES) throw new StateError(file, `transcript is ${size} bytes, over the ${TRANSCRIPT_MAX_BYTES} byte read limit`);
|
|
return fs.readFileSync(file, 'utf8');
|
|
} catch (e) {
|
|
if (e instanceof StateError) throw e;
|
|
throw new StateError(file, `cannot read transcript (${errText(e)})`);
|
|
}
|
|
}
|
|
|
|
export const canonicalSkill = (name: string) => name.trim().toLowerCase().replace(/^\//, '').replace(/^gstack:/, '');
|
|
export function sameSkill(a: string, b: string): boolean {
|
|
const x = canonicalSkill(a);
|
|
const y = canonicalSkill(b);
|
|
return x === y || x === `gstack-${y}` || `gstack-${x}` === y;
|
|
}
|
|
|
|
export interface SkillLoad { skill: string; at: number; line: number; via: 'skill-tool' | 'slash-command' | 'read' }
|
|
|
|
/** Every skill load in order: a Skill tool call, a typed /command, or a Read of a SKILL.md under a skills/ directory (an installed skill, not a checkout's source). */
|
|
export function skillLoads(t: Transcript): SkillLoad[] {
|
|
const loads: SkillLoad[] = [];
|
|
t.events.forEach((e, at) => {
|
|
if (e.kind === 'tool_use' && e.name === 'Skill') {
|
|
const name = e.input.skill ?? e.input.command;
|
|
if (typeof name === 'string' && name.trim()) loads.push({ skill: canonicalSkill(name.split(/\s/)[0]), at, line: e.line, via: 'skill-tool' });
|
|
} else if (e.kind === 'tool_use' && e.name === 'Read' && typeof e.input.file_path === 'string') {
|
|
const m = /(?:^|[\\/])skills[\\/](?:.*[\\/])?([^\\/]+)[\\/]SKILL\.md$/.exec(e.input.file_path);
|
|
if (m) loads.push({ skill: canonicalSkill(m[1]), at, line: e.line, via: 'read' });
|
|
} else if (e.kind === 'prompt') {
|
|
const m = /<command-name>\s*\/?([^<\s]+)\s*<\/command-name>/.exec(e.text);
|
|
if (m) loads.push({ skill: canonicalSkill(m[1]), at, line: e.line, via: 'slash-command' });
|
|
}
|
|
});
|
|
return loads;
|
|
}
|
|
|
|
export const STATUS_RE = /^[ \t>*_-]*(?:\*\*|__)?(?:STATUS|Status)(?:\*\*|__)?[ \t]*(?:\*\*|__)?:[ \t]*(?:\*\*|__)?[ \t]*(DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/m;
|
|
const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/;
|
|
const AGENT_TOOLS = /^(?:Agent|Task)$/;
|
|
const CHECK_RUNNER = /^(?:pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|tsc|make|cargo|go|bun|bunx|npm|npx|pnpm|yarn|node|deno|python3?|ruby|rake|mvn|gradlew?|dotnet|swift|xcodebuild|curl|mix|bazel|just|dart|flutter|rails)$/i;
|
|
const CHECK_SCRIPT = /(?:^|[-_.])(?:tests?|spec|repro|check|verify|lint)(?:[-_.]|$)/i;
|
|
const COMMAND_WRAPPER = /^(?:bundle|uv|poetry|pipenv|docker|podman|xvfb-run|bash|sh|zsh)$/;
|
|
const COMMAND_PREFIX = /^(?:env|sudo|time|nice|timeout|command|exec|\w+=\S*|-\S*|\d+[smh]?)$/;
|
|
const isCheckProgram = (word: string) => { const name = word.split(/[\\/]/).pop() ?? ''; return CHECK_RUNNER.test(name) || (name !== 'test' && CHECK_SCRIPT.test(name)); };
|
|
const SAFE_SPECIALIST = /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/;
|
|
|
|
/**
|
|
* A test or repro run: some segment of the command (split on && || ; | and newlines) starts with a test runner or a
|
|
* test, check or repro script, directly or behind a wrapper (bundle exec, uv run, docker compose run, xvfb-run, bash).
|
|
*/
|
|
export function isCheckCommand(cmd: string): boolean {
|
|
return cmd.split(/&&|\|\||[;|\n]/).some(segment => {
|
|
const words = segment.trim().replace(/^[({]\s*/, '').split(/\s+/);
|
|
while (words.length > 1 && COMMAND_PREFIX.test(words[0])) words.shift();
|
|
if (isCheckProgram(words[0])) return true;
|
|
return COMMAND_WRAPPER.test(words[0].split(/[\\/]/).pop() ?? '') && words.slice(1, 6).some(isCheckProgram);
|
|
});
|
|
}
|
|
const WAIVE_RE = /gstack-skill-contract["']?\s+waive\s+["']?([A-Za-z0-9][A-Za-z0-9_.-]*)["']?/;
|
|
|
|
type ToolUse = Extract<TranscriptEvent, { kind: 'tool_use' }>;
|
|
type ToolResult = Extract<TranscriptEvent, { kind: 'tool_result' }>;
|
|
const isToolUse = (e: TranscriptEvent): e is ToolUse => e.kind === 'tool_use';
|
|
const bashCommand = (e: TranscriptEvent) => (isToolUse(e) && e.name === 'Bash' ? String(e.input.command ?? '') : null);
|
|
const agentText = (e: ToolUse) => [e.input.description, e.input.prompt, e.input.subagent_type, e.input.name].filter(v => typeof v === 'string').join('\n');
|
|
const cut = (s: string, n = 80) => (s.length > n ? `${s.slice(0, n - 3)}...` : s).replace(/\s+/g, ' ');
|
|
|
|
function labelValue(text: string, label: string): string | null {
|
|
const re = new RegExp(`^[ \\t>*_|-]*(?:\\*\\*|__)?${escapeRe(label)}[ \\t]*(?:\\*\\*|__)?[ \\t]*:[ \\t]*(?:\\*\\*|__)?(.*)$`, 'im');
|
|
const m = re.exec(text);
|
|
return m ? m[1].replace(/(?:\*\*|__)\s*$/, '').replace(/^`+|`+$/g, '').trim() : null;
|
|
}
|
|
|
|
export function isPlaceholder(value: string): boolean {
|
|
const v = value.trim();
|
|
return !v || /^\[[^\]]*\]$/.test(v) || /^<[^>]*>$/.test(v) || /^[A-Z_]+(?:\s*\|\s*[A-Z_]+)+$/.test(v) ||
|
|
/^(?:tbd|tba|todo|xxx|fixme|placeholder|\.{2,}|…|-+|—|\?+)$/i.test(v);
|
|
}
|
|
|
|
function parseNames(raw: string): string[] {
|
|
const end = raw.search(/\.\s|\.$|\s(?:Skipped|Gated)\s*:/i);
|
|
return (end < 0 ? raw : raw.slice(0, end)).replace(/\([^)]*\)/g, ' ').split(/,|;|\band\b/)
|
|
.map(n => n.replace(/[[\]()"'`*]/g, '').trim()).filter(n => n && !/^none$/i.test(n));
|
|
}
|
|
|
|
interface ItemCheck { met: boolean; detail: string; fix: string }
|
|
|
|
function checkItem(item: ContractItem, run: TranscriptEvent[]): ItemCheck {
|
|
const texts = run.filter(e => e.kind === 'text').map(e => (e as { text: string }).text);
|
|
const agents = run.filter((e): e is ToolUse => isToolUse(e) && AGENT_TOOLS.test(e.name));
|
|
switch (item.kind) {
|
|
case 'report_labels': {
|
|
const heading = item.heading as string;
|
|
const labels = item.labels ?? [];
|
|
const headingRe = new RegExp(escapeRe(heading), 'i');
|
|
const source = [...texts].reverse().find(t => headingRe.test(t));
|
|
if (!source) return { met: false, detail: `no ${heading} found`, fix: item.fix ?? `print the ${heading} with real values for ${labels.join(', ')}` };
|
|
const after = source.slice(source.search(headingRe));
|
|
const bad = labels.filter(label => { const v = labelValue(after, label); return v === null || isPlaceholder(v); });
|
|
return bad.length
|
|
? { met: false, detail: `${heading} lacks a real value for ${bad.join(', ')}`, fix: item.fix ?? `fill ${bad.join(', ')} in the ${heading} with real values` }
|
|
: { met: true, detail: `${heading} has all ${labels.length} labels`, fix: '' };
|
|
}
|
|
case 'check_after_last_edit': {
|
|
let lastEdit = -1;
|
|
run.forEach((e, i) => { if (isToolUse(e) && EDIT_TOOLS.test(e.name)) lastEdit = i; });
|
|
if (lastEdit < 0) return { met: true, detail: 'no edits since the skill loaded', fix: '' };
|
|
const re = item.match ? new RegExp(item.match, 'i') : null;
|
|
const check = run.slice(lastEdit + 1).map(bashCommand).find(cmd => {
|
|
if (cmd === null || /gstack-skill-contract/.test(cmd)) return false;
|
|
const bare = cmd.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
|
|
return re ? re.test(bare) : isCheckCommand(bare);
|
|
});
|
|
return check
|
|
? { met: true, detail: `ran "${cut(check)}" after the last edit`, fix: '' }
|
|
: { met: false, detail: `no test or repro command after the last ${(run[lastEdit] as ToolUse).name}`, fix: item.fix ?? 'run the test or repro again after your last edit and show its result' };
|
|
}
|
|
case 'selected_specialists_dispatched': {
|
|
const marker = markerPattern(item.marker ?? DEFAULT_MATCHERS.specialistSelection);
|
|
const empty = markerPattern(item.empty_marker ?? DEFAULT_MATCHERS.specialistsSkipped);
|
|
const bashIds = new Set(run.filter(e => bashCommand(e) !== null).map(e => (e as ToolUse).id));
|
|
const sources = run.filter(e => e.kind === 'text' || (e.kind === 'tool_result' && bashIds.has(e.id))).map(e => (e as { text: string }).text).reverse();
|
|
let names: string[] | null = null;
|
|
for (const src of sources) {
|
|
const m = marker.exec(src);
|
|
const parsed = m ? (m.groups?.count === '0' ? [] : parseNames(m.groups?.names ?? '')) : null;
|
|
if (parsed && parsed.every(n => SAFE_SPECIALIST.test(n))) { names = parsed; break; }
|
|
if (empty.test(src)) { names = []; break; }
|
|
}
|
|
if (names === null) return { met: false, detail: 'no specialist selection line found', fix: item.fix ?? 'print the specialist selection line, then dispatch one Agent per selected specialist' };
|
|
if (!names.length) return { met: true, detail: 'no specialists selected', fix: '' };
|
|
const missing = names.filter(n => {
|
|
const forms = [n, n.replace(/\s+/g, '-'), n.replace(/\s+/g, '_')].map(f => f.toLowerCase());
|
|
return !agents.some(a => forms.some(f => agentText(a).toLowerCase().includes(f)));
|
|
});
|
|
return missing.length
|
|
? { met: false, detail: `selected ${names.join(', ')}; no Agent dispatch for ${missing.join(', ')}`, fix: item.fix ?? `dispatch an Agent for ${missing.join(', ')}` }
|
|
: { met: true, detail: `dispatched ${names.join(', ')}`, fix: '' };
|
|
}
|
|
case 'agent_dispatch': {
|
|
if (item.marker) {
|
|
const marker = markerPattern(item.marker);
|
|
let markedAt = -1;
|
|
run.forEach((e, i) => { if (e.kind === 'text' && marker.test(e.text)) markedAt = i; });
|
|
if (markedAt >= 0 && run.slice(markedAt + 1).some(e => isToolUse(e) && AGENT_TOOLS.test(e.name))) {
|
|
return { met: true, detail: `printed "${item.marker}", then an Agent dispatch`, fix: '' };
|
|
}
|
|
}
|
|
const re = new RegExp(item.match as string, 'i');
|
|
return agents.some(a => re.test(agentText(a)))
|
|
? { met: true, detail: `an Agent dispatch matches /${item.match}/`, fix: '' }
|
|
: { met: false, detail: `no Agent dispatch matches /${item.match}/`, fix: item.fix ?? `dispatch the Agent pass matching /${item.match}/` };
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Waivers in a run: item id -> reason, from waive Bash calls whose result (if any) is not an error. */
|
|
function waivers(run: TranscriptEvent[], skill: string, results: Map<string, ToolResult>): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
for (const e of run) {
|
|
const cmd = bashCommand(e);
|
|
const m = cmd === null ? null : WAIVE_RE.exec(cmd);
|
|
if (!m || !cmd) continue;
|
|
const reason = /--reason[= ]\s*(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(cmd);
|
|
const text = reason ? (reason[1] ?? reason[2] ?? reason[3] ?? '').trim() : '';
|
|
const skillFlag = /--skill[= ]\s*["']?([^\s"']+)/.exec(cmd);
|
|
if (!text || (skillFlag && !sameSkill(skillFlag[1], skill)) || results.get((e as ToolUse).id)?.isError) continue;
|
|
out.set(m[1], text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface ItemResult { id: string; kind: ItemKind; status: 'met' | 'missing' | 'waived'; detail: string; fix: string; reason?: string }
|
|
export interface CheckResult {
|
|
skill: string;
|
|
version: 1;
|
|
loaded: boolean;
|
|
load: SkillLoad | null;
|
|
items: ItemResult[];
|
|
missing: string[];
|
|
waived: string[];
|
|
/** every item met or waived: nothing to block on */
|
|
met: boolean;
|
|
/** every item met with no waiver: what follow-rate metrics count */
|
|
follow: boolean;
|
|
statusLine: string | null;
|
|
pendingQuestion: boolean;
|
|
pendingAgents: string[];
|
|
}
|
|
|
|
/**
|
|
* The contract against one run of the skill: from its load (the last one, or the one at loadLine) to its next load.
|
|
* Pure: same transcript, same result.
|
|
*/
|
|
export function checkContract(t: Transcript, skill: string, contract: Contract, opts: { loadLine?: number } = {}): CheckResult {
|
|
const events = t.events;
|
|
const loads = skillLoads(t).filter(l => sameSkill(l.skill, skill));
|
|
const load = (opts.loadLine === undefined ? loads[loads.length - 1] : loads.find(l => l.line === opts.loadLine)) ?? null;
|
|
const next = load ? loads.find(l => l.at > load.at) : undefined;
|
|
const run = load ? events.slice(load.at + 1, next?.at) : [];
|
|
const results = new Map<string, ToolResult>();
|
|
for (const e of run) if (e.kind === 'tool_result') results.set(e.id, e);
|
|
const waived = load ? waivers(run, skill, results) : new Map<string, string>();
|
|
const lastPrompt = events.map(e => e.kind).lastIndexOf('prompt');
|
|
const turn = events.slice(lastPrompt + 1).filter(e => e.kind === 'text' || e.kind === 'tool_use');
|
|
const final = turn[turn.length - 1];
|
|
const finalText = [...turn].reverse().find(e => e.kind === 'text') as { text: string } | undefined;
|
|
const statusLine = finalText ? STATUS_RE.exec(finalText.text)?.[1] ?? null : null;
|
|
const exitMarker = new RegExp(`^[ \\t>*_-]*${markerPattern(DEFAULT_MATCHERS.earlyExit).source}`, 'im');
|
|
const endedEarly = !!finalText && run.includes(finalText as TranscriptEvent) && exitMarker.test(finalText.text);
|
|
const earlyExit = !load ? null : statusLine === 'BLOCKED' ? 'STATUS: BLOCKED' : endedEarly ? DEFAULT_MATCHERS.earlyExit : null;
|
|
const items: ItemResult[] = load ? contract.items.map(item => {
|
|
if (earlyExit) return { id: item.id, kind: item.kind, status: 'met', detail: `the skill ended the run early (${earlyExit})`, fix: '' };
|
|
const r = checkItem(item, run);
|
|
if (r.met) return { id: item.id, kind: item.kind, status: 'met', detail: r.detail, fix: '' };
|
|
const reason = waived.get(item.id);
|
|
return reason ? { id: item.id, kind: item.kind, status: 'waived', detail: r.detail, fix: r.fix, reason } : { id: item.id, kind: item.kind, status: 'missing', detail: r.detail, fix: r.fix };
|
|
}) : [];
|
|
const lastParagraph = (finalText?.text ?? '').trim().split(/\n\s*\n/).pop() ?? '';
|
|
const pendingQuestion = statusLine === 'NEEDS_CONTEXT' || (final !== undefined && isToolUse(final) && /AskUserQuestion$/.test(final.name)) ||
|
|
(final !== undefined && final.kind === 'text' && /\?[\s*_)"']*$/.test(lastParagraph));
|
|
const pendingAgents = run.flatMap((e, i) => {
|
|
if (!isToolUse(e) || !AGENT_TOOLS.test(e.name)) return [];
|
|
const result = results.get(e.id);
|
|
if (!result) return [e.id];
|
|
const resultAt = run.indexOf(result);
|
|
const ids = [e.id, ...[...result.text.matchAll(/\b(?:agent|task)[_ -]?id["']?\s*[:=]\s*["']?([\w-]+)/gi)].map(m => m[1])].filter(Boolean);
|
|
const named = ids.map(id => new RegExp(`(?:^|[^\\w-])${escapeRe(id)}(?:[^\\w-]|$)`));
|
|
const notified = run.slice(resultAt + 1).some(n => n.kind === 'meta' && named.some(re => re.test(n.text)));
|
|
return e.input.run_in_background === true && i < resultAt && !notified ? [e.id] : [];
|
|
});
|
|
const missing = items.filter(i => i.status === 'missing').map(i => i.id);
|
|
const waivedIds = items.filter(i => i.status === 'waived').map(i => i.id);
|
|
return {
|
|
skill, version: 1, loaded: !!load, load, items, missing, waived: waivedIds,
|
|
met: !!load && missing.length === 0, follow: !!load && missing.length === 0 && waivedIds.length === 0,
|
|
statusLine, pendingQuestion, pendingAgents,
|
|
};
|
|
}
|
|
|
|
/** The one agent-facing block text (gate rev 2, E1). It never names the off switch: that is for humans, in the docs and setup. */
|
|
export function blockMessage(result: CheckResult): string {
|
|
const fixes = result.items.filter(i => i.status === 'missing').map(i => `${i.id}: ${i.fix}`).join('; ');
|
|
return `SKILL_CONTRACT_UNMET ${result.skill} (contract v1): missing ${result.missing.join(', ')}. Fix: ${fixes}. ` +
|
|
'If an item truly does not apply, run gstack-skill-contract waive <item> --reason "<why>" (this session only; counted as a miss for follow). This blocks once.';
|
|
}
|
|
|
|
export type ContractLookup =
|
|
| { skill: string; file: string; contract: Contract }
|
|
| { skill: string; file: string | null; error: string; reason: 'not-found' | 'no-contract' | 'invalid' };
|
|
|
|
/** The contract of an installed skill (by frontmatter name or directory), or of an explicit SKILL.md. */
|
|
export function lookupContract(skill: string, skillsDir: string, skillFile?: string): ContractLookup {
|
|
let file: string | null = skillFile ?? null;
|
|
let frontmatter: string | null = null;
|
|
if (file) {
|
|
try { frontmatter = readFrontmatter(file); } catch (e) { return { skill, file, error: `cannot read ${file} (${errText(e)})`, reason: 'not-found' }; }
|
|
} else {
|
|
const found = scanInstalledSkills(skillsDir).skills.find(s => sameSkill(s.name, skill) || sameSkill(path.basename(path.dirname(s.file)), skill));
|
|
if (found) { file = found.file; frontmatter = found.frontmatter; }
|
|
}
|
|
if (!file) return { skill, file, error: `no installed skill named ${skill} under ${skillsDir}`, reason: 'not-found' };
|
|
const parsed = frontmatter === null ? null : parseContract(frontmatter);
|
|
if (parsed === null) return { skill, file, error: `${file} declares no contract`, reason: 'no-contract' };
|
|
return 'error' in parsed ? { skill, file, error: `${file}: ${parsed.error}`, reason: 'invalid' } : { skill, file, contract: parsed.contract };
|
|
}
|
|
|
|
export type Outcome = 'met' | 'met_waived' | 'blocked' | 'met_after_block' | 'miss';
|
|
export interface RunRecord { skill: string; load_line: number; outcome: Outcome; missing: string[]; waived: string[]; blocked_at?: string; updated_at: string }
|
|
export interface SessionRecord { version: 1; session_id: string; transcript_path: string; runs: Record<string, RunRecord> }
|
|
|
|
export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
return path.join(resolveStateRoot(env), 'skill-contracts');
|
|
}
|
|
|
|
export function recordPath(sessionId: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128);
|
|
if (!safe) throw new StateError(recordsDir(env), 'hook payload has no session_id');
|
|
return path.join(recordsDir(env), `${safe}.json`);
|
|
}
|
|
|
|
export const runKey = (skill: string, load: SkillLoad) => `${canonicalSkill(skill)}@${load.line}`;
|
|
|
|
export function readRecord(file: string): SessionRecord | null {
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(file, 'utf8');
|
|
} catch (e) {
|
|
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT' || (e as NodeJS.ErrnoException)?.code === 'ENOTDIR') return null;
|
|
throw new StateError(file, `cannot read record (${errText(e)})`);
|
|
}
|
|
let rec: unknown;
|
|
try { rec = JSON.parse(text); } catch { throw new StateError(file, 'corrupt record (not JSON)'); }
|
|
if (!isObject(rec) || rec.version !== 1 || typeof rec.session_id !== 'string' || !isObject(rec.runs)) {
|
|
throw new StateError(file, 'corrupt record (unexpected shape)');
|
|
}
|
|
return rec as SessionRecord;
|
|
}
|
|
|
|
export function writeRecord(file: string, rec: SessionRecord): void {
|
|
try {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
atomicWriteSync(file, `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
|
|
} catch (e) {
|
|
throw new StateError(file, `cannot write record (${errText(e)})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Block at most once per skill run: an unmet contract blocks unless this run was already blocked, which makes it a miss.
|
|
* A run recorded met, met_waived or met_after_block is final: later unrelated turns in the session never reopen it.
|
|
*/
|
|
export function nextOutcome(prev: RunRecord | undefined, result: CheckResult): { outcome: Outcome; block: boolean } {
|
|
if (prev && (prev.outcome === 'met' || prev.outcome === 'met_waived' || prev.outcome === 'met_after_block')) return { outcome: prev.outcome, block: false };
|
|
if (result.met) return { outcome: prev?.blocked_at ? 'met_after_block' : result.waived.length ? 'met_waived' : 'met', block: false };
|
|
return prev?.blocked_at ? { outcome: 'miss', block: false } : { outcome: 'blocked', block: true };
|
|
}
|
|
|
|
/** Count one unknown transcript shape in $GSTACK_STATE_ROOT/hooks/skill-contract.canary.json; returns the new count, 0 when it cannot be written. */
|
|
export function bumpCanary(why: string, env: NodeJS.ProcessEnv = process.env): number {
|
|
try {
|
|
const file = path.join(resolveStateRoot(env), 'hooks', 'skill-contract.canary.json');
|
|
let count = 0;
|
|
try { count = Number(JSON.parse(fs.readFileSync(file, 'utf8')).count) || 0; } catch { /* first one */ }
|
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
atomicWriteSync(file, `${JSON.stringify({ count: count + 1, last_cause: why, updated_at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
return count + 1;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export interface ReportRow { session: string; skill: string; outcome: Outcome | 'fixed_after_block'; missing: string[]; waived: string[]; transcript: string }
|
|
|
|
/** Every recorded run; a run still marked blocked is re-checked against its transcript when a recheck is given. */
|
|
export function reportRows(records: SessionRecord[], recheck?: (rec: SessionRecord, run: RunRecord) => CheckResult | null): ReportRow[] {
|
|
return records.flatMap(rec => Object.values(rec.runs).map(run => {
|
|
const row: ReportRow = { session: rec.session_id, skill: run.skill, outcome: run.outcome, missing: run.missing, waived: run.waived, transcript: rec.transcript_path };
|
|
if (run.outcome !== 'blocked' || !recheck) return row;
|
|
const again = recheck(rec, run);
|
|
if (!again) return row;
|
|
return again.met
|
|
? { ...row, outcome: 'fixed_after_block' as const, missing: [], waived: again.waived }
|
|
: { ...row, outcome: 'miss' as const, missing: again.missing, waived: again.waived };
|
|
}));
|
|
}
|
|
|
|
/** A miss for follow: blocked and never fixed, stopped again unmet, or met only through a waiver. */
|
|
export const isMiss = (row: ReportRow) => row.outcome === 'blocked' || row.outcome === 'miss' || row.waived.length > 0;
|
|
|
|
export function readAllRecords(env: NodeJS.ProcessEnv = process.env): { records: SessionRecord[]; errors: string[] } {
|
|
const dir = recordsDir(env);
|
|
const records: SessionRecord[] = [];
|
|
const errors: string[] = [];
|
|
let names: string[] = [];
|
|
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort(); } catch { return { records, errors }; }
|
|
for (const name of names) {
|
|
try {
|
|
const rec = readRecord(path.join(dir, name));
|
|
if (rec) records.push(rec);
|
|
} catch (e) {
|
|
errors.push(e instanceof StateError ? `${e.file}: ${e.why}` : String(e));
|
|
}
|
|
}
|
|
return { records, errors };
|
|
}
|