Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions skills/workflow-render/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
name: workflow-render
user-invocable: false
allowed-tools: Bash(node .claude/skills/workflow-render/scripts/render.cjs)
---

# Workflow Render

Deterministic renderer for fixed-shape workflow output. Layout that is fully determined by data is computed here, in code, and emitted verbatim — never re-derived character-by-character on every render. The wrap/width math lives here once, so the gutter-budget bug can exist in only one place.

Two ways in:

- **CLI** — Claude invokes it via Bash for trivial-input shapes (a label, a title). Print the result verbatim to the user.
- **Library** — other scripts `require()` it and call the functions in-process. The data-owner (e.g. `discovery.cjs`) builds the structure and calls the renderer; Claude never assembles the input.

The output is for display. Never read a rendered block back to extract a decision — decision data comes from structured state, not from parsing the rendered text.

## Invocation

```bash
node .claude/skills/workflow-render/scripts/render.cjs <command> [args]
```

All shapes are a fixed `--width` (default `49`, the canonical workflow width).

## Commands

### `signpost <label> [--style step|substep] [--width N]`

A step or sub-step marker, padded to width. One line.

```
render signpost "Construct Specification"
── Construct Specification ──────────────────────

render signpost "Extract Sources" --style substep
·· Extract Sources ······························
```

Loop and route labels are passed verbatim: `render signpost "Task Execution (3 of 12)"`.

### `box <title> [--width N]`

A bullet-bordered phase-title box with a trailing blank line.

```
render box "Planning Overview"
●───────────────────────────────────────────────●
Planning Overview
●───────────────────────────────────────────────●
```

### `wrap <text> [--width N] [--prefix STR]`

Word-wrap `text` so that `prefix + line` stays within `width`; every line carries the prefix (the gutter/indent). The wrap budget is `width − prefix.length` — this is the primitive trees and wrapped lists build on. Utility/inspection command; in code, call `wrapWithPrefix` directly.

## Library API

```js
const { signpost, box, wrap, wrapWithPrefix, fillTo, WIDTH } =
require('.../skills/workflow-render/scripts/render.cjs');

signpost(label, { style, width }) // → string (one line)
box(title, { width }) // → string (block, trailing blank)
wrapWithPrefix(text, { width, prefix }) // → string[] (each line prefixed)
wrap(text, budget) // → string[] (segments ≤ budget)
fillTo(head, fillChar, width) // → string (head padded to width)
```

`wrapWithPrefix` throws if the prefix leaves no room within the width — a misconfigured gutter fails loudly rather than silently overflowing.

## Tests

`tests/scripts/test-render.cjs` (run `node --test tests/scripts/test-render.cjs`). Add a test alongside any change to `render.cjs`.
171 changes: 171 additions & 0 deletions skills/workflow-render/scripts/render.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env node
'use strict';

// ---------------------------------------------------------------------------
// Deterministic renderer for fixed-shape workflow output.
//
// Layout that is fully determined by data is computed here, in code, and
// emitted verbatim — never re-derived character-by-character by the model on
// every render. This is both a LIBRARY (scripts `require()` it and call the
// functions in-process) and a CLI (Claude invokes it via Bash for trivial
// shapes). The wrap/width math lives here once, so the gutter-budget bug can
// exist in exactly one place.
//
// Slice 1: the shared width/wrap core + the signpost family (step markers,
// sub-step markers, phase-title boxes). Menus, lists, and trees build on the
// same core later.
// ---------------------------------------------------------------------------

const WIDTH = 49; // canonical fixed width (CONVENTIONS.md: phase titles, markers)

// ---------------------------------------------------------------------------
// Core: width + wrap primitives
// ---------------------------------------------------------------------------

// Fill a head string out to `width` with `fillChar`. If the head already meets
// or exceeds the width, it is returned unchanged (never truncated, never a
// negative repeat).
function fillTo(head, fillChar, width) {
const deficit = width - head.length;
return deficit > 0 ? head + fillChar.repeat(deficit) : head;
}

// Greedy word-wrap `text` into segments no wider than `budget` columns. A word
// longer than the budget is hard-split (so a long unbroken token can never
// overflow the budget). Returns an array of segments with no trailing spaces.
function wrap(text, budget) {
if (!Number.isInteger(budget) || budget < 1) {
throw new Error(`wrap: budget must be a positive integer (got ${budget})`);
}
const words = String(text).trim().split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (let word of words) {
while (word.length > budget) {
// Hard-split an oversized token across as many lines as needed.
if (line) { lines.push(line); line = ''; }
lines.push(word.slice(0, budget));
word = word.slice(budget);
}
if (!line) {
line = word;
} else if (line.length + 1 + word.length <= budget) {
line += ' ' + word;
} else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines.length ? lines : [''];
}

// Wrap `text` and prefix every resulting line with `prefix`, keeping the total
// rendered width (prefix + text) within `width`. THE budget bug lives here and
// only here: the wrap budget is `width - prefix.length`, never the bare width.
// `prefix` is the gutter/indent string applied to every line uniformly.
function wrapWithPrefix(text, { width = WIDTH, prefix = '' } = {}) {
const budget = width - prefix.length;
if (budget < 1) {
throw new Error(
`wrapWithPrefix: prefix (${prefix.length}) leaves no room within width ${width}`
);
}
return wrap(text, budget).map((seg) => prefix + seg);
}

// ---------------------------------------------------------------------------
// Shapes: signpost family
// ---------------------------------------------------------------------------

const STYLES = {
step: { lead: '── ', fill: '─' }, // ── name ──── (em-dash)
substep: { lead: '·· ', fill: '·' }, // ·· name ···· (middle dot)
};

// `── Label ──────…` step marker (default) or `·· Label ··…` sub-step marker,
// padded to `width`. A single line, no trailing newline.
function signpost(label, { style = 'step', width = WIDTH } = {}) {
const s = STYLES[style];
if (!s) throw new Error(`signpost: unknown style "${style}" (step|substep)`);
const text = String(label).trim();
if (!text) throw new Error('signpost: label is required');
return fillTo(s.lead + text + ' ', s.fill, width);
}

// Phase-title box — bullet-bordered, fixed width, 2-space title padding, with a
// trailing blank line inside the block (CONVENTIONS.md: phase titles).
//
// ●───…───●
// Title
// ●───…───●
// <blank>
function box(title, { width = WIDTH } = {}) {
const text = String(title).trim();
if (!text) throw new Error('box: title is required');
const border = '●' + '─'.repeat(width - 2) + '●';
return `${border}\n ${text}\n${border}\n\n`;
}

// ---------------------------------------------------------------------------
// Exports (library use)
// ---------------------------------------------------------------------------

module.exports = { WIDTH, fillTo, wrap, wrapWithPrefix, signpost, box };

// ---------------------------------------------------------------------------
// CLI (Claude / shell use) — guarded so `require()` never runs it.
// ---------------------------------------------------------------------------

function die(msg) {
process.stderr.write(msg + '\n');
process.exit(1);
}

// Minimal flag parser: collects `--key value` pairs and bare positionals.
function parseArgs(argv) {
const opts = {};
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
opts[a.slice(2)] = argv[++i];
} else {
positional.push(a);
}
}
return { opts, positional };
}

function runCli(argv) {
const [command, ...rest] = argv;
const { opts, positional } = parseArgs(rest);
const width = opts.width !== undefined ? parseInt(opts.width, 10) : WIDTH;

switch (command) {
case 'signpost':
if (!positional.length) die('Usage: render signpost <label> [--style step|substep] [--width N]');
process.stdout.write(signpost(positional.join(' '), { style: opts.style || 'step', width }) + '\n');
break;
case 'box':
if (!positional.length) die('Usage: render box <title> [--width N]');
process.stdout.write(box(positional.join(' '), { width }));
break;
case 'wrap': {
if (!positional.length) die('Usage: render wrap <text> [--width N] [--prefix STR]');
const lines = wrapWithPrefix(positional.join(' '), { width, prefix: opts.prefix || '' });
process.stdout.write(lines.join('\n') + '\n');
break;
}
default:
die(`Unknown command "${command || ''}"\nCommands: signpost, box, wrap`);
}
}

if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (err) {
die(err.message);
}
}
130 changes: 130 additions & 0 deletions tests/scripts/test-render.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
'use strict';

const { describe, it } = require('node:test');
const assert = require('node:assert');

const {
WIDTH,
fillTo,
wrap,
wrapWithPrefix,
signpost,
box,
} = require('../../skills/workflow-render/scripts/render.cjs');

describe('render core: fillTo', () => {
it('pads a head out to the target width', () => {
const out = fillTo('── Name ', '─', 49);
assert.strictEqual(out.length, 49);
assert.ok(out.startsWith('── Name '));
assert.ok(out.endsWith('─'));
});

it('returns the head unchanged when it already meets the width', () => {
assert.strictEqual(fillTo('exactly-ten', '─', 5), 'exactly-ten');
});

it('never produces a negative repeat', () => {
assert.doesNotThrow(() => fillTo('x'.repeat(60), '─', 49));
});
});

describe('render core: wrap', () => {
it('wraps greedily within the budget', () => {
const lines = wrap('the quick brown fox jumps', 10);
assert.deepStrictEqual(lines, ['the quick', 'brown fox', 'jumps']);
for (const l of lines) assert.ok(l.length <= 10);
});

it('hard-splits a token longer than the budget', () => {
const lines = wrap('fastest-cumulative-time wins', 10);
for (const l of lines) assert.ok(l.length <= 10, `"${l}" exceeds budget`);
assert.strictEqual(lines[0], 'fastest-cu'); // oversized token broken at the budget
assert.ok(lines.length >= 3);
});

it('returns a single empty segment for empty text', () => {
assert.deepStrictEqual(wrap('', 10), ['']);
});

it('rejects a non-positive budget', () => {
assert.throws(() => wrap('x', 0));
assert.throws(() => wrap('x', -3));
});
});

describe('render core: wrapWithPrefix (the budget bug lives here)', () => {
it('subtracts the prefix width from the budget so total width is respected', () => {
const prefix = ' │ '; // 9-char discovery-map gutter
const lines = wrapWithPrefix(
'AI imagery enhancement-only v1 description generation primitive',
{ width: 49, prefix }
);
for (const l of lines) {
assert.ok(l.startsWith(prefix), 'every line carries the gutter');
assert.ok(l.length <= 49, `"${l}" (${l.length}) overruns width 49`);
}
});

it('keeps the gutter on every continuation line (tree never breaks)', () => {
const lines = wrapWithPrefix('one two three four five six seven', {
width: 20,
prefix: '│ ',
});
assert.ok(lines.length > 1);
for (const l of lines) assert.ok(l.startsWith('│ '));
});

it('throws when the prefix leaves no room within the width', () => {
assert.throws(() => wrapWithPrefix('x', { width: 5, prefix: ' ' }));
});
});

describe('render shape: signpost', () => {
it('renders a step marker at the canonical width', () => {
const out = signpost('Construct Specification');
assert.strictEqual(out.length, WIDTH);
assert.strictEqual(out, '── Construct Specification ' + '─'.repeat(WIDTH - 27));
assert.ok(out.startsWith('── Construct Specification '));
});

it('renders a sub-step marker with middle dots', () => {
const out = signpost('Extract Sources', { style: 'substep' });
assert.strictEqual(out.length, WIDTH);
assert.ok(out.startsWith('·· Extract Sources '));
assert.ok(out.endsWith('·'));
});

it('honours a custom width', () => {
assert.strictEqual(signpost('X', { width: 20 }).length, 20);
});

it('supports loop-iteration labels verbatim', () => {
const out = signpost('Task Execution (3 of 12)');
assert.ok(out.startsWith('── Task Execution (3 of 12) '));
assert.strictEqual(out.length, WIDTH);
});

it('rejects an empty label and an unknown style', () => {
assert.throws(() => signpost(' '));
assert.throws(() => signpost('X', { style: 'bogus' }));
});
});

describe('render shape: box (phase title)', () => {
it('renders a 49-wide bullet-bordered box with a trailing blank line', () => {
const out = box('Planning Overview');
const lines = out.split('\n');
// border, title, border, <visible blank>, "" (final terminator)
assert.strictEqual(lines[0], '●' + '─'.repeat(WIDTH - 2) + '●');
assert.strictEqual(lines[0].length, WIDTH);
assert.strictEqual(lines[1], ' Planning Overview');
assert.strictEqual(lines[2], lines[0]);
assert.strictEqual(lines[3], ''); // the breathing-room blank line
assert.ok(out.endsWith('\n\n'));
});

it('rejects an empty title', () => {
assert.throws(() => box(''));
});
});