mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
Merge origin/main into garrytan/prompt-injection-guard
Main landed v1.4.0.0 with /make-pdf (PR #1086), so this branch bumps to v1.5.0.0 and keeps main's entry intact below. Conflicts resolved: - CHANGELOG.md: both branches used v1.4.0.0 — renumbered this branch to v1.5.0.0, kept main's v1.4.0.0 entry directly below. - test/skill-validation.test.ts: both branches fixed the same set of failing tests. Took main's more conservative assertions (check for "Code paths:" / "User flows:" summary labels instead of the older "CODE PATHS" / "USER FLOWS" header strings). ALLOWED_SUBSTEPS stays the same on both sides. - bun.lock: kept both new deps (matcher from this branch, marked from main's /make-pdf). Verified via bun install. - scripts/resolvers/preamble/generate-preamble-bash.ts: both branches added _EXPLAIN_LEVEL + _QUESTION_TUNING echoes. Kept main's version (which has value validation) and removed the duplicate block my branch added. Regenerated all SKILL.md files. - Golden fixtures refreshed after regen. VERSION: 1.4.0.0 → 1.5.0.0. package.json synced. All tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
name: make-pdf copy-paste gate
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'make-pdf/**'
|
||||
- 'browse/src/meta-commands.ts'
|
||||
- 'browse/src/write-commands.ts'
|
||||
- 'browse/src/commands.ts'
|
||||
- 'browse/src/cli.ts'
|
||||
- 'scripts/resolvers/make-pdf.ts'
|
||||
- 'package.json'
|
||||
- '.github/workflows/make-pdf-gate.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: make-pdf-gate-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
# Windows is tolerant-mode — Xpdf / Poppler-Windows extraction
|
||||
# differs enough from the Linux/macOS baseline that the strict
|
||||
# exact-diff gate is unreliable. Enable once the normalized
|
||||
# comparator proves tolerant enough (Codex round 2 #18).
|
||||
#
|
||||
# include:
|
||||
# - os: windows-latest
|
||||
# tolerant: true
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install poppler (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install poppler
|
||||
|
||||
- name: Install poppler-utils (Ubuntu)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y poppler-utils
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Build binaries
|
||||
run: bun run build
|
||||
|
||||
- name: ad-hoc codesign (Apple Silicon)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
for bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf; do
|
||||
codesign --remove-signature "$bin" 2>/dev/null || true
|
||||
codesign -s - -f "$bin" || true
|
||||
done
|
||||
|
||||
- name: Log toolchain versions
|
||||
run: |
|
||||
echo "OS: ${{ matrix.os }}"
|
||||
bun --version
|
||||
which pdftotext && pdftotext -v 2>&1 | head -1 || true
|
||||
|
||||
- name: Run make-pdf unit tests
|
||||
run: bun test make-pdf/test/*.test.ts
|
||||
|
||||
- name: Run combined-features copy-paste gate (P0)
|
||||
env:
|
||||
BROWSE_BIN: ${{ github.workspace }}/browse/dist/browse
|
||||
run: bun test make-pdf/test/e2e/combined-gate.test.ts
|
||||
@@ -3,6 +3,7 @@ node_modules/
|
||||
dist/
|
||||
browse/dist/
|
||||
design/dist/
|
||||
make-pdf/dist/
|
||||
bin/gstack-global-discover
|
||||
.gstack/
|
||||
.claude/skills/
|
||||
|
||||
+33
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.0.0] - 2026-04-19
|
||||
## [1.5.0.0] - 2026-04-20
|
||||
|
||||
## **Your sidebar agent now defends itself against prompt injection.**
|
||||
|
||||
@@ -60,6 +60,38 @@ Supabase migration `004_attack_telemetry.sql` adds five nullable columns to `tel
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0.0] - 2026-04-20
|
||||
|
||||
## **Turn any markdown file into a PDF that looks finished.**
|
||||
|
||||
The new `/make-pdf` skill takes a `.md` file and produces a publication-quality PDF. 1 inch margins. Helvetica. Page numbers in the footer. Running header with the doc title. Curly quotes, em dashes, ellipsis (…). Optional cover page. Optional clickable table of contents. Optional diagonal DRAFT watermark. Copy any paragraph out of the PDF and paste it into a Google Doc: it pastes as one clean block, not "S a i l i n g" spaced out letter by letter. That last part is the whole game. Most markdown-to-PDF tools produce output that reads like a legal document run through a scanner three times. This one reads like a real essay or a real letter.
|
||||
|
||||
### What you can do now
|
||||
|
||||
- `$P generate letter.md` writes a clean letter PDF to `/tmp/letter.pdf` with sensible defaults.
|
||||
- `$P generate --cover --toc --author "Garry Tan" --title "On Horizons" essay.md essay.pdf` adds a left-aligned cover page (title, subtitle, date, hairline rule) and a TOC from your H1/H2/H3 headings.
|
||||
- `$P generate --watermark DRAFT memo.md draft.pdf` overlays a diagonal DRAFT watermark on every page. Send as draft. Drop the flag when it's final.
|
||||
- `$P generate --no-chapter-breaks memo.md` disables the default "every H1 starts a new page" behavior for memos that happen to have multiple top-level headings.
|
||||
- `$P generate --allow-network essay.md` lets external images load. Off by default so someone else's markdown can't phone home through a tracking pixel when you generate their PDF.
|
||||
- `$P preview essay.md` renders the same HTML and opens it in your browser. Refresh as you edit. Skip the PDF round trip until you're ready.
|
||||
- `$P setup` verifies browse + Chromium + pdftotext are installed and runs an end-to-end smoke test.
|
||||
|
||||
### Why the text actually copies cleanly
|
||||
|
||||
Headless Chromium emits per-glyph `Tj` operators for webfonts with non-standard metrics tables. That's why every other "markdown to PDF" tool produces PDFs where copy-paste turns "Sailing" into "S a i l i n g". We ship with system Helvetica for everything ... Chromium has native metrics for it and emits clean word-level `Tj` operators. The CI matrix runs a combined-features fixture (smartypants + hyphens + ligatures + bold/italic + inline code + lists + blockquote + chapter breaks, all on) through `pdftotext` and asserts the extracted text matches a handwritten expected file. If any feature breaks extraction, the gate fails.
|
||||
|
||||
### Under the hood
|
||||
|
||||
make-pdf shells out to `browse` for Chromium lifecycle. No second Playwright install, no second 58MB binary, no second codesigning dance. `$B pdf` grew from "take a screenshot as A4" into a real PDF engine with `--format`/`--width`/`--height`, `--margins`, `--header-template`/`--footer-template`, `--page-numbers`, `--tagged`, `--outline`, `--toc`, `--tab-id`, and `--from-file` for large payloads (Windows argv caps). `$B load-html` and `$B js` got `--tab-id` too, so parallel `$P generate` calls never race on the active tab. `$B newtab --json` returns structured output so make-pdf can parse the tab ID without regex-matching log strings.
|
||||
|
||||
### For contributors
|
||||
|
||||
- Skill file: `make-pdf/SKILL.md.tmpl`. Binary source: `make-pdf/src/`. Test fixtures: `make-pdf/test/fixtures/`. CI workflow: `.github/workflows/make-pdf-gate.yml`.
|
||||
- New resolver `{{MAKE_PDF_SETUP}}` emits the `$P=` alias with the same discovery order as `$B`: `MAKE_PDF_BIN` env override, then local skill root, then global install, then PATH.
|
||||
- Combined-features copy-paste gate is the P0 test in `make-pdf/test/e2e/combined-gate.test.ts`. Per-feature gates are P1 diagnostics.
|
||||
- Phase 4 deferrals: vendored Paged.js for accurate TOC page numbers, vendored highlight.js for syntax highlighting, drop caps, pull quotes, CMYK safe conversion, two-column layout.
|
||||
- Preamble bash now emits `_EXPLAIN_LEVEL` and `_QUESTION_TUNING` so downstream skills can read them at runtime. Golden-file fixtures updated to match.
|
||||
|
||||
## [1.3.0.0] - 2026-04-19
|
||||
|
||||
## **Your design skills learn your taste.**
|
||||
|
||||
@@ -49,6 +49,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"gstack","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -99,12 +107,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
@@ -796,7 +798,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `back` | History back |
|
||||
| `forward` | History forward |
|
||||
| `goto <url>` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) |
|
||||
| `load-html <file> [--wait-until load|domcontentloaded|networkidle]` | Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner. |
|
||||
| `load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]` | Load HTML via setContent. Accepts a file path under safe-dirs (validated), OR --from-file <payload.json> with {"html":"...","waitUntil":"..."} for large inline HTML (Windows argv safe). |
|
||||
| `reload` | Reload page |
|
||||
| `url` | Print current URL |
|
||||
|
||||
@@ -871,7 +873,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `diff <url1> <url2>` | Text diff between pages |
|
||||
| `pdf [path]` | Save as PDF |
|
||||
| `pdf [path] [--format letter|a4|legal] [--width <dim> --height <dim>] [--margins <dim>] [--margin-top <dim> --margin-right <dim> --margin-bottom <dim> --margin-left <dim>] [--header-template <html>] [--footer-template <html>] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id <N>] | pdf --from-file <payload.json> [--tab-id <N>]` | Save the current page as PDF. Supports page layout (--format, --width, --height, --margins, --margin-*), structure (--toc waits for Paged.js), branding (--header-template, --footer-template, --page-numbers), accessibility (--tagged, --outline), and --from-file <payload.json> for large payloads. Use --tab-id <N> to target a specific tab. |
|
||||
| `prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]` | Clean screenshot with optional cleanup, scroll positioning, and element hiding |
|
||||
| `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. |
|
||||
| `screenshot [--selector <css>] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]` | Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work. |
|
||||
@@ -893,7 +895,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `closetab [id]` | Close tab |
|
||||
| `newtab [url]` | Open new tab |
|
||||
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
||||
| `tab <id>` | Switch to tab |
|
||||
| `tabs` | List open tabs |
|
||||
|
||||
|
||||
+8
-6
@@ -58,6 +58,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"autoplan","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -108,12 +116,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -51,6 +51,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"benchmark-models","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -101,12 +109,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -51,6 +51,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"benchmark","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -101,12 +109,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+11
-9
@@ -50,6 +50,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -100,12 +108,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
@@ -738,7 +740,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| `back` | History back |
|
||||
| `forward` | History forward |
|
||||
| `goto <url>` | Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) |
|
||||
| `load-html <file> [--wait-until load|domcontentloaded|networkidle]` | Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner. |
|
||||
| `load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]` | Load HTML via setContent. Accepts a file path under safe-dirs (validated), OR --from-file <payload.json> with {"html":"...","waitUntil":"..."} for large inline HTML (Windows argv safe). |
|
||||
| `reload` | Reload page |
|
||||
| `url` | Print current URL |
|
||||
|
||||
@@ -813,7 +815,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `diff <url1> <url2>` | Text diff between pages |
|
||||
| `pdf [path]` | Save as PDF |
|
||||
| `pdf [path] [--format letter|a4|legal] [--width <dim> --height <dim>] [--margins <dim>] [--margin-top <dim> --margin-right <dim> --margin-bottom <dim> --margin-left <dim>] [--header-template <html>] [--footer-template <html>] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id <N>] | pdf --from-file <payload.json> [--tab-id <N>]` | Save the current page as PDF. Supports page layout (--format, --width, --height, --margins, --margin-*), structure (--toc waits for Paged.js), branding (--header-template, --footer-template, --page-numbers), accessibility (--tagged, --outline), and --from-file <payload.json> for large payloads. Use --tab-id <N> to target a specific tab. |
|
||||
| `prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]` | Clean screenshot with optional cleanup, scroll positioning, and element hiding |
|
||||
| `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. |
|
||||
| `screenshot [--selector <css>] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]` | Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work. |
|
||||
@@ -835,7 +837,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `closetab [id]` | Close tab |
|
||||
| `newtab [url]` | Open new tab |
|
||||
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
||||
| `tab <id>` | Switch to tab |
|
||||
| `tabs` | List open tabs |
|
||||
|
||||
|
||||
+30
-3
@@ -375,11 +375,38 @@ async function ensureServer(): Promise<ServerState> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `--tab-id <N>` from args and return { tabId, args } with the flag stripped.
|
||||
* Used by make-pdf's tab-scoped flow: every browse command (newtab, load-html, js,
|
||||
* pdf, closetab) can take `--tab-id <N>` to target a specific tab. Without this,
|
||||
* parallel `$P generate` calls would race on the active tab.
|
||||
*/
|
||||
export function extractTabId(args: string[]): { tabId: number | undefined; args: string[] } {
|
||||
const stripped: string[] = [];
|
||||
let tabId: number | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--tab-id') {
|
||||
const next = args[++i];
|
||||
if (next === undefined) continue;
|
||||
const parsed = parseInt(next, 10);
|
||||
if (!isNaN(parsed)) tabId = parsed;
|
||||
} else {
|
||||
stripped.push(args[i]);
|
||||
}
|
||||
}
|
||||
return { tabId, args: stripped };
|
||||
}
|
||||
|
||||
// ─── Command Dispatch ──────────────────────────────────────────
|
||||
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
// BROWSE_TAB env var pins commands to a specific tab (set by sidebar-agent per-tab)
|
||||
const browseTab = process.env.BROWSE_TAB;
|
||||
const body = JSON.stringify({ command, args, ...(browseTab ? { tabId: parseInt(browseTab, 10) } : {}) });
|
||||
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
|
||||
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
|
||||
// (set by sidebar-agent per-tab) or the active tab.
|
||||
const extracted = extractTabId(args);
|
||||
args = extracted.args;
|
||||
const envTab = process.env.BROWSE_TAB;
|
||||
const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined);
|
||||
const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
||||
|
||||
@@ -71,7 +71,7 @@ export function wrapUntrustedContent(result: string, url: string): string {
|
||||
export const COMMAND_DESCRIPTIONS: Record<string, { category: string; description: string; usage?: string }> = {
|
||||
// Navigation
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto <url>' },
|
||||
'load-html': { category: 'Navigation', description: 'Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner.', usage: 'load-html <file> [--wait-until load|domcontentloaded|networkidle]' },
|
||||
'load-html': { category: 'Navigation', description: 'Load HTML via setContent. Accepts a file path under safe-dirs (validated), OR --from-file <payload.json> with {"html":"...","waitUntil":"..."} for large inline HTML (Windows argv safe).', usage: 'load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]' },
|
||||
'back': { category: 'Navigation', description: 'History back' },
|
||||
'forward': { category: 'Navigation', description: 'History forward' },
|
||||
'reload': { category: 'Navigation', description: 'Reload page' },
|
||||
@@ -120,13 +120,13 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'archive': { category: 'Extraction', description: 'Save complete page as MHTML via CDP', usage: 'archive [path]' },
|
||||
// Visual
|
||||
'screenshot': { category: 'Visual', description: 'Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work.', usage: 'screenshot [--selector <css>] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]' },
|
||||
'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' },
|
||||
'pdf': { category: 'Visual', description: 'Save the current page as PDF. Supports page layout (--format, --width, --height, --margins, --margin-*), structure (--toc waits for Paged.js), branding (--header-template, --footer-template, --page-numbers), accessibility (--tagged, --outline), and --from-file <payload.json> for large payloads. Use --tab-id <N> to target a specific tab.', usage: 'pdf [path] [--format letter|a4|legal] [--width <dim> --height <dim>] [--margins <dim>] [--margin-top <dim> --margin-right <dim> --margin-bottom <dim> --margin-left <dim>] [--header-template <html>] [--footer-template <html>] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id <N>] | pdf --from-file <payload.json> [--tab-id <N>]' },
|
||||
'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' },
|
||||
'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff <url1> <url2>' },
|
||||
// Tabs
|
||||
'tabs': { category: 'Tabs', description: 'List open tabs' },
|
||||
'tab': { category: 'Tabs', description: 'Switch to tab', usage: 'tab <id>' },
|
||||
'newtab': { category: 'Tabs', description: 'Open new tab', usage: 'newtab [url]' },
|
||||
'newtab': { category: 'Tabs', description: 'Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf).', usage: 'newtab [url] [--json]' },
|
||||
'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' },
|
||||
// Server
|
||||
'status': { category: 'Server', description: 'Health check' },
|
||||
|
||||
+218
-5
@@ -37,6 +37,187 @@ function tokenizePipeSegment(segment: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ─── PDF flag parsing (make-pdf contract) ─────────────────────────────
|
||||
//
|
||||
// The $B pdf command grew from a 2-line wrapper (format: 'A4') into a real
|
||||
// PDF engine frontend. make-pdf/dist/pdf shells out to `browse pdf` with
|
||||
// this flag set, so the contract here has to be stable.
|
||||
//
|
||||
// Mutex rules enforced:
|
||||
// --format vs --width/--height
|
||||
// --margins vs any --margin-*
|
||||
// --page-numbers vs --footer-template (page-numbers writes the footer itself)
|
||||
//
|
||||
// Units for dimensions: "1in" | "72pt" | "25mm" | "2.54cm". Bare numbers
|
||||
// are interpreted as pixels (Playwright's default), which is almost never
|
||||
// what callers want — we warn but don't reject.
|
||||
//
|
||||
// Large payloads: header/footer HTML and custom CSS can exceed Windows'
|
||||
// 8191-char CreateProcess cap via argv. Callers pass `--from-file <path>`
|
||||
// to a JSON file holding the full options. make-pdf always uses this path.
|
||||
interface ParsedPdfArgs {
|
||||
output: string;
|
||||
format?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
marginTop?: string;
|
||||
marginRight?: string;
|
||||
marginBottom?: string;
|
||||
marginLeft?: string;
|
||||
headerTemplate?: string;
|
||||
footerTemplate?: string;
|
||||
pageNumbers?: boolean;
|
||||
tagged?: boolean;
|
||||
outline?: boolean;
|
||||
printBackground?: boolean;
|
||||
preferCSSPageSize?: boolean;
|
||||
toc?: boolean;
|
||||
}
|
||||
|
||||
function parsePdfArgs(args: string[]): ParsedPdfArgs {
|
||||
// --from-file short-circuits argv parsing entirely
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--from-file') {
|
||||
const payloadPath = args[++i];
|
||||
if (!payloadPath) throw new Error('pdf: --from-file requires a path');
|
||||
return parsePdfFromFile(payloadPath);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ParsedPdfArgs = {
|
||||
output: `${TEMP_DIR}/browse-page.pdf`,
|
||||
};
|
||||
|
||||
let margins: string | undefined;
|
||||
const positional: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--format') { result.format = requireValue(args, ++i, 'format'); }
|
||||
else if (a === '--page-size') { result.format = requireValue(args, ++i, 'page-size'); }
|
||||
else if (a === '--width') { result.width = requireValue(args, ++i, 'width'); }
|
||||
else if (a === '--height') { result.height = requireValue(args, ++i, 'height'); }
|
||||
else if (a === '--margins') { margins = requireValue(args, ++i, 'margins'); }
|
||||
else if (a === '--margin-top') { result.marginTop = requireValue(args, ++i, 'margin-top'); }
|
||||
else if (a === '--margin-right') { result.marginRight = requireValue(args, ++i, 'margin-right'); }
|
||||
else if (a === '--margin-bottom') { result.marginBottom = requireValue(args, ++i, 'margin-bottom'); }
|
||||
else if (a === '--margin-left') { result.marginLeft = requireValue(args, ++i, 'margin-left'); }
|
||||
else if (a === '--header-template') { result.headerTemplate = requireValue(args, ++i, 'header-template'); }
|
||||
else if (a === '--footer-template') { result.footerTemplate = requireValue(args, ++i, 'footer-template'); }
|
||||
else if (a === '--page-numbers') { result.pageNumbers = true; }
|
||||
else if (a === '--tagged') { result.tagged = true; }
|
||||
else if (a === '--outline') { result.outline = true; }
|
||||
else if (a === '--print-background') { result.printBackground = true; }
|
||||
else if (a === '--prefer-css-page-size') { result.preferCSSPageSize = true; }
|
||||
else if (a === '--toc') { result.toc = true; }
|
||||
else if (a.startsWith('--')) { throw new Error(`Unknown pdf flag: ${a}`); }
|
||||
else { positional.push(a); }
|
||||
}
|
||||
|
||||
if (positional.length > 0) result.output = positional[0];
|
||||
|
||||
if (margins !== undefined) {
|
||||
if (result.marginTop || result.marginRight || result.marginBottom || result.marginLeft) {
|
||||
throw new Error('pdf: --margins is mutex with --margin-top/--margin-right/--margin-bottom/--margin-left');
|
||||
}
|
||||
result.marginTop = result.marginRight = result.marginBottom = result.marginLeft = margins;
|
||||
}
|
||||
|
||||
if (result.format && (result.width || result.height)) {
|
||||
throw new Error('pdf: --format is mutex with --width/--height');
|
||||
}
|
||||
if (result.pageNumbers && result.footerTemplate) {
|
||||
throw new Error('pdf: --page-numbers is mutex with --footer-template (page-numbers writes the footer itself)');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
|
||||
const raw = fs.readFileSync(payloadPath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
const out: ParsedPdfArgs = {
|
||||
output: json.output || `${TEMP_DIR}/browse-page.pdf`,
|
||||
format: json.format,
|
||||
width: json.width,
|
||||
height: json.height,
|
||||
marginTop: json.marginTop,
|
||||
marginRight: json.marginRight,
|
||||
marginBottom: json.marginBottom,
|
||||
marginLeft: json.marginLeft,
|
||||
headerTemplate: json.headerTemplate,
|
||||
footerTemplate: json.footerTemplate,
|
||||
pageNumbers: json.pageNumbers === true,
|
||||
tagged: json.tagged === true,
|
||||
outline: json.outline === true,
|
||||
printBackground: json.printBackground === true,
|
||||
preferCSSPageSize: json.preferCSSPageSize === true,
|
||||
toc: json.toc === true,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
function requireValue(args: string[], i: number, flag: string): string {
|
||||
const v = args[i];
|
||||
if (v === undefined || v.startsWith('--')) {
|
||||
throw new Error(`pdf: --${flag} requires a value`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function buildPdfOptions(parsed: ParsedPdfArgs): Record<string, unknown> {
|
||||
const opts: Record<string, unknown> = {};
|
||||
|
||||
// Page size
|
||||
if (parsed.format) {
|
||||
opts.format = parsed.format.charAt(0).toUpperCase() + parsed.format.slice(1).toLowerCase();
|
||||
} else if (parsed.width && parsed.height) {
|
||||
opts.width = parsed.width;
|
||||
opts.height = parsed.height;
|
||||
} else {
|
||||
opts.format = 'Letter';
|
||||
}
|
||||
|
||||
// Margins
|
||||
const margin: Record<string, string> = {};
|
||||
if (parsed.marginTop) margin.top = parsed.marginTop;
|
||||
if (parsed.marginRight) margin.right = parsed.marginRight;
|
||||
if (parsed.marginBottom) margin.bottom = parsed.marginBottom;
|
||||
if (parsed.marginLeft) margin.left = parsed.marginLeft;
|
||||
if (Object.keys(margin).length > 0) opts.margin = margin;
|
||||
|
||||
// Header/footer
|
||||
const displayHeaderFooter =
|
||||
!!parsed.headerTemplate || !!parsed.footerTemplate || parsed.pageNumbers === true;
|
||||
if (displayHeaderFooter) {
|
||||
opts.displayHeaderFooter = true;
|
||||
// Provide minimum empty templates when only one is set, otherwise Chromium
|
||||
// emits its default ugly URL/date in the other slot.
|
||||
if (parsed.headerTemplate !== undefined) opts.headerTemplate = parsed.headerTemplate;
|
||||
else if (parsed.pageNumbers || parsed.footerTemplate) opts.headerTemplate = '<div></div>';
|
||||
|
||||
if (parsed.pageNumbers) {
|
||||
opts.footerTemplate = [
|
||||
'<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; ',
|
||||
'width:100%; text-align:center;">',
|
||||
'<span class="pageNumber"></span> of <span class="totalPages"></span>',
|
||||
'</div>',
|
||||
].join('');
|
||||
} else if (parsed.footerTemplate !== undefined) {
|
||||
opts.footerTemplate = parsed.footerTemplate;
|
||||
} else {
|
||||
opts.footerTemplate = '<div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.tagged === true) opts.tagged = true;
|
||||
if (parsed.outline === true) opts.outline = true;
|
||||
if (parsed.printBackground === true) opts.printBackground = true;
|
||||
if (parsed.preferCSSPageSize === true) opts.preferCSSPageSize = true;
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** Options passed from handleCommandInternal for chain routing */
|
||||
export interface MetaCommandOpts {
|
||||
chainDepth?: number;
|
||||
@@ -72,8 +253,18 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
case 'newtab': {
|
||||
const url = args[0];
|
||||
// --json returns structured output (machine-parseable). Other flag-like
|
||||
// tokens are treated as the url. make-pdf always passes --json.
|
||||
let url: string | undefined;
|
||||
let jsonMode = false;
|
||||
for (const a of args) {
|
||||
if (a === '--json') { jsonMode = true; }
|
||||
else if (!url) { url = a; }
|
||||
}
|
||||
const id = await bm.newTab(url);
|
||||
if (jsonMode) {
|
||||
return JSON.stringify({ tabId: id, url: url ?? null });
|
||||
}
|
||||
return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -213,10 +404,32 @@ export async function handleMetaCommand(
|
||||
|
||||
case 'pdf': {
|
||||
const page = bm.getPage();
|
||||
const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
|
||||
validateOutputPath(pdfPath);
|
||||
await page.pdf({ path: pdfPath, format: 'A4' });
|
||||
return `PDF saved: ${pdfPath}`;
|
||||
const parsed = parsePdfArgs(args);
|
||||
validateOutputPath(parsed.output);
|
||||
|
||||
// If --toc: wait up to 3s for Paged.js to signal by setting
|
||||
// window.__pagedjsAfterFired = true. If the polyfill isn't injected
|
||||
// (make-pdf v1 ships without Paged.js; TOC renders without page
|
||||
// numbers), we fall through silently — callers that require strict
|
||||
// TOC pagination should pass --require-paged-js too.
|
||||
if (parsed.toc) {
|
||||
const deadline = Date.now() + 3000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
ready = await page.evaluate('!!window.__pagedjsAfterFired');
|
||||
} catch { /* tab may still be hydrating */ }
|
||||
if (ready) break;
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
// Intentionally non-fatal. Paged.js is optional in v1.
|
||||
}
|
||||
|
||||
const opts = buildPdfOptions(parsed);
|
||||
opts.path = parsed.output;
|
||||
await page.pdf(opts);
|
||||
|
||||
return `PDF saved: ${parsed.output}`;
|
||||
}
|
||||
|
||||
case 'responsive': {
|
||||
|
||||
@@ -175,13 +175,32 @@ export async function handleWriteCommand(
|
||||
|
||||
case 'load-html': {
|
||||
if (inFrame) throw new Error('Cannot use load-html inside a frame. Run \'frame main\' first.');
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse load-html <file> [--wait-until load|domcontentloaded|networkidle]');
|
||||
|
||||
// Parse --wait-until flag
|
||||
// --from-file <path.json>: read inline HTML from a JSON payload. Used by
|
||||
// make-pdf to dodge Windows argv size limits on large rendered HTML.
|
||||
// The JSON shape is { html: string, waitUntil?: "load"|"domcontentloaded"|"networkidle" }.
|
||||
// The safe-dirs + magic-byte + size-cap checks below still apply to the
|
||||
// INLINE HTML content, not to the payload file path itself.
|
||||
let fromFilePayload: { html: string; waitUntil?: SetContentWaitUntil } | null = null;
|
||||
let filePath: string | undefined;
|
||||
let waitUntil: SetContentWaitUntil = 'domcontentloaded';
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--wait-until') {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--from-file') {
|
||||
const payloadPath = args[++i];
|
||||
if (!payloadPath) throw new Error('load-html: --from-file requires a path');
|
||||
const raw = fs.readFileSync(payloadPath, 'utf8');
|
||||
let json: any;
|
||||
try { json = JSON.parse(raw); }
|
||||
catch (e: any) { throw new Error(`load-html: --from-file JSON parse failed: ${e.message}`); }
|
||||
if (typeof json.html !== 'string') {
|
||||
throw new Error('load-html: --from-file JSON must have a "html" string field');
|
||||
}
|
||||
if (json.waitUntil && json.waitUntil !== 'load'
|
||||
&& json.waitUntil !== 'domcontentloaded' && json.waitUntil !== 'networkidle') {
|
||||
throw new Error(`load-html: --from-file waitUntil '${json.waitUntil}' invalid`);
|
||||
}
|
||||
fromFilePayload = { html: json.html, waitUntil: json.waitUntil };
|
||||
} else if (args[i] === '--wait-until') {
|
||||
const val = args[++i];
|
||||
if (val !== 'load' && val !== 'domcontentloaded' && val !== 'networkidle') {
|
||||
throw new Error(`Invalid --wait-until '${val}'. Must be one of: load, domcontentloaded, networkidle.`);
|
||||
@@ -189,9 +208,31 @@ export async function handleWriteCommand(
|
||||
waitUntil = val;
|
||||
} else if (args[i].startsWith('--')) {
|
||||
throw new Error(`Unknown flag: ${args[i]}`);
|
||||
} else if (!filePath) {
|
||||
filePath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Inline HTML path: validate size + magic byte, then setContent directly.
|
||||
if (fromFilePayload) {
|
||||
const MAX_BYTES = parseInt(process.env.GSTACK_BROWSE_MAX_HTML_BYTES || '', 10) || (50 * 1024 * 1024);
|
||||
if (Buffer.byteLength(fromFilePayload.html, 'utf8') > MAX_BYTES) {
|
||||
throw new Error(
|
||||
`load-html: --from-file html too large (> ${MAX_BYTES} bytes). ` +
|
||||
'Raise with GSTACK_BROWSE_MAX_HTML_BYTES=<N>.'
|
||||
);
|
||||
}
|
||||
const peek = fromFilePayload.html.trimStart();
|
||||
if (!/^<[a-zA-Z!?]/.test(peek)) {
|
||||
throw new Error('load-html: --from-file html does not start with a valid markup opener');
|
||||
}
|
||||
const finalWaitUntil = fromFilePayload.waitUntil ?? waitUntil;
|
||||
await session.setTabContent(fromFilePayload.html, { waitUntil: finalWaitUntil });
|
||||
return `Loaded HTML: (inline from --from-file, ${fromFilePayload.html.length} chars)`;
|
||||
}
|
||||
|
||||
if (!filePath) throw new Error('Usage: browse load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]');
|
||||
|
||||
// Extension allowlist
|
||||
const ALLOWED_EXT = ['.html', '.htm', '.xhtml', '.svg'];
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* $B pdf flag contract tests.
|
||||
*
|
||||
* Pure unit tests of the parsing/validation logic. These do NOT spin up
|
||||
* Chromium — that's covered by make-pdf's integration tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
import { extractTabId } from "../src/cli";
|
||||
|
||||
// We can't import the internal parsePdfArgs directly without exporting it,
|
||||
// but we can exercise it end-to-end through the browse CLI. For fast unit
|
||||
// coverage we test the flag-extraction layer here.
|
||||
|
||||
describe("extractTabId", () => {
|
||||
test("strips --tab-id and returns the value", () => {
|
||||
const { tabId, args } = extractTabId(["--tab-id", "3", "extra"]);
|
||||
expect(tabId).toBe(3);
|
||||
expect(args).toEqual(["extra"]);
|
||||
});
|
||||
|
||||
test("returns undefined when flag is absent", () => {
|
||||
const { tabId, args } = extractTabId(["goto", "https://example.com"]);
|
||||
expect(tabId).toBeUndefined();
|
||||
expect(args).toEqual(["goto", "https://example.com"]);
|
||||
});
|
||||
|
||||
test("ignores trailing --tab-id with no value", () => {
|
||||
const { tabId, args } = extractTabId(["click", "@e1", "--tab-id"]);
|
||||
expect(tabId).toBeUndefined();
|
||||
expect(args).toEqual(["click", "@e1"]);
|
||||
});
|
||||
|
||||
test("handles --tab-id at different positions", () => {
|
||||
const front = extractTabId(["--tab-id", "5", "pdf", "/tmp/out.pdf"]);
|
||||
expect(front.tabId).toBe(5);
|
||||
expect(front.args).toEqual(["pdf", "/tmp/out.pdf"]);
|
||||
|
||||
const middle = extractTabId(["pdf", "--tab-id", "7", "/tmp/out.pdf"]);
|
||||
expect(middle.tabId).toBe(7);
|
||||
expect(middle.args).toEqual(["pdf", "/tmp/out.pdf"]);
|
||||
|
||||
const end = extractTabId(["pdf", "/tmp/out.pdf", "--tab-id", "9"]);
|
||||
expect(end.tabId).toBe(9);
|
||||
expect(end.args).toEqual(["pdf", "/tmp/out.pdf"]);
|
||||
});
|
||||
|
||||
test("ignores non-numeric --tab-id values", () => {
|
||||
const { tabId, args } = extractTabId(["--tab-id", "abc", "pdf"]);
|
||||
expect(tabId).toBeUndefined();
|
||||
expect(args).toEqual(["pdf"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pdf --from-file payload shape", () => {
|
||||
test("writes a JSON payload file and reads it back", () => {
|
||||
const tmpPath = path.join(os.tmpdir(), `browse-pdf-test-${Date.now()}.json`);
|
||||
const payload = {
|
||||
output: "/tmp/browse-out.pdf",
|
||||
format: "letter",
|
||||
marginTop: "1in",
|
||||
marginRight: "1in",
|
||||
marginBottom: "1in",
|
||||
marginLeft: "1in",
|
||||
pageNumbers: true,
|
||||
tagged: true,
|
||||
outline: true,
|
||||
toc: false,
|
||||
headerTemplate: '<div style="font-size:9pt">Title</div>',
|
||||
footerTemplate: undefined,
|
||||
};
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(payload));
|
||||
try {
|
||||
const readBack = JSON.parse(fs.readFileSync(tmpPath, "utf8"));
|
||||
expect(readBack.output).toBe("/tmp/browse-out.pdf");
|
||||
expect(readBack.pageNumbers).toBe(true);
|
||||
expect(readBack.headerTemplate).toContain("Title");
|
||||
} finally {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -501,8 +501,12 @@ describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => {
|
||||
});
|
||||
|
||||
test('CLI reads BROWSE_TAB and sends tabId in command body', () => {
|
||||
// BROWSE_TAB env var is still honored (sidebar-agent path). After the
|
||||
// make-pdf refactor, the CLI layer now also accepts --tab-id <N>, with
|
||||
// the CLI flag taking precedence over the env var. Both resolve to the
|
||||
// same `tabId` body field.
|
||||
expect(cliSrc).toContain('process.env.BROWSE_TAB');
|
||||
expect(cliSrc).toContain('tabId: parseInt(browseTab');
|
||||
expect(cliSrc).toContain('parseInt(envTab, 10)');
|
||||
});
|
||||
|
||||
test('handleCommandInternal accepts tabId from request body', () => {
|
||||
@@ -548,8 +552,11 @@ describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => {
|
||||
expect(handleFn).toContain('tabId !== null');
|
||||
});
|
||||
|
||||
test('CLI only sends tabId when BROWSE_TAB is set', () => {
|
||||
// Should conditionally include tabId in the body
|
||||
expect(cliSrc).toContain('browseTab ? { tabId:');
|
||||
test('CLI only sends tabId when it is a valid number', () => {
|
||||
// Body should conditionally include tabId. Historically that was keyed off
|
||||
// the BROWSE_TAB env var. After the make-pdf refactor, the CLI also honors
|
||||
// a --tab-id <N> flag on the CLI itself, so the check is "tabId defined
|
||||
// AND not NaN" rather than literally inspecting the env var.
|
||||
expect(cliSrc).toContain('tabId !== undefined && !isNaN(tabId)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"diff": "^7.0.0",
|
||||
"marked": "^18.0.2",
|
||||
"playwright": "^1.58.2",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
},
|
||||
@@ -257,6 +258,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"marked": ["marked@18.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
+8
-6
@@ -50,6 +50,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"canary","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -100,12 +108,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -52,6 +52,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"codex","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -102,12 +110,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -54,6 +54,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"context-restore","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -104,12 +112,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -54,6 +54,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"context-save","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -104,12 +112,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"cso","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"design-consultation","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -57,6 +57,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"design-html","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -107,12 +115,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -52,6 +52,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"design-shotgun","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -102,12 +110,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -52,6 +52,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"document-release","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -102,12 +110,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -52,6 +52,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"health","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -102,12 +110,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -69,6 +69,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"investigate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -119,12 +127,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -49,6 +49,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"land-and-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -99,12 +107,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -52,6 +52,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"learn","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -102,12 +110,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: make-pdf
|
||||
preamble-tier: 1
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Turn any markdown file into a publication-quality PDF. Proper 1in margins,
|
||||
intelligent page breaks, page numbers, cover pages, running headers, curly
|
||||
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Output you'd
|
||||
send to a VC partner, a book agent, a judge, or Rick Rubin's team. Not a
|
||||
draft artifact — a finished artifact. Use when asked to "make a PDF",
|
||||
"export to PDF", "turn this markdown into a PDF", or "generate a document".
|
||||
(gstack)
|
||||
Voice triggers (speech-to-text aliases): "make this a pdf", "make it a pdf", "export to pdf", "turn this into a pdf", "turn this markdown into a pdf", "generate a pdf", "make a pdf from", "pdf this markdown".
|
||||
triggers:
|
||||
- markdown to pdf
|
||||
- generate pdf
|
||||
- make pdf
|
||||
- export pdf
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- AskUserQuestion
|
||||
---
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
|
||||
[ -n "$_UPD" ] && echo "$_UPD" || true
|
||||
mkdir -p ~/.gstack/sessions
|
||||
touch ~/.gstack/sessions/"$PPID"
|
||||
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true")
|
||||
_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no")
|
||||
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
echo "BRANCH: $_BRANCH"
|
||||
_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false")
|
||||
echo "PROACTIVE: $_PROACTIVE"
|
||||
echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED"
|
||||
echo "SKILL_PREFIX: $_SKILL_PREFIX"
|
||||
source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true
|
||||
REPO_MODE=${REPO_MODE:-unknown}
|
||||
echo "REPO_MODE: $REPO_MODE"
|
||||
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
|
||||
echo "LAKE_INTRO: $_LAKE_SEEN"
|
||||
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true)
|
||||
_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no")
|
||||
_TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"make-pdf","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
# zsh-compatible: use find instead of glob to avoid NOMATCH error
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||
if [ -f "$_PF" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$_PF" 2>/dev/null || true
|
||||
fi
|
||||
break
|
||||
done
|
||||
# Learnings count
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl"
|
||||
if [ -f "$_LEARN_FILE" ]; then
|
||||
_LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ')
|
||||
echo "LEARNINGS: $_LEARN_COUNT entries loaded"
|
||||
if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "LEARNINGS: 0"
|
||||
fi
|
||||
# Session timeline: record skill start (local-only, never sent anywhere)
|
||||
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"make-pdf","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
|
||||
# Check if CLAUDE.md has routing rules
|
||||
_HAS_ROUTING="no"
|
||||
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
|
||||
_HAS_ROUTING="yes"
|
||||
fi
|
||||
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
|
||||
echo "HAS_ROUTING: $_HAS_ROUTING"
|
||||
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
|
||||
# Vendoring deprecation: detect if CWD has a vendored gstack copy
|
||||
_VENDORED="no"
|
||||
if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
|
||||
if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then
|
||||
_VENDORED="yes"
|
||||
fi
|
||||
fi
|
||||
echo "VENDORED_GSTACK: $_VENDORED"
|
||||
echo "MODEL_OVERLAY: claude"
|
||||
# Checkpoint mode (explicit = no auto-commit, continuous = WIP commits as you go)
|
||||
_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit")
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not
|
||||
auto-invoke skills based on conversation context. Only run skills the user explicitly
|
||||
types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say:
|
||||
"I think /skillname might help here — want me to run it?" and wait for confirmation.
|
||||
The user opted out of proactive behavior.
|
||||
|
||||
If `SKILL_PREFIX` is `"true"`, the user has namespaced skill names. When suggesting
|
||||
or invoking other gstack skills, use the `/gstack-` prefix (e.g., `/gstack-qa` instead
|
||||
of `/qa`, `/gstack-ship` instead of `/ship`). Disk paths are unaffected — always use
|
||||
`~/.claude/skills/gstack/[skill-name]/SKILL.md` for reading skill files.
|
||||
|
||||
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined).
|
||||
|
||||
If output shows `JUST_UPGRADED <from> <to>` AND `SPAWNED_SESSION` is NOT set: tell
|
||||
the user "Running gstack v{to} (just updated!)" and then check for new features to
|
||||
surface. For each per-feature marker below, if the marker file is missing AND the
|
||||
feature is plausibly useful for this user, use AskUserQuestion to let them try it.
|
||||
Fire once per feature per user, NOT once per upgrade.
|
||||
|
||||
**In spawned sessions (`SPAWNED_SESSION` = "true"): SKIP feature discovery entirely.**
|
||||
Just print "Running gstack v{to}" and continue. Orchestrators do not want interactive
|
||||
prompts from sub-sessions.
|
||||
|
||||
**Feature discovery markers and prompts** (one at a time, max one per session):
|
||||
|
||||
1. `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint` →
|
||||
Prompt: "Continuous checkpoint auto-commits your work as you go with `WIP:` prefix
|
||||
so you never lose progress to a crash. Local-only by default — doesn't push
|
||||
anywhere unless you turn that on. Want to try it?"
|
||||
Options: A) Enable continuous mode, B) Show me first (print the section from
|
||||
the preamble Continuous Checkpoint Mode), C) Skip.
|
||||
If A: run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`.
|
||||
Always: `touch ~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`
|
||||
|
||||
2. `~/.claude/skills/gstack/.feature-prompted-model-overlay` →
|
||||
Inform only (no prompt): "Model overlays are active. `MODEL_OVERLAY: {model}`
|
||||
shown in the preamble output tells you which behavioral patch is applied.
|
||||
Override with `--model` when regenerating skills (e.g., `bun run gen:skill-docs
|
||||
--model gpt-5.4`). Default is claude."
|
||||
Always: `touch ~/.claude/skills/gstack/.feature-prompted-model-overlay`
|
||||
|
||||
After handling JUST_UPGRADED (prompts done or skipped), continue with the skill
|
||||
workflow.
|
||||
|
||||
If `WRITING_STYLE_PENDING` is `yes`: You're on the first skill run after upgrading
|
||||
to gstack v1. Ask the user once about the new default writing style. Use AskUserQuestion:
|
||||
|
||||
> v1 prompts = simpler. Technical terms get a one-sentence gloss on first use,
|
||||
> questions are framed in outcome terms, sentences are shorter.
|
||||
>
|
||||
> Keep the new default, or prefer the older tighter prose?
|
||||
|
||||
Options:
|
||||
- A) Keep the new default (recommended — good writing helps everyone)
|
||||
- B) Restore V0 prose — set `explain_level: terse`
|
||||
|
||||
If A: leave `explain_level` unset (defaults to `default`).
|
||||
If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`.
|
||||
|
||||
Always run (regardless of choice):
|
||||
```bash
|
||||
rm -f ~/.gstack/.writing-style-prompt-pending
|
||||
touch ~/.gstack/.writing-style-prompted
|
||||
```
|
||||
|
||||
This only happens once. If `WRITING_STYLE_PENDING` is `no`, skip this entirely.
|
||||
|
||||
If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle.
|
||||
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
|
||||
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
|
||||
Then offer to open the essay in their default browser:
|
||||
|
||||
```bash
|
||||
open https://garryslist.org/posts/boil-the-ocean
|
||||
touch ~/.gstack/.completeness-intro-seen
|
||||
```
|
||||
|
||||
Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once.
|
||||
|
||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled,
|
||||
ask the user about telemetry. Use AskUserQuestion:
|
||||
|
||||
> Help gstack get better! Community mode shares usage data (which skills you use, how long
|
||||
> they take, crash info) with a stable device ID so we can track trends and fix bugs faster.
|
||||
> No code, file paths, or repo names are ever sent.
|
||||
> Change anytime with `gstack-config set telemetry off`.
|
||||
|
||||
Options:
|
||||
- A) Help gstack get better! (recommended)
|
||||
- B) No thanks
|
||||
|
||||
If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community`
|
||||
|
||||
If B: ask a follow-up AskUserQuestion:
|
||||
|
||||
> How about anonymous mode? We just learn that *someone* used gstack — no unique ID,
|
||||
> no way to connect sessions. Just a counter that helps us know if anyone's out there.
|
||||
|
||||
Options:
|
||||
- A) Sure, anonymous is fine
|
||||
- B) No thanks, fully off
|
||||
|
||||
If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous`
|
||||
If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off`
|
||||
|
||||
Always run:
|
||||
```bash
|
||||
touch ~/.gstack/.telemetry-prompted
|
||||
```
|
||||
|
||||
This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely.
|
||||
|
||||
If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled,
|
||||
ask the user about proactive behavior. Use AskUserQuestion:
|
||||
|
||||
> gstack can proactively figure out when you might need a skill while you work —
|
||||
> like suggesting /qa when you say "does this work?" or /investigate when you hit
|
||||
> a bug. We recommend keeping this on — it speeds up every part of your workflow.
|
||||
|
||||
Options:
|
||||
- A) Keep it on (recommended)
|
||||
- B) Turn it off — I'll type /commands myself
|
||||
|
||||
If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true`
|
||||
If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false`
|
||||
|
||||
Always run:
|
||||
```bash
|
||||
touch ~/.gstack/.proactive-prompted
|
||||
```
|
||||
|
||||
This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely.
|
||||
|
||||
If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`:
|
||||
Check if a CLAUDE.md file exists in the project root. If it does not exist, create it.
|
||||
|
||||
Use AskUserQuestion:
|
||||
|
||||
> gstack works best when your project's CLAUDE.md includes skill routing rules.
|
||||
> This tells Claude to use specialized workflows (like /ship, /investigate, /qa)
|
||||
> instead of answering directly. It's a one-time addition, about 15 lines.
|
||||
|
||||
Options:
|
||||
- A) Add routing rules to CLAUDE.md (recommended)
|
||||
- B) No thanks, I'll invoke skills manually
|
||||
|
||||
If A: Append this section to the end of CLAUDE.md:
|
||||
|
||||
```markdown
|
||||
|
||||
## Skill routing
|
||||
|
||||
When the user's request matches an available skill, ALWAYS invoke it using the Skill
|
||||
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
|
||||
The skill has specialized workflows that produce better results than ad-hoc answers.
|
||||
|
||||
Key routing rules:
|
||||
- Product ideas, "is this worth building", brainstorming → invoke office-hours
|
||||
- Bugs, errors, "why is this broken", 500 errors → invoke investigate
|
||||
- Ship, deploy, push, create PR → invoke ship
|
||||
- QA, test the site, find bugs → invoke qa
|
||||
- Code review, check my diff → invoke review
|
||||
- Update docs after shipping → invoke document-release
|
||||
- Weekly retro → invoke retro
|
||||
- Design system, brand → invoke design-consultation
|
||||
- Visual audit, design polish → invoke design-review
|
||||
- Architecture review → invoke plan-eng-review
|
||||
- Save progress, checkpoint, resume → invoke checkpoint
|
||||
- Code quality, health check → invoke health
|
||||
```
|
||||
|
||||
Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"`
|
||||
|
||||
If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true`
|
||||
Say "No problem. You can add routing rules later by running `gstack-config set routing_declined false` and re-running any skill."
|
||||
|
||||
This only happens once per project. If `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`, skip this entirely.
|
||||
|
||||
If `VENDORED_GSTACK` is `yes`: This project has a vendored copy of gstack at
|
||||
`.claude/skills/gstack/`. Vendoring is deprecated. We will not keep vendored copies
|
||||
up to date, so this project's gstack will fall behind.
|
||||
|
||||
Use AskUserQuestion (one-time per project, check for `~/.gstack/.vendoring-warned-$SLUG` marker):
|
||||
|
||||
> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated.
|
||||
> We won't keep this copy up to date, so you'll fall behind on new features and fixes.
|
||||
>
|
||||
> Want to migrate to team mode? It takes about 30 seconds.
|
||||
|
||||
Options:
|
||||
- A) Yes, migrate to team mode now
|
||||
- B) No, I'll handle it myself
|
||||
|
||||
If A:
|
||||
1. Run `git rm -r .claude/skills/gstack/`
|
||||
2. Run `echo '.claude/skills/gstack/' >> .gitignore`
|
||||
3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`)
|
||||
4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"`
|
||||
5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`"
|
||||
|
||||
If B: say "OK, you're on your own to keep the vendored copy up to date."
|
||||
|
||||
Always run (regardless of choice):
|
||||
```bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
touch ~/.gstack/.vendoring-warned-${SLUG:-unknown}
|
||||
```
|
||||
|
||||
This only happens once per project. If the marker file exists, skip entirely.
|
||||
|
||||
If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an
|
||||
AI orchestrator (e.g., OpenClaw). In spawned sessions:
|
||||
- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option.
|
||||
- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro.
|
||||
- Focus on completing the task and reporting results via prose output.
|
||||
- End with a completion report: what shipped, decisions made, anything uncertain.
|
||||
|
||||
## Model-Specific Behavioral Patch (claude)
|
||||
|
||||
The following nudges are tuned for the claude model family. They are
|
||||
**subordinate** to skill workflow, STOP points, AskUserQuestion gates, plan-mode
|
||||
safety, and /ship review gates. If a nudge below conflicts with skill instructions,
|
||||
the skill wins. Treat these as preferences, not rules.
|
||||
|
||||
**Todo-list discipline.** When working through a multi-step plan, mark each task
|
||||
complete individually as you finish it. Do not batch-complete at the end. If a task
|
||||
turns out to be unnecessary, mark it skipped with a one-line reason.
|
||||
|
||||
**Think before heavy actions.** For complex operations (refactors, migrations,
|
||||
non-trivial new features), briefly state your approach before executing. This lets
|
||||
the user course-correct cheaply instead of mid-flight.
|
||||
|
||||
**Dedicated tools over Bash.** Prefer Read, Edit, Write, Glob, Grep over shell
|
||||
equivalents (cat, sed, find, grep). The dedicated tools are cheaper and clearer.
|
||||
|
||||
## Voice
|
||||
|
||||
**Tone:** direct, concrete, sharp, never corporate, never academic. Sound like a builder, not a consultant. Name the file, the function, the command. No filler, no throat-clearing.
|
||||
|
||||
**Writing rules:** No em dashes (use commas, periods, "..."). No AI vocabulary (delve, crucial, robust, comprehensive, nuanced, etc.). Short paragraphs. End with what to do.
|
||||
|
||||
The user always has context you don't. Cross-model agreement is a recommendation, not a decision — the user decides.
|
||||
|
||||
## Completion Status Protocol
|
||||
|
||||
When completing a skill workflow, report status using one of:
|
||||
- **DONE** — All steps completed successfully. Evidence provided for each claim.
|
||||
- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern.
|
||||
- **BLOCKED** — Cannot proceed. State what is blocking and what was tried.
|
||||
- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need.
|
||||
|
||||
### Escalation
|
||||
|
||||
It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result."
|
||||
|
||||
Bad work is worse than no work. You will not be penalized for escalating.
|
||||
- If you have attempted a task 3 times without success, STOP and escalate.
|
||||
- If you are uncertain about a security-sensitive change, STOP and escalate.
|
||||
- If the scope of work exceeds what you can verify, STOP and escalate.
|
||||
|
||||
Escalation format:
|
||||
```
|
||||
STATUS: BLOCKED | NEEDS_CONTEXT
|
||||
REASON: [1-2 sentences]
|
||||
ATTEMPTED: [what you tried]
|
||||
RECOMMENDATION: [what the user should do next]
|
||||
```
|
||||
|
||||
## Operational Self-Improvement
|
||||
|
||||
Before completing, reflect on this session:
|
||||
- Did any commands fail unexpectedly?
|
||||
- Did you take a wrong approach and have to backtrack?
|
||||
- Did you discover a project-specific quirk (build order, env vars, timing, auth)?
|
||||
- Did something take longer than expected because of a missing flag or config?
|
||||
|
||||
If yes, log an operational learning for future sessions:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
|
||||
```
|
||||
|
||||
Replace SKILL_NAME with the current skill name. Only log genuine operational discoveries.
|
||||
Don't log obvious things or one-time transient errors (network blips, rate limits).
|
||||
A good test: would knowing this save 5+ minutes in a future session? If yes, log it.
|
||||
|
||||
## Telemetry (run last)
|
||||
|
||||
After the skill workflow completes (success, error, or abort), log the telemetry event.
|
||||
Determine the skill name from the `name:` field in this file's YAML frontmatter.
|
||||
Determine the outcome from the workflow result (success if completed normally, error
|
||||
if it failed, abort if the user interrupted).
|
||||
|
||||
**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to
|
||||
`~/.gstack/analytics/` (user config directory, not project files). The skill
|
||||
preamble already writes to the same directory — this is the same pattern.
|
||||
Skipping this command loses session duration and outcome data.
|
||||
|
||||
Run this bash:
|
||||
|
||||
```bash
|
||||
_TEL_END=$(date +%s)
|
||||
_TEL_DUR=$(( _TEL_END - _TEL_START ))
|
||||
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
|
||||
# Session timeline: record skill completion (local-only, never sent anywhere)
|
||||
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"SKILL_NAME","event":"completed","branch":"'$(git branch --show-current 2>/dev/null || echo unknown)'","outcome":"OUTCOME","duration_s":"'"$_TEL_DUR"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||
# Local analytics (gated on telemetry setting)
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"SKILL_NAME","duration_s":"'"$_TEL_DUR"'","outcome":"OUTCOME","browse":"USED_BROWSE","session":"'"$_SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
# Remote telemetry (opt-in, requires binary)
|
||||
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log \
|
||||
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
|
||||
fi
|
||||
```
|
||||
|
||||
Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with
|
||||
success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used.
|
||||
If you cannot determine the outcome, use "unknown". The local JSONL always logs. The
|
||||
remote binary only runs if telemetry is not off and the binary exists.
|
||||
|
||||
## Plan Mode Safe Operations
|
||||
|
||||
In plan mode, these are always allowed (they inform the plan, don't modify source):
|
||||
`$B` (browse), `$D` (design), `codex exec`/`codex review`, writes to `~/.gstack/`,
|
||||
writes to the plan file, `open` for generated artifacts.
|
||||
|
||||
## Skill Invocation During Plan Mode
|
||||
|
||||
If the user invokes a skill in plan mode, that skill takes precedence over generic plan mode behavior. Treat it as executable instructions, not reference. Follow step
|
||||
by step. AskUserQuestion calls satisfy plan mode's end-of-turn requirement. At a STOP
|
||||
point, stop immediately. Do not continue the workflow past a STOP point and do not call ExitPlanMode there. Commands marked "PLAN
|
||||
MODE EXCEPTION — ALWAYS RUN" execute. Other writes need to be already permitted
|
||||
above or explicitly exception-marked. Call ExitPlanMode only after the skill
|
||||
workflow completes — only then call ExitPlanMode (or if the user tells you to cancel the skill or leave plan mode).
|
||||
|
||||
## Plan Status Footer
|
||||
|
||||
In plan mode, before ExitPlanMode: if the plan file lacks a `## GSTACK REVIEW REPORT`
|
||||
section, run `~/.claude/skills/gstack/bin/gstack-review-read` and append a report.
|
||||
With JSONL entries (before `---CONFIG---`), format the standard runs/status/findings
|
||||
table. With `NO_REVIEWS` or empty, append a 5-row placeholder table (CEO/Codex/Eng/
|
||||
Design/DX Review) with all zeros and verdict "NO REVIEWS YET — run `/autoplan`".
|
||||
If a richer review report already exists, skip — review skills wrote it.
|
||||
|
||||
PLAN MODE EXCEPTION — always allowed (it's the plan file).
|
||||
|
||||
# make-pdf: publication-quality PDFs from markdown
|
||||
|
||||
Turn `.md` files into PDFs that look like Faber & Faber essays: 1in margins,
|
||||
left-aligned body, Helvetica throughout, curly quotes and em dashes, optional
|
||||
cover page and clickable TOC, diagonal DRAFT watermark when you need it.
|
||||
Copy-paste from the PDF produces clean words, never "S a i l i n g".
|
||||
|
||||
## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)
|
||||
|
||||
```bash
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
P=""
|
||||
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
|
||||
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/make-pdf/dist/pdf" ] && P="$_ROOT/.claude/skills/gstack/make-pdf/dist/pdf"
|
||||
[ -z "$P" ] && P="$HOME/.claude/skills/gstack/make-pdf/dist/pdf"
|
||||
if [ -x "$P" ]; then
|
||||
echo "MAKE_PDF_READY: $P"
|
||||
alias _p_="$P" # shellcheck alias helper (not exported)
|
||||
export P # available as $P in subsequent blocks within the same skill invocation
|
||||
else
|
||||
echo "MAKE_PDF_NOT_AVAILABLE (run './setup' in the gstack repo to build it)"
|
||||
fi
|
||||
```
|
||||
|
||||
If `MAKE_PDF_NOT_AVAILABLE` is printed: tell the user the binary is not
|
||||
built. Have them run `./setup` from the gstack repo, then retry.
|
||||
|
||||
If `MAKE_PDF_READY` is printed: `$P` is the binary path for the rest of
|
||||
the skill. Use `$P` (not an explicit path) so the skill body stays portable.
|
||||
|
||||
Core commands:
|
||||
- `$P generate <input.md> [output.pdf]` — render markdown to PDF (80% use case)
|
||||
- `$P generate --cover --toc essay.md out.pdf` — full publication layout
|
||||
- `$P generate --watermark DRAFT memo.md draft.pdf` — diagonal DRAFT watermark
|
||||
- `$P preview <input.md>` — render HTML and open in browser (fast iteration)
|
||||
- `$P setup` — verify browse + Chromium + pdftotext and run a smoke test
|
||||
- `$P --help` — full flag reference
|
||||
|
||||
Output contract:
|
||||
- `stdout`: ONLY the output path on success. One line.
|
||||
- `stderr`: progress (`Rendering HTML... Generating PDF...`) unless `--quiet`.
|
||||
- Exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.
|
||||
|
||||
## Core patterns
|
||||
|
||||
### 80% case — memo/letter
|
||||
|
||||
One command, no flags. Gets a clean PDF with running header + page numbers
|
||||
+ CONFIDENTIAL footer by default.
|
||||
|
||||
```bash
|
||||
$P generate letter.md # writes /tmp/letter.pdf
|
||||
$P generate letter.md letter.pdf # explicit output path
|
||||
```
|
||||
|
||||
### Publication mode — cover + TOC + chapter breaks
|
||||
|
||||
```bash
|
||||
$P generate --cover --toc --author "Garry Tan" --title "On Horizons" \
|
||||
essay.md essay.pdf
|
||||
```
|
||||
|
||||
Each top-level H1 in the markdown starts a new page. Disable with
|
||||
`--no-chapter-breaks` for memos that happen to have multiple H1s.
|
||||
|
||||
### Draft-stage watermark
|
||||
|
||||
```bash
|
||||
$P generate --watermark DRAFT memo.md draft.pdf
|
||||
```
|
||||
|
||||
Diagonal 10% opacity DRAFT across every page. When the draft is final, drop
|
||||
the flag and regenerate.
|
||||
|
||||
### Fast iteration via preview
|
||||
|
||||
```bash
|
||||
$P preview essay.md
|
||||
```
|
||||
|
||||
Renders HTML with the same print CSS and opens it in your browser. Refresh
|
||||
as you edit the markdown. Skip the PDF round trip until you're ready.
|
||||
|
||||
### Brand-free (no CONFIDENTIAL footer)
|
||||
|
||||
```bash
|
||||
$P generate --no-confidential memo.md memo.pdf
|
||||
```
|
||||
|
||||
## Common flags
|
||||
|
||||
```
|
||||
Page layout:
|
||||
--margins <dim> 1in (default) | 72pt | 2.54cm | 25mm
|
||||
--page-size letter|a4|legal
|
||||
|
||||
Structure:
|
||||
--cover Cover page (title, author, date, hairline rule)
|
||||
--toc Clickable TOC with page numbers
|
||||
--no-chapter-breaks Don't start a new page at every H1
|
||||
|
||||
Branding:
|
||||
--watermark <text> Diagonal watermark ("DRAFT", "CONFIDENTIAL")
|
||||
--header-template <html> Custom running header
|
||||
--footer-template <html> Custom footer (mutex with --page-numbers)
|
||||
--no-confidential Suppress the CONFIDENTIAL right-footer
|
||||
|
||||
Output:
|
||||
--page-numbers "N of M" footer (default on)
|
||||
--tagged Accessible PDF (default on)
|
||||
--outline PDF bookmarks from headings (default on)
|
||||
--quiet Suppress progress on stderr
|
||||
--verbose Per-stage timings
|
||||
|
||||
Network:
|
||||
--allow-network Fetch external images. Off by default
|
||||
(blocks tracking pixels).
|
||||
|
||||
Metadata:
|
||||
--title "..." Document title (defaults to first H1)
|
||||
--author "..." Author for cover + PDF metadata
|
||||
--date "..." Date for cover (defaults to today)
|
||||
```
|
||||
|
||||
## When Claude should run it
|
||||
|
||||
Watch for markdown-to-PDF intent. Any of these patterns → run `$P generate`:
|
||||
|
||||
- "Can you make this markdown a PDF"
|
||||
- "Export it as a PDF"
|
||||
- "Turn this letter into a PDF"
|
||||
- "I need a PDF of the essay"
|
||||
- "Print this as a PDF for me"
|
||||
|
||||
If the user has a `.md` file open and says "make it look nice", propose
|
||||
`$P generate --cover --toc` and ask before running.
|
||||
|
||||
## Debugging
|
||||
|
||||
- Output looks empty / blank → check browse daemon is running: `$B status`.
|
||||
- Fragmented text on copy-paste → highlight.js output (Phase 4). Retry with
|
||||
`--no-syntax` once that flag exists. For now, remove fenced code blocks
|
||||
and regenerate.
|
||||
- Paged.js timeout → probably no headings in the markdown. Drop `--toc`.
|
||||
- External image missing → add `--allow-network` (understand you're giving
|
||||
the markdown file permission to fetch from its image URLs).
|
||||
- Generated PDF too tall/wide → `--page-size a4` or `--margins 0.75in`.
|
||||
|
||||
## Output contract
|
||||
|
||||
```
|
||||
stdout: /tmp/letter.pdf ← just the path, one line
|
||||
stderr: Rendering HTML... ← progress spinner (unless --quiet)
|
||||
Generating PDF...
|
||||
Done in 1.5s. 43 words · 22KB · /tmp/letter.pdf
|
||||
|
||||
exit code: 0 success / 1 bad args / 2 render error / 3 Paged.js timeout
|
||||
/ 4 browse unavailable
|
||||
```
|
||||
|
||||
Capture the path: `PDF=$($P generate letter.md)` — then use `$PDF`.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: make-pdf
|
||||
preamble-tier: 1
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Turn any markdown file into a publication-quality PDF. Proper 1in margins,
|
||||
intelligent page breaks, page numbers, cover pages, running headers, curly
|
||||
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Output you'd
|
||||
send to a VC partner, a book agent, a judge, or Rick Rubin's team. Not a
|
||||
draft artifact — a finished artifact. Use when asked to "make a PDF",
|
||||
"export to PDF", "turn this markdown into a PDF", or "generate a document".
|
||||
(gstack)
|
||||
voice-triggers:
|
||||
- "make this a pdf"
|
||||
- "make it a pdf"
|
||||
- "export to pdf"
|
||||
- "turn this into a pdf"
|
||||
- "turn this markdown into a pdf"
|
||||
- "generate a pdf"
|
||||
- "make a pdf from"
|
||||
- "pdf this markdown"
|
||||
triggers:
|
||||
- markdown to pdf
|
||||
- generate pdf
|
||||
- make pdf
|
||||
- export pdf
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
{{PREAMBLE}}
|
||||
|
||||
# make-pdf: publication-quality PDFs from markdown
|
||||
|
||||
Turn `.md` files into PDFs that look like Faber & Faber essays: 1in margins,
|
||||
left-aligned body, Helvetica throughout, curly quotes and em dashes, optional
|
||||
cover page and clickable TOC, diagonal DRAFT watermark when you need it.
|
||||
Copy-paste from the PDF produces clean words, never "S a i l i n g".
|
||||
|
||||
{{MAKE_PDF_SETUP}}
|
||||
|
||||
## Core patterns
|
||||
|
||||
### 80% case — memo/letter
|
||||
|
||||
One command, no flags. Gets a clean PDF with running header + page numbers
|
||||
+ CONFIDENTIAL footer by default.
|
||||
|
||||
```bash
|
||||
$P generate letter.md # writes /tmp/letter.pdf
|
||||
$P generate letter.md letter.pdf # explicit output path
|
||||
```
|
||||
|
||||
### Publication mode — cover + TOC + chapter breaks
|
||||
|
||||
```bash
|
||||
$P generate --cover --toc --author "Garry Tan" --title "On Horizons" \
|
||||
essay.md essay.pdf
|
||||
```
|
||||
|
||||
Each top-level H1 in the markdown starts a new page. Disable with
|
||||
`--no-chapter-breaks` for memos that happen to have multiple H1s.
|
||||
|
||||
### Draft-stage watermark
|
||||
|
||||
```bash
|
||||
$P generate --watermark DRAFT memo.md draft.pdf
|
||||
```
|
||||
|
||||
Diagonal 10% opacity DRAFT across every page. When the draft is final, drop
|
||||
the flag and regenerate.
|
||||
|
||||
### Fast iteration via preview
|
||||
|
||||
```bash
|
||||
$P preview essay.md
|
||||
```
|
||||
|
||||
Renders HTML with the same print CSS and opens it in your browser. Refresh
|
||||
as you edit the markdown. Skip the PDF round trip until you're ready.
|
||||
|
||||
### Brand-free (no CONFIDENTIAL footer)
|
||||
|
||||
```bash
|
||||
$P generate --no-confidential memo.md memo.pdf
|
||||
```
|
||||
|
||||
## Common flags
|
||||
|
||||
```
|
||||
Page layout:
|
||||
--margins <dim> 1in (default) | 72pt | 2.54cm | 25mm
|
||||
--page-size letter|a4|legal
|
||||
|
||||
Structure:
|
||||
--cover Cover page (title, author, date, hairline rule)
|
||||
--toc Clickable TOC with page numbers
|
||||
--no-chapter-breaks Don't start a new page at every H1
|
||||
|
||||
Branding:
|
||||
--watermark <text> Diagonal watermark ("DRAFT", "CONFIDENTIAL")
|
||||
--header-template <html> Custom running header
|
||||
--footer-template <html> Custom footer (mutex with --page-numbers)
|
||||
--no-confidential Suppress the CONFIDENTIAL right-footer
|
||||
|
||||
Output:
|
||||
--page-numbers "N of M" footer (default on)
|
||||
--tagged Accessible PDF (default on)
|
||||
--outline PDF bookmarks from headings (default on)
|
||||
--quiet Suppress progress on stderr
|
||||
--verbose Per-stage timings
|
||||
|
||||
Network:
|
||||
--allow-network Fetch external images. Off by default
|
||||
(blocks tracking pixels).
|
||||
|
||||
Metadata:
|
||||
--title "..." Document title (defaults to first H1)
|
||||
--author "..." Author for cover + PDF metadata
|
||||
--date "..." Date for cover (defaults to today)
|
||||
```
|
||||
|
||||
## When Claude should run it
|
||||
|
||||
Watch for markdown-to-PDF intent. Any of these patterns → run `$P generate`:
|
||||
|
||||
- "Can you make this markdown a PDF"
|
||||
- "Export it as a PDF"
|
||||
- "Turn this letter into a PDF"
|
||||
- "I need a PDF of the essay"
|
||||
- "Print this as a PDF for me"
|
||||
|
||||
If the user has a `.md` file open and says "make it look nice", propose
|
||||
`$P generate --cover --toc` and ask before running.
|
||||
|
||||
## Debugging
|
||||
|
||||
- Output looks empty / blank → check browse daemon is running: `$B status`.
|
||||
- Fragmented text on copy-paste → highlight.js output (Phase 4). Retry with
|
||||
`--no-syntax` once that flag exists. For now, remove fenced code blocks
|
||||
and regenerate.
|
||||
- Paged.js timeout → probably no headings in the markdown. Drop `--toc`.
|
||||
- External image missing → add `--allow-network` (understand you're giving
|
||||
the markdown file permission to fetch from its image URLs).
|
||||
- Generated PDF too tall/wide → `--page-size a4` or `--margins 0.75in`.
|
||||
|
||||
## Output contract
|
||||
|
||||
```
|
||||
stdout: /tmp/letter.pdf ← just the path, one line
|
||||
stderr: Rendering HTML... ← progress spinner (unless --quiet)
|
||||
Generating PDF...
|
||||
Done in 1.5s. 43 words · 22KB · /tmp/letter.pdf
|
||||
|
||||
exit code: 0 success / 1 bad args / 2 render error / 3 Paged.js timeout
|
||||
/ 4 browse unavailable
|
||||
```
|
||||
|
||||
Capture the path: `PDF=$($P generate letter.md)` — then use `$PDF`.
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Typed shell-out wrapper for the browse CLI.
|
||||
*
|
||||
* Every browse call goes through this file. Reasons:
|
||||
* - One place to do binary resolution.
|
||||
* - One place to enforce the --from-file convention for large payloads
|
||||
* (Windows argv cap is 8191 chars; 200KB HTML dies without this).
|
||||
* - One place that maps non-zero exit codes to typed errors.
|
||||
*
|
||||
* Binary resolution order (Codex round 2 #4):
|
||||
* 1. $BROWSE_BIN env override
|
||||
* 2. sibling dir: dirname(argv[0])/../browse/dist/browse
|
||||
* 3. ~/.claude/skills/gstack/browse/dist/browse
|
||||
* 4. PATH lookup: `browse`
|
||||
* 5. error with setup hint
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
import { BrowseClientError } from "./types";
|
||||
|
||||
export interface LoadHtmlOptions {
|
||||
html: string; // raw HTML string
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle";
|
||||
tabId: number;
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
output: string;
|
||||
tabId: number;
|
||||
format?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
marginTop?: string;
|
||||
marginRight?: string;
|
||||
marginBottom?: string;
|
||||
marginLeft?: string;
|
||||
headerTemplate?: string;
|
||||
footerTemplate?: string;
|
||||
pageNumbers?: boolean;
|
||||
tagged?: boolean;
|
||||
outline?: boolean;
|
||||
printBackground?: boolean;
|
||||
preferCSSPageSize?: boolean;
|
||||
toc?: boolean;
|
||||
}
|
||||
|
||||
export interface JsOptions {
|
||||
tabId: number;
|
||||
expression: string; // JS expression to evaluate
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the browse binary. Throws a BrowseClientError with a
|
||||
* canonical setup message if not found.
|
||||
*/
|
||||
export function resolveBrowseBin(): string {
|
||||
const envOverride = process.env.BROWSE_BIN;
|
||||
if (envOverride && isExecutable(envOverride)) return envOverride;
|
||||
|
||||
// Sibling: look relative to this process's binary
|
||||
// (for when make-pdf and browse live next to each other in dist/)
|
||||
const selfDir = path.dirname(process.argv[0]);
|
||||
const siblingCandidates = [
|
||||
path.resolve(selfDir, "../browse/dist/browse"),
|
||||
path.resolve(selfDir, "../../browse/dist/browse"),
|
||||
path.resolve(selfDir, "../browse"),
|
||||
];
|
||||
for (const candidate of siblingCandidates) {
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
|
||||
// Global install
|
||||
const home = os.homedir();
|
||||
const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse");
|
||||
if (isExecutable(globalPath)) return globalPath;
|
||||
|
||||
// PATH lookup
|
||||
try {
|
||||
const which = execFileSync("which", ["browse"], { encoding: "utf8" }).trim();
|
||||
if (which && isExecutable(which)) return which;
|
||||
} catch {
|
||||
// `which` exited non-zero; fall through to error
|
||||
}
|
||||
|
||||
throw new BrowseClientError(
|
||||
/* exitCode */ 127,
|
||||
"resolve",
|
||||
[
|
||||
"browse binary not found.",
|
||||
"",
|
||||
"make-pdf needs browse (the gstack Chromium daemon) to render PDFs.",
|
||||
"Tried:",
|
||||
` - $BROWSE_BIN (${envOverride || "unset"})`,
|
||||
` - sibling: ${siblingCandidates.join(", ")}`,
|
||||
` - global: ${globalPath}`,
|
||||
" - PATH: `browse`",
|
||||
"",
|
||||
"To fix: run gstack setup from the gstack repo:",
|
||||
" cd ~/.claude/skills/gstack && ./setup",
|
||||
"",
|
||||
"Or set BROWSE_BIN explicitly:",
|
||||
" export BROWSE_BIN=/path/to/browse",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function isExecutable(p: string): boolean {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a browse command. Returns stdout on success.
|
||||
* Throws BrowseClientError on non-zero exit.
|
||||
*/
|
||||
function runBrowse(args: string[]): string {
|
||||
const bin = resolveBrowseBin();
|
||||
try {
|
||||
return execFileSync(bin, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024, // 16MB; tab content can be large
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err: any) {
|
||||
const exitCode = typeof err.status === "number" ? err.status : 1;
|
||||
const stderr = typeof err.stderr === "string"
|
||||
? err.stderr
|
||||
: (err.stderr?.toString() ?? "");
|
||||
throw new BrowseClientError(exitCode, args[0] || "unknown", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a payload to a tmp file and return the path. Used for any payload
|
||||
* >4KB to avoid Windows argv limits (Codex round 2 #3).
|
||||
*/
|
||||
function writePayloadFile(payload: Record<string, unknown>): string {
|
||||
const hash = crypto.createHash("sha256")
|
||||
.update(JSON.stringify(payload))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
const tmpPath = path.join(os.tmpdir(), `make-pdf-browse-${process.pid}-${hash}.json`);
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
|
||||
return tmpPath;
|
||||
}
|
||||
|
||||
function cleanupPayloadFile(p: string): void {
|
||||
try { fs.unlinkSync(p); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a new tab. Returns the tabId.
|
||||
* Requires `$B newtab --json` to be available (added in the browse flag
|
||||
* extension for this feature). If --json isn't supported yet, the fallback
|
||||
* parses "Opened tab N" from stdout.
|
||||
*/
|
||||
export function newtab(url?: string): number {
|
||||
const args = ["newtab"];
|
||||
if (url) args.push(url);
|
||||
// Try --json first (preferred path for programmatic use)
|
||||
try {
|
||||
const out = runBrowse([...args, "--json"]);
|
||||
const parsed = JSON.parse(out);
|
||||
if (typeof parsed.tabId === "number") return parsed.tabId;
|
||||
} catch {
|
||||
// Fall back to stdout-string parsing. Brittle, but works on older browse builds.
|
||||
}
|
||||
const out = runBrowse(args);
|
||||
const m = out.match(/tab\s+(\d+)/i);
|
||||
if (!m) throw new BrowseClientError(1, "newtab", `could not parse tab id from: ${out}`);
|
||||
return parseInt(m[1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a tab (by id or the active tab).
|
||||
*/
|
||||
export function closetab(tabId?: number): void {
|
||||
const args = ["closetab"];
|
||||
if (tabId !== undefined) args.push(String(tabId));
|
||||
runBrowse(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load raw HTML into a specific tab.
|
||||
* Uses --from-file for any payload >4KB (Codex round 2 #3).
|
||||
*/
|
||||
export function loadHtml(opts: LoadHtmlOptions): void {
|
||||
// Always use --from-file to dodge argv limits. The HTML is almost always >4KB.
|
||||
const payload = {
|
||||
html: opts.html,
|
||||
waitUntil: opts.waitUntil ?? "domcontentloaded",
|
||||
};
|
||||
const payloadFile = writePayloadFile(payload);
|
||||
try {
|
||||
runBrowse([
|
||||
"load-html",
|
||||
"--from-file", payloadFile,
|
||||
"--tab-id", String(opts.tabId),
|
||||
]);
|
||||
} finally {
|
||||
cleanupPayloadFile(payloadFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JS expression in a tab. Returns the serialized result as string.
|
||||
*/
|
||||
export function js(opts: JsOptions): string {
|
||||
return runBrowse([
|
||||
"js",
|
||||
opts.expression,
|
||||
"--tab-id", String(opts.tabId),
|
||||
]).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a boolean JS expression until it evaluates to true, or timeout.
|
||||
* Returns true if it succeeded, false if timed out.
|
||||
*/
|
||||
export function waitForExpression(opts: {
|
||||
expression: string;
|
||||
tabId: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
}): boolean {
|
||||
const poll = opts.pollIntervalMs ?? 200;
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const result = js({ expression: opts.expression, tabId: opts.tabId });
|
||||
if (result === "true") return true;
|
||||
} catch {
|
||||
// Tab may still be loading; keep polling
|
||||
}
|
||||
const wait = Math.min(poll, Math.max(0, deadline - Date.now()));
|
||||
if (wait <= 0) break;
|
||||
// Synchronous sleep is fine — this only runs once per PDF render
|
||||
const end = Date.now() + wait;
|
||||
while (Date.now() < end) { /* busy wait */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PDF from the given tab. Uses --from-file when header/footer
|
||||
* templates are present (they can be HTML strings of arbitrary size).
|
||||
*/
|
||||
export function pdf(opts: PdfOptions): void {
|
||||
// If any large payload is present, send via --from-file
|
||||
const hasLargePayload =
|
||||
(opts.headerTemplate && opts.headerTemplate.length > 1024) ||
|
||||
(opts.footerTemplate && opts.footerTemplate.length > 1024);
|
||||
|
||||
if (hasLargePayload) {
|
||||
const payloadFile = writePayloadFile({
|
||||
output: opts.output,
|
||||
tabId: opts.tabId,
|
||||
...optionsToPdfFlags(opts),
|
||||
});
|
||||
try {
|
||||
runBrowse(["pdf", "--from-file", payloadFile]);
|
||||
} finally {
|
||||
cleanupPayloadFile(payloadFile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Small payload: pass flags via argv
|
||||
const args = ["pdf", opts.output, "--tab-id", String(opts.tabId)];
|
||||
pushFlagsFromOptions(args, opts);
|
||||
runBrowse(args);
|
||||
}
|
||||
|
||||
function optionsToPdfFlags(opts: PdfOptions): Record<string, unknown> {
|
||||
// Shape mirrors what the browse `pdf` case expects when reading --from-file
|
||||
const out: Record<string, unknown> = {};
|
||||
if (opts.format) out.format = opts.format;
|
||||
if (opts.width) out.width = opts.width;
|
||||
if (opts.height) out.height = opts.height;
|
||||
if (opts.marginTop) out.marginTop = opts.marginTop;
|
||||
if (opts.marginRight) out.marginRight = opts.marginRight;
|
||||
if (opts.marginBottom) out.marginBottom = opts.marginBottom;
|
||||
if (opts.marginLeft) out.marginLeft = opts.marginLeft;
|
||||
if (opts.headerTemplate !== undefined) out.headerTemplate = opts.headerTemplate;
|
||||
if (opts.footerTemplate !== undefined) out.footerTemplate = opts.footerTemplate;
|
||||
if (opts.pageNumbers !== undefined) out.pageNumbers = opts.pageNumbers;
|
||||
if (opts.tagged !== undefined) out.tagged = opts.tagged;
|
||||
if (opts.outline !== undefined) out.outline = opts.outline;
|
||||
if (opts.printBackground !== undefined) out.printBackground = opts.printBackground;
|
||||
if (opts.preferCSSPageSize !== undefined) out.preferCSSPageSize = opts.preferCSSPageSize;
|
||||
if (opts.toc !== undefined) out.toc = opts.toc;
|
||||
return out;
|
||||
}
|
||||
|
||||
function pushFlagsFromOptions(args: string[], opts: PdfOptions): void {
|
||||
if (opts.format) { args.push("--format", opts.format); }
|
||||
if (opts.width) { args.push("--width", opts.width); }
|
||||
if (opts.height) { args.push("--height", opts.height); }
|
||||
if (opts.marginTop) { args.push("--margin-top", opts.marginTop); }
|
||||
if (opts.marginRight) { args.push("--margin-right", opts.marginRight); }
|
||||
if (opts.marginBottom) { args.push("--margin-bottom", opts.marginBottom); }
|
||||
if (opts.marginLeft) { args.push("--margin-left", opts.marginLeft); }
|
||||
if (opts.headerTemplate !== undefined) {
|
||||
args.push("--header-template", opts.headerTemplate);
|
||||
}
|
||||
if (opts.footerTemplate !== undefined) {
|
||||
args.push("--footer-template", opts.footerTemplate);
|
||||
}
|
||||
if (opts.pageNumbers === true) args.push("--page-numbers");
|
||||
if (opts.tagged === true) args.push("--tagged");
|
||||
if (opts.outline === true) args.push("--outline");
|
||||
if (opts.printBackground === true) args.push("--print-background");
|
||||
if (opts.preferCSSPageSize === true) args.push("--prefer-css-page-size");
|
||||
if (opts.toc === true) args.push("--toc");
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* make-pdf CLI — argv parse, dispatch, exit.
|
||||
*
|
||||
* Output contract (per CEO plan DX spec):
|
||||
* stdout: ONLY the output path on success. One line. Nothing else.
|
||||
* stderr: progress spinner per stage, final "Done in Xs. N pages."
|
||||
* --quiet: suppress progress. Errors still print.
|
||||
* --verbose: per-stage timings.
|
||||
* exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.
|
||||
*/
|
||||
|
||||
import { COMMANDS } from "./commands";
|
||||
import { ExitCode, BrowseClientError } from "./types";
|
||||
import type { GenerateOptions, PreviewOptions } from "./types";
|
||||
|
||||
interface ParsedArgs {
|
||||
command: string;
|
||||
positional: string[];
|
||||
flags: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
printUsage();
|
||||
process.exit(ExitCode.Success);
|
||||
}
|
||||
|
||||
// First non-flag arg is the command.
|
||||
let command = "";
|
||||
const positional: string[] = [];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next !== undefined && !next.startsWith("--")) {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[key] = true;
|
||||
}
|
||||
} else if (!command) {
|
||||
command = a;
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, positional, flags };
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
const lines = [
|
||||
"make-pdf — turn markdown into publication-quality PDFs",
|
||||
"",
|
||||
"Usage:",
|
||||
];
|
||||
for (const [name, info] of COMMANDS) {
|
||||
lines.push(` $P ${info.usage}`);
|
||||
lines.push(` ${info.description}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Page layout:");
|
||||
lines.push(" --margins <dim> All four margins (default: 1in). in, pt, cm, mm.");
|
||||
lines.push(" --page-size letter|a4|legal (aliases: --format)");
|
||||
lines.push("");
|
||||
lines.push("Document structure:");
|
||||
lines.push(" --cover Add a cover page.");
|
||||
lines.push(" --toc Generate clickable table of contents.");
|
||||
lines.push(" --no-chapter-breaks Don't start a new page at every H1.");
|
||||
lines.push("");
|
||||
lines.push("Branding:");
|
||||
lines.push(" --watermark <text> Diagonal watermark on every page.");
|
||||
lines.push(" --header-template <html>");
|
||||
lines.push(" --footer-template <html> Mutex with --page-numbers.");
|
||||
lines.push(" --no-confidential Suppress the CONFIDENTIAL footer.");
|
||||
lines.push("");
|
||||
lines.push("Output control:");
|
||||
lines.push(" --page-numbers / --no-page-numbers (default: on)");
|
||||
lines.push(" --tagged / --no-tagged (default: on, accessible PDF)");
|
||||
lines.push(" --outline / --no-outline (default: on, PDF bookmarks)");
|
||||
lines.push(" --quiet Suppress progress on stderr.");
|
||||
lines.push(" --verbose Per-stage timings on stderr.");
|
||||
lines.push("");
|
||||
lines.push("Network:");
|
||||
lines.push(" --allow-network Load external images (off by default).");
|
||||
lines.push("");
|
||||
lines.push("Examples:");
|
||||
lines.push(" $P generate letter.md");
|
||||
lines.push(" $P generate --cover --toc essay.md essay.pdf");
|
||||
lines.push(" $P generate --watermark DRAFT memo.md draft.pdf");
|
||||
lines.push(" $P preview letter.md");
|
||||
lines.push("");
|
||||
lines.push("Run `$P setup` to verify browse + Chromium + pdftotext install.");
|
||||
console.error(lines.join("\n"));
|
||||
}
|
||||
|
||||
function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
|
||||
const p = parsed.positional;
|
||||
if (p.length === 0) {
|
||||
console.error("$P generate: missing <input.md>");
|
||||
console.error("Usage: $P generate <input.md> [output.pdf] [options]");
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
const f = parsed.flags;
|
||||
const booleanFlag = (key: string, def: boolean): boolean => {
|
||||
if (f[key] === true) return true;
|
||||
if (f[`no-${key}`] === true) return false;
|
||||
return def;
|
||||
};
|
||||
return {
|
||||
input: p[0],
|
||||
output: p[1],
|
||||
margins: f.margins as string | undefined,
|
||||
marginTop: f["margin-top"] as string | undefined,
|
||||
marginRight: f["margin-right"] as string | undefined,
|
||||
marginBottom: f["margin-bottom"] as string | undefined,
|
||||
marginLeft: f["margin-left"] as string | undefined,
|
||||
pageSize: ((f["page-size"] ?? f.format) as any),
|
||||
cover: f.cover === true,
|
||||
toc: f.toc === true,
|
||||
noChapterBreaks: f["no-chapter-breaks"] === true,
|
||||
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
|
||||
headerTemplate: typeof f["header-template"] === "string"
|
||||
? f["header-template"] : undefined,
|
||||
footerTemplate: typeof f["footer-template"] === "string"
|
||||
? f["footer-template"] : undefined,
|
||||
confidential: booleanFlag("confidential", true),
|
||||
pageNumbers: booleanFlag("page-numbers", true),
|
||||
tagged: booleanFlag("tagged", true),
|
||||
outline: booleanFlag("outline", true),
|
||||
quiet: f.quiet === true,
|
||||
verbose: f.verbose === true,
|
||||
allowNetwork: f["allow-network"] === true,
|
||||
title: typeof f.title === "string" ? f.title : undefined,
|
||||
author: typeof f.author === "string" ? f.author : undefined,
|
||||
date: typeof f.date === "string" ? f.date : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function previewOptionsFromFlags(parsed: ParsedArgs): PreviewOptions {
|
||||
const p = parsed.positional;
|
||||
if (p.length === 0) {
|
||||
console.error("$P preview: missing <input.md>");
|
||||
console.error("Usage: $P preview <input.md> [options]");
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
const f = parsed.flags;
|
||||
const booleanFlag = (key: string, def: boolean): boolean => {
|
||||
if (f[key] === true) return true;
|
||||
if (f[`no-${key}`] === true) return false;
|
||||
return def;
|
||||
};
|
||||
return {
|
||||
input: p[0],
|
||||
cover: f.cover === true,
|
||||
toc: f.toc === true,
|
||||
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
|
||||
noChapterBreaks: f["no-chapter-breaks"] === true,
|
||||
confidential: booleanFlag("confidential", true),
|
||||
allowNetwork: f["allow-network"] === true,
|
||||
title: typeof f.title === "string" ? f.title : undefined,
|
||||
author: typeof f.author === "string" ? f.author : undefined,
|
||||
date: typeof f.date === "string" ? f.date : undefined,
|
||||
quiet: f.quiet === true,
|
||||
verbose: f.verbose === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseArgs(process.argv);
|
||||
|
||||
if (!parsed.command) {
|
||||
printUsage();
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
|
||||
if (!COMMANDS.has(parsed.command)) {
|
||||
console.error(`$P: unknown command: ${parsed.command}`);
|
||||
console.error("");
|
||||
printUsage();
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (parsed.command) {
|
||||
case "version": {
|
||||
// Read from VERSION file or fall back to a hard-coded default.
|
||||
try {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const versionFile = path.resolve(
|
||||
path.dirname(process.argv[1] || ""),
|
||||
"../../VERSION",
|
||||
);
|
||||
const version = fs.readFileSync(versionFile, "utf8").trim();
|
||||
console.log(version);
|
||||
} catch {
|
||||
console.log("make-pdf (version unknown)");
|
||||
}
|
||||
process.exit(ExitCode.Success);
|
||||
}
|
||||
|
||||
case "setup": {
|
||||
const { runSetup } = await import("./setup");
|
||||
await runSetup();
|
||||
process.exit(ExitCode.Success);
|
||||
}
|
||||
|
||||
case "generate": {
|
||||
const opts = generateOptionsFromFlags(parsed);
|
||||
const { generate } = await import("./orchestrator");
|
||||
const outputPath = await generate(opts);
|
||||
// Contract: stdout = output path only
|
||||
console.log(outputPath);
|
||||
process.exit(ExitCode.Success);
|
||||
}
|
||||
|
||||
case "preview": {
|
||||
const opts = previewOptionsFromFlags(parsed);
|
||||
const { preview } = await import("./orchestrator");
|
||||
const htmlPath = await preview(opts);
|
||||
console.log(htmlPath);
|
||||
process.exit(ExitCode.Success);
|
||||
}
|
||||
|
||||
default:
|
||||
// Unreachable: COMMANDS.has guarded above
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof BrowseClientError) {
|
||||
console.error(`$P: ${err.message}`);
|
||||
process.exit(ExitCode.BrowseUnavailable);
|
||||
}
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(`$P: file not found: ${err.path ?? err.message}`);
|
||||
process.exit(ExitCode.BadArgs);
|
||||
}
|
||||
if (err?.name === "PagedJsTimeout") {
|
||||
console.error(`$P: ${err.message}`);
|
||||
process.exit(ExitCode.PagedJsTimeout);
|
||||
}
|
||||
console.error(`$P: ${err?.message ?? String(err)}`);
|
||||
if (parsed.flags.verbose && err?.stack) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
process.exit(ExitCode.RenderError);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Command registry for make-pdf — single source of truth.
|
||||
*
|
||||
* Dependency graph:
|
||||
* commands.ts ──▶ cli.ts (runtime dispatch)
|
||||
* ──▶ gen-skill-docs.ts (generates usage table in SKILL.md)
|
||||
* ──▶ tests (validation)
|
||||
*
|
||||
* Zero side effects. Safe to import from build scripts.
|
||||
*/
|
||||
|
||||
export const COMMANDS = new Map<string, {
|
||||
description: string;
|
||||
usage: string;
|
||||
flags?: string[];
|
||||
category: "Primary" | "Setup";
|
||||
}>([
|
||||
["generate", {
|
||||
description: "Render a markdown file to a publication-quality PDF",
|
||||
usage: "generate <input.md> [output.pdf] [options]",
|
||||
category: "Primary",
|
||||
flags: [
|
||||
// Page layout
|
||||
"--margins", "--margin-top", "--margin-right", "--margin-bottom", "--margin-left",
|
||||
"--page-size", "--format",
|
||||
// Structure
|
||||
"--cover", "--toc", "--no-chapter-breaks",
|
||||
// Branding
|
||||
"--watermark", "--header-template", "--footer-template", "--no-confidential",
|
||||
// Output
|
||||
"--page-numbers", "--no-page-numbers", "--tagged", "--no-tagged",
|
||||
"--outline", "--no-outline", "--quiet", "--verbose",
|
||||
// Network
|
||||
"--allow-network",
|
||||
// Metadata
|
||||
"--title", "--author", "--date",
|
||||
],
|
||||
}],
|
||||
["preview", {
|
||||
description: "Render markdown to HTML and open it in the browser (fast iteration)",
|
||||
usage: "preview <input.md> [options]",
|
||||
category: "Primary",
|
||||
flags: [
|
||||
"--cover", "--toc", "--no-chapter-breaks", "--watermark",
|
||||
"--no-confidential", "--allow-network",
|
||||
"--title", "--author", "--date",
|
||||
"--quiet", "--verbose",
|
||||
],
|
||||
}],
|
||||
["setup", {
|
||||
description: "Verify browse + Chromium + pdftotext, then run a smoke test",
|
||||
usage: "setup",
|
||||
category: "Setup",
|
||||
flags: [],
|
||||
}],
|
||||
["version", {
|
||||
description: "Print make-pdf version",
|
||||
usage: "version",
|
||||
category: "Setup",
|
||||
flags: [],
|
||||
}],
|
||||
]);
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Orchestrator — ties render, browseClient, and filesystem together.
|
||||
*
|
||||
* generate(opts): markdown → PDF on disk. Returns output path.
|
||||
* preview(opts): markdown → HTML, opens it in a browser.
|
||||
*
|
||||
* Progress indication (per DX spec):
|
||||
* - stdout: ONLY the output path, printed by cli.ts after this returns.
|
||||
* - stderr: spinner + per-stage status lines, unless opts.quiet.
|
||||
* - --verbose: stage timings.
|
||||
*
|
||||
* Tab lifecycle: every generate opens a dedicated tab via $B newtab --json,
|
||||
* runs load-html/js/pdf against --tab-id <N>, and closes the tab in a
|
||||
* try/finally. Parallel $P generate calls never race on the active tab.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { render } from "./render";
|
||||
import type { GenerateOptions, PreviewOptions } from "./types";
|
||||
import { ExitCode } from "./types";
|
||||
import * as browseClient from "./browseClient";
|
||||
|
||||
class ProgressReporter {
|
||||
private readonly quiet: boolean;
|
||||
private readonly verbose: boolean;
|
||||
private readonly stageStart = new Map<string, number>();
|
||||
private readonly totalStart: number;
|
||||
constructor(opts: { quiet?: boolean; verbose?: boolean }) {
|
||||
this.quiet = opts.quiet === true;
|
||||
this.verbose = opts.verbose === true;
|
||||
this.totalStart = Date.now();
|
||||
}
|
||||
begin(stage: string): void {
|
||||
this.stageStart.set(stage, Date.now());
|
||||
if (this.quiet) return;
|
||||
process.stderr.write(`\r\x1b[K${stage}...`);
|
||||
}
|
||||
end(stage: string, extra?: string): void {
|
||||
const start = this.stageStart.get(stage) ?? Date.now();
|
||||
const ms = Date.now() - start;
|
||||
if (this.quiet) return;
|
||||
if (this.verbose) {
|
||||
process.stderr.write(`\r\x1b[K${stage} (${ms}ms)${extra ? ` — ${extra}` : ""}\n`);
|
||||
}
|
||||
}
|
||||
done(extra: string): void {
|
||||
if (this.quiet) return;
|
||||
const total = ((Date.now() - this.totalStart) / 1000).toFixed(1);
|
||||
process.stderr.write(`\r\x1b[KDone in ${total}s. ${extra}\n`);
|
||||
}
|
||||
fail(stage: string, err: Error): void {
|
||||
if (!this.quiet) process.stderr.write("\r\x1b[K");
|
||||
// Always emit failure info, even in quiet mode — this is an error path.
|
||||
process.stderr.write(`${stage} failed: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* generate — full pipeline. Returns the output PDF path on success.
|
||||
*/
|
||||
export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
const progress = new ProgressReporter(opts);
|
||||
const input = path.resolve(opts.input);
|
||||
|
||||
if (!fs.existsSync(input)) {
|
||||
throw new Error(`input file not found: ${input}`);
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(
|
||||
opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.pdf`),
|
||||
);
|
||||
|
||||
// Stage 1: read markdown
|
||||
progress.begin("Reading markdown");
|
||||
const markdown = fs.readFileSync(input, "utf8");
|
||||
progress.end("Reading markdown");
|
||||
|
||||
// Stage 2: render HTML
|
||||
progress.begin("Rendering HTML");
|
||||
const rendered = render({
|
||||
markdown,
|
||||
title: opts.title,
|
||||
author: opts.author,
|
||||
date: opts.date,
|
||||
cover: opts.cover,
|
||||
toc: opts.toc,
|
||||
watermark: opts.watermark,
|
||||
noChapterBreaks: opts.noChapterBreaks,
|
||||
confidential: opts.confidential,
|
||||
pageSize: opts.pageSize,
|
||||
margins: opts.margins,
|
||||
});
|
||||
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
||||
|
||||
// Stage 3: write HTML to a tmp file browse can read
|
||||
// (We don't actually write it; we pass inline via --from-file JSON.)
|
||||
// But for preview mode and debugging, we still write to tmp.
|
||||
const htmlTmp = tmpFile("html");
|
||||
fs.writeFileSync(htmlTmp, rendered.html, "utf8");
|
||||
|
||||
// Stage 4: spin up a dedicated tab, load HTML, (wait for Paged.js if TOC),
|
||||
// then emit PDF. Always close the tab.
|
||||
progress.begin("Opening tab");
|
||||
const tabId = browseClient.newtab();
|
||||
progress.end("Opening tab", `tabId=${tabId}`);
|
||||
|
||||
try {
|
||||
progress.begin("Loading HTML into Chromium");
|
||||
browseClient.loadHtml({
|
||||
html: rendered.html,
|
||||
waitUntil: "domcontentloaded",
|
||||
tabId,
|
||||
});
|
||||
progress.end("Loading HTML into Chromium");
|
||||
|
||||
if (opts.toc) {
|
||||
progress.begin("Paginating with Paged.js");
|
||||
// Browse's $B pdf already waits internally when --toc is passed.
|
||||
// We pass toc=true to browseClient.pdf() below.
|
||||
progress.end("Paginating with Paged.js", "Paged.js after");
|
||||
}
|
||||
|
||||
progress.begin("Generating PDF");
|
||||
browseClient.pdf({
|
||||
output: outputPath,
|
||||
tabId,
|
||||
format: opts.pageSize ?? "letter",
|
||||
marginTop: opts.marginTop ?? opts.margins ?? "1in",
|
||||
marginRight: opts.marginRight ?? opts.margins ?? "1in",
|
||||
marginBottom: opts.marginBottom ?? opts.margins ?? "1in",
|
||||
marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
|
||||
headerTemplate: opts.headerTemplate,
|
||||
footerTemplate: opts.footerTemplate,
|
||||
pageNumbers: opts.pageNumbers !== false && !opts.footerTemplate,
|
||||
tagged: opts.tagged !== false,
|
||||
outline: opts.outline !== false,
|
||||
printBackground: !!opts.watermark,
|
||||
toc: opts.toc,
|
||||
});
|
||||
progress.end("Generating PDF");
|
||||
|
||||
const stat = fs.statSync(outputPath);
|
||||
const kb = Math.round(stat.size / 1024);
|
||||
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
|
||||
} finally {
|
||||
// Always clean up the tab — even on crash, timeout, or Chromium hang.
|
||||
try {
|
||||
browseClient.closetab(tabId);
|
||||
} catch {
|
||||
// best-effort; we already exited the main path
|
||||
}
|
||||
// Cleanup tmp HTML
|
||||
try { fs.unlinkSync(htmlTmp); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* preview — render HTML and open it. No PDF round trip.
|
||||
*/
|
||||
export async function preview(opts: PreviewOptions): Promise<string> {
|
||||
const progress = new ProgressReporter(opts);
|
||||
const input = path.resolve(opts.input);
|
||||
if (!fs.existsSync(input)) {
|
||||
throw new Error(`input file not found: ${input}`);
|
||||
}
|
||||
|
||||
progress.begin("Rendering HTML");
|
||||
const markdown = fs.readFileSync(input, "utf8");
|
||||
const rendered = render({
|
||||
markdown,
|
||||
title: opts.title,
|
||||
author: opts.author,
|
||||
date: opts.date,
|
||||
cover: opts.cover,
|
||||
toc: opts.toc,
|
||||
watermark: opts.watermark,
|
||||
noChapterBreaks: opts.noChapterBreaks,
|
||||
confidential: opts.confidential,
|
||||
});
|
||||
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
||||
|
||||
// Write to a stable path under /tmp so the user can reload in the same tab.
|
||||
const previewPath = path.join(os.tmpdir(), `make-pdf-preview-${deriveSlug(input)}.html`);
|
||||
fs.writeFileSync(previewPath, rendered.html, "utf8");
|
||||
|
||||
progress.begin("Opening preview");
|
||||
tryOpen(previewPath);
|
||||
progress.end("Opening preview");
|
||||
|
||||
progress.done(`Preview at ${previewPath}`);
|
||||
return previewPath;
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────
|
||||
|
||||
function deriveSlug(p: string): string {
|
||||
const base = path.basename(p).replace(/\.[^.]+$/, "");
|
||||
return base.replace(/[^a-zA-Z0-9-_]+/g, "-").slice(0, 64) || "document";
|
||||
}
|
||||
|
||||
function tmpFile(ext: string): string {
|
||||
const hash = crypto.randomBytes(6).toString("hex");
|
||||
return path.join(os.tmpdir(), `make-pdf-${process.pid}-${hash}.${ext}`);
|
||||
}
|
||||
|
||||
function tryOpen(pathOrUrl: string): void {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" :
|
||||
platform === "win32" ? "cmd" :
|
||||
"xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", pathOrUrl] : [pathOrUrl];
|
||||
try {
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
} catch {
|
||||
// Non-fatal; the caller already has the path and will print it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup-only re-export so cli.ts can dynamic-import without another file. */
|
||||
export { ExitCode };
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* pdftotext wrapper — the tool behind the copy-paste CI gate.
|
||||
*
|
||||
* Codex round 2 surfaced two real problems we address here:
|
||||
*
|
||||
* #18: pdftotext (Poppler) vs pdftotext (Xpdf) vs pdftotext-next vary on
|
||||
* whitespace, line wrap, Unicode normalization, form feeds, and
|
||||
* extraction order. Cross-platform exact diffing is a non-starter.
|
||||
* We normalize aggressively and diff the normalized form.
|
||||
*
|
||||
* #19: the regex /(?:\b\w\s){4,}/ only catches one failure shape (letters
|
||||
* spaced out). It misses word-order corruption, missing whitespace
|
||||
* between paragraphs, and homoglyph substitution. We add a word-token
|
||||
* diff and a paragraph-boundary assertion on top.
|
||||
*
|
||||
* Resolution order for the pdftotext binary:
|
||||
* 1. $PDFTOTEXT_BIN env override
|
||||
* 2. `which pdftotext` on PATH
|
||||
* 3. standard Homebrew paths on macOS
|
||||
* 4. throws a friendly "install poppler" error
|
||||
*
|
||||
* The wrapper is *optional at runtime*: production renders don't need it.
|
||||
* Only the CI gate and unit tests invoke pdftotext.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export class PdftotextUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PdftotextUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface PdftotextInfo {
|
||||
bin: string;
|
||||
version: string; // "pdftotext version 24.02.0" or similar
|
||||
flavor: "poppler" | "xpdf" | "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate pdftotext. Throws PdftotextUnavailableError if none is found.
|
||||
*/
|
||||
export function resolvePdftotext(): PdftotextInfo {
|
||||
const envOverride = process.env.PDFTOTEXT_BIN;
|
||||
if (envOverride && isExecutable(envOverride)) {
|
||||
return describeBinary(envOverride);
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
try {
|
||||
const which = execFileSync("which", ["pdftotext"], { encoding: "utf8" }).trim();
|
||||
if (which && isExecutable(which)) return describeBinary(which);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Common macOS Homebrew locations
|
||||
const macCandidates = [
|
||||
"/opt/homebrew/bin/pdftotext", // Apple Silicon
|
||||
"/usr/local/bin/pdftotext", // Intel Mac or Linuxbrew
|
||||
"/usr/bin/pdftotext", // distro package
|
||||
];
|
||||
for (const candidate of macCandidates) {
|
||||
if (isExecutable(candidate)) return describeBinary(candidate);
|
||||
}
|
||||
|
||||
throw new PdftotextUnavailableError([
|
||||
"pdftotext not found.",
|
||||
"",
|
||||
"make-pdf needs pdftotext to run the copy-paste CI gate.",
|
||||
"(Runtime rendering does NOT need it. This only affects tests.)",
|
||||
"",
|
||||
"To install:",
|
||||
" macOS: brew install poppler",
|
||||
" Ubuntu: sudo apt-get install poppler-utils",
|
||||
" Fedora: sudo dnf install poppler-utils",
|
||||
"",
|
||||
"Or set PDFTOTEXT_BIN to an explicit path:",
|
||||
" export PDFTOTEXT_BIN=/path/to/pdftotext",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function isExecutable(p: string): boolean {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeBinary(bin: string): PdftotextInfo {
|
||||
let version = "unknown";
|
||||
let flavor: PdftotextInfo["flavor"] = "unknown";
|
||||
try {
|
||||
// pdftotext -v writes to stderr and exits 0 on poppler, 99 on some xpdf builds.
|
||||
const result = execFileSync(bin, ["-v"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
version = (result || "").trim().split("\n")[0] || "unknown";
|
||||
} catch (err: any) {
|
||||
// Many pdftotext builds exit non-zero on -v but still write to stderr.
|
||||
const stderr = err?.stderr?.toString?.() ?? "";
|
||||
version = stderr.trim().split("\n")[0] || "unknown";
|
||||
}
|
||||
const v = version.toLowerCase();
|
||||
if (v.includes("poppler")) flavor = "poppler";
|
||||
else if (v.includes("xpdf")) flavor = "xpdf";
|
||||
return { bin, version, flavor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pdftotext on a PDF and return the extracted text.
|
||||
*
|
||||
* Uses `-layout` by default because that's what downstream normalization
|
||||
* expects. Callers that need raw text can pass layout=false.
|
||||
*/
|
||||
export function pdftotext(pdfPath: string, opts?: { layout?: boolean }): string {
|
||||
const info = resolvePdftotext();
|
||||
const layout = opts?.layout ?? true;
|
||||
const args: string[] = [];
|
||||
if (layout) args.push("-layout");
|
||||
args.push(pdfPath, "-"); // "-" = stdout
|
||||
try {
|
||||
return execFileSync(info.bin, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error(`pdftotext failed on ${pdfPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize extracted text for cross-platform, cross-flavor diffing.
|
||||
*
|
||||
* What we strip / normalize:
|
||||
* - Unicode: NFC canonical composition (macOS emits NFD; Linux emits NFC;
|
||||
* this dodges the fundamental encoding diff).
|
||||
* - CR and CRLF → LF (Windows Xpdf emits CRLF).
|
||||
* - Form feeds (\f) → double newline (Poppler emits \f at page breaks).
|
||||
* - Trailing spaces on every line.
|
||||
* - Runs of 3+ blank lines → 2 blank lines.
|
||||
* - Leading/trailing whitespace on the whole string.
|
||||
* - Non-breaking space (U+00A0) → regular space.
|
||||
* - Zero-width space (U+200B) and zero-width non-joiner (U+200C) → empty.
|
||||
* - Soft hyphen (U+00AD) → empty (pdftotext -layout sometimes emits these
|
||||
* for hyphens: auto breaks).
|
||||
*/
|
||||
export function normalize(raw: string): string {
|
||||
let s = raw;
|
||||
s = s.normalize("NFC");
|
||||
s = s.replace(/\r\n/g, "\n");
|
||||
s = s.replace(/\r/g, "\n");
|
||||
s = s.replace(/\f/g, "\n\n");
|
||||
s = s.replace(/\u00a0/g, " ");
|
||||
s = s.replace(/[\u200b\u200c\u00ad]/g, "");
|
||||
s = s.replace(/[ \t]+$/gm, "");
|
||||
s = s.replace(/\n{3,}/g, "\n\n");
|
||||
s = s.trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical copy-paste gate used in the E2E tests.
|
||||
*
|
||||
* Returns { ok: true } when all three assertions pass; returns
|
||||
* { ok: false, reasons: [...] } with one or more failure reasons otherwise.
|
||||
*/
|
||||
export interface GateResult {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
extracted: string;
|
||||
}
|
||||
|
||||
export function copyPasteGate(pdfPath: string, expected: string): GateResult {
|
||||
const extracted = normalize(pdftotext(pdfPath, { layout: true }));
|
||||
const expectedNorm = normalize(expected);
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Assertion 1: every expected paragraph appears as a whole line or
|
||||
// contiguous block in the extracted text.
|
||||
const expectedParagraphs = splitParagraphs(expectedNorm);
|
||||
for (const paragraph of expectedParagraphs) {
|
||||
const compact = collapseWhitespace(paragraph);
|
||||
const extractedCompact = collapseWhitespace(extracted);
|
||||
if (!extractedCompact.includes(compact)) {
|
||||
reasons.push(
|
||||
`expected paragraph not found in extracted text: ${truncate(paragraph, 80)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Assertion 2: no "S a i l i n g"-style single-char runs.
|
||||
// Count groups of 4+ consecutive letter-then-space tokens. False positive
|
||||
// risk on things like "A B C D" (initials) — mitigate by requiring the
|
||||
// letters spell a known-word substring of the expected text.
|
||||
const fragRegex = /((?:\b\w\s){4,})/g;
|
||||
let fragMatch: RegExpExecArray | null;
|
||||
while ((fragMatch = fragRegex.exec(extracted)) !== null) {
|
||||
const letters = fragMatch[1].replace(/\s/g, "");
|
||||
// Only flag if the reassembled letters appear in the expected text.
|
||||
if (expectedNorm.toLowerCase().includes(letters.toLowerCase()) && letters.length >= 4) {
|
||||
reasons.push(
|
||||
`per-glyph emission detected (the "S ai li ng" bug): "${fragMatch[1].trim()}" reassembles to "${letters}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Assertion 3: paragraph boundaries preserved. Count double-newlines
|
||||
// in both; they should differ by no more than ±2 (header/footer noise).
|
||||
const expectedBreaks = (expectedNorm.match(/\n\n/g) || []).length;
|
||||
const extractedBreaks = (extracted.match(/\n\n/g) || []).length;
|
||||
if (Math.abs(expectedBreaks - extractedBreaks) > 4) {
|
||||
reasons.push(
|
||||
`paragraph boundary count drift: expected ~${expectedBreaks}, got ${extractedBreaks}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: reasons.length === 0, reasons, extracted };
|
||||
}
|
||||
|
||||
function splitParagraphs(s: string): string[] {
|
||||
return s.split(/\n\n+/).map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
|
||||
function collapseWhitespace(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n) + "..." : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit diagnostic info to stderr — useful for CI failure debugging.
|
||||
* Call this once before running any gate in a CI log.
|
||||
*/
|
||||
export function logDiagnostics(): void {
|
||||
try {
|
||||
const info = resolvePdftotext();
|
||||
process.stderr.write(
|
||||
`[pdftotext] bin=${info.bin} flavor=${info.flavor} version="${info.version}" ` +
|
||||
`os=${os.platform()}-${os.arch()} node=${process.version}\n`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
process.stderr.write(`[pdftotext] unavailable: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Print stylesheet generator.
|
||||
*
|
||||
* Source of truth: .context/designs/make-pdf-print-reference.html and siblings.
|
||||
* Mirror those CSS rules here. The HTML references were approved via
|
||||
* /plan-design-review with explicit design decisions locked in the plan:
|
||||
*
|
||||
* - Helvetica only (system font, no bundled webfonts — dodges the
|
||||
* per-glyph Tj bug that breaks copy-paste extraction).
|
||||
* - All paragraphs flush-left. No first-line indent, no justify, no
|
||||
* p+p indent. text-align: left everywhere. 12pt margin-bottom.
|
||||
* - Cover page has the same 1in margins as every other page. No flexbox
|
||||
* center, no inset padding, no vertical centering. Distinction comes
|
||||
* from eyebrow + larger title + hairline rule, not from centering.
|
||||
* - `@page :first` suppresses running header/footer but does NOT override
|
||||
* the 1in margin.
|
||||
* - No <link>, no external CSS/fonts — everything inlined.
|
||||
* - CJK fallback: Helvetica, Arial, Hiragino Kaku Gothic ProN, Noto Sans
|
||||
* CJK JP, Microsoft YaHei, sans-serif.
|
||||
*/
|
||||
|
||||
export interface PrintCssOptions {
|
||||
// Document structure
|
||||
cover?: boolean;
|
||||
toc?: boolean;
|
||||
noChapterBreaks?: boolean;
|
||||
|
||||
// Branding
|
||||
watermark?: string;
|
||||
confidential?: boolean;
|
||||
|
||||
// Header (running title, top of page)
|
||||
runningHeader?: string;
|
||||
|
||||
// Page size (in CSS `@page size:` terms)
|
||||
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
||||
|
||||
// Margins (default 1in)
|
||||
margins?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a CSS block (no <style> wrapper) for inline injection.
|
||||
*/
|
||||
export function printCss(opts: PrintCssOptions = {}): string {
|
||||
const size = opts.pageSize ?? "letter";
|
||||
const margin = opts.margins ?? "1in";
|
||||
const hasWatermark = typeof opts.watermark === "string" && opts.watermark.length > 0;
|
||||
|
||||
return [
|
||||
pageRules(size, margin, opts),
|
||||
rootTypography(),
|
||||
coverRules(opts.cover === true),
|
||||
tocRules(opts.toc === true),
|
||||
chapterRules(opts.noChapterBreaks === true),
|
||||
blockRules(),
|
||||
inlineRules(),
|
||||
codeRules(),
|
||||
quoteRules(),
|
||||
figureRules(),
|
||||
tableRules(),
|
||||
listRules(),
|
||||
footnoteRules(),
|
||||
hasWatermark ? watermarkRules() : "",
|
||||
breakAvoidRules(),
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
|
||||
const runningHeader = escapeCssString(opts.runningHeader ?? "");
|
||||
const showConfidential = opts.confidential !== false;
|
||||
|
||||
return [
|
||||
`@page {`,
|
||||
` size: ${size};`,
|
||||
` margin: ${margin};`,
|
||||
runningHeader
|
||||
? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }`
|
||||
: ``,
|
||||
` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }`,
|
||||
showConfidential
|
||||
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
||||
: ``,
|
||||
`}`,
|
||||
``,
|
||||
// Cover page: suppress running header/footer but keep margins.
|
||||
`@page :first {`,
|
||||
` @top-center { content: none; }`,
|
||||
` @bottom-center { content: none; }`,
|
||||
` @bottom-right { content: none; }`,
|
||||
`}`,
|
||||
].filter(line => line !== "").join("\n");
|
||||
}
|
||||
|
||||
function rootTypography(): string {
|
||||
return [
|
||||
`html { lang: en; }`,
|
||||
`body {`,
|
||||
` font-family: Helvetica, Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`,
|
||||
` font-size: 11pt;`,
|
||||
` line-height: 1.5;`,
|
||||
` color: #111;`,
|
||||
` background: white;`,
|
||||
` hyphens: auto;`,
|
||||
` font-variant-ligatures: common-ligatures;`,
|
||||
` font-kerning: normal;`,
|
||||
` text-rendering: geometricPrecision;`,
|
||||
` margin: 0;`,
|
||||
` padding: 0;`,
|
||||
`}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function coverRules(enabled: boolean): string {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`.cover {`,
|
||||
` page: first;`,
|
||||
` page-break-after: always;`,
|
||||
` break-after: page;`,
|
||||
` text-align: left;`,
|
||||
`}`,
|
||||
`.cover .eyebrow {`,
|
||||
` font-size: 9pt;`,
|
||||
` letter-spacing: 0.2em;`,
|
||||
` text-transform: uppercase;`,
|
||||
` color: #666;`,
|
||||
` margin: 0 0 36pt;`,
|
||||
`}`,
|
||||
`.cover h1.cover-title {`,
|
||||
` font-size: 32pt;`,
|
||||
` line-height: 1.15;`,
|
||||
` font-weight: 700;`,
|
||||
` letter-spacing: -0.01em;`,
|
||||
` margin: 0 0 18pt;`,
|
||||
` max-width: 5.5in;`,
|
||||
` text-align: left;`,
|
||||
`}`,
|
||||
`.cover .cover-subtitle {`,
|
||||
` font-size: 14pt;`,
|
||||
` line-height: 1.4;`,
|
||||
` font-weight: 400;`,
|
||||
` color: #333;`,
|
||||
` margin: 0 0 36pt;`,
|
||||
` max-width: 5in;`,
|
||||
` text-align: left;`,
|
||||
`}`,
|
||||
`.cover hr.rule {`,
|
||||
` width: 2.5in;`,
|
||||
` height: 0;`,
|
||||
` border: 0;`,
|
||||
` border-top: 1px solid #111;`,
|
||||
` margin: 0 0 18pt 0;`,
|
||||
`}`,
|
||||
`.cover .cover-meta { font-size: 10pt; line-height: 1.6; color: #333; text-align: left; }`,
|
||||
`.cover .cover-meta strong { font-weight: 700; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function tocRules(enabled: boolean): string {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`.toc { page-break-after: always; break-after: page; }`,
|
||||
`.toc h2 {`,
|
||||
` font-size: 13pt;`,
|
||||
` text-transform: uppercase;`,
|
||||
` letter-spacing: 0.15em;`,
|
||||
` color: #666;`,
|
||||
` font-weight: 600;`,
|
||||
` margin: 0 0 0.5in;`,
|
||||
`}`,
|
||||
`.toc ol {`,
|
||||
` list-style: none;`,
|
||||
` padding: 0;`,
|
||||
` margin: 0;`,
|
||||
`}`,
|
||||
`.toc li {`,
|
||||
` display: flex;`,
|
||||
` align-items: baseline;`,
|
||||
` gap: 0.25in;`,
|
||||
` font-size: 11pt;`,
|
||||
` line-height: 2;`,
|
||||
` padding: 4pt 0;`,
|
||||
`}`,
|
||||
`.toc li .toc-title { flex: 0 0 auto; }`,
|
||||
`.toc li .toc-dots { flex: 1 1 auto; border-bottom: 1px dotted #aaa; margin: 0 6pt; transform: translateY(-4pt); }`,
|
||||
`.toc li .toc-page { flex: 0 0 auto; color: #666; font-variant-numeric: tabular-nums; }`,
|
||||
`.toc li.level-2 { padding-left: 0.35in; font-size: 10pt; }`,
|
||||
`.toc li a { color: inherit; text-decoration: none; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function chapterRules(noChapterBreaks: boolean): string {
|
||||
const breakRule = noChapterBreaks
|
||||
? `/* chapter breaks disabled */`
|
||||
: [
|
||||
`.chapter { break-before: page; page-break-before: always; }`,
|
||||
`.chapter:first-of-type { break-before: auto; page-break-before: auto; }`,
|
||||
].join("\n");
|
||||
return [
|
||||
breakRule,
|
||||
`h1 {`,
|
||||
` font-size: 22pt;`,
|
||||
` line-height: 1.2;`,
|
||||
` font-weight: 700;`,
|
||||
` letter-spacing: -0.01em;`,
|
||||
` margin: 0 0 0.25in;`,
|
||||
` break-after: avoid;`,
|
||||
` page-break-after: avoid;`,
|
||||
`}`,
|
||||
`h2 { font-size: 15pt; line-height: 1.3; font-weight: 700; margin: 24pt 0 6pt; break-after: avoid; page-break-after: avoid; }`,
|
||||
`h3 { font-size: 12pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 18pt 0 4pt; break-after: avoid; page-break-after: avoid; }`,
|
||||
`h4 { font-size: 11pt; font-weight: 700; margin: 12pt 0 4pt; break-after: avoid; page-break-after: avoid; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function blockRules(): string {
|
||||
// Flush-left paragraphs, no indent, 12pt gap. No justify.
|
||||
// Rule from the plan's "Body paragraph rule (post-review fix)".
|
||||
return [
|
||||
`p {`,
|
||||
` margin: 0 0 12pt;`,
|
||||
` text-align: left;`,
|
||||
` widows: 3;`,
|
||||
` orphans: 3;`,
|
||||
`}`,
|
||||
`p:first-child { margin-top: 0; }`,
|
||||
`p.lead { font-size: 13pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function inlineRules(): string {
|
||||
return [
|
||||
`a {`,
|
||||
` color: #0055cc;`,
|
||||
` text-decoration: underline;`,
|
||||
` text-decoration-thickness: 0.5pt;`,
|
||||
` text-underline-offset: 1.5pt;`,
|
||||
`}`,
|
||||
`strong { font-weight: 700; }`,
|
||||
`em { font-style: italic; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function codeRules(): string {
|
||||
return [
|
||||
`code {`,
|
||||
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
||||
` font-size: 9.5pt;`,
|
||||
` background: #f4f4f4;`,
|
||||
` padding: 1pt 3pt;`,
|
||||
` border-radius: 2pt;`,
|
||||
` border: 0.5pt solid #e4e4e4;`,
|
||||
`}`,
|
||||
`pre {`,
|
||||
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
||||
` font-size: 9pt;`,
|
||||
` line-height: 1.4;`,
|
||||
` background: #f7f7f5;`,
|
||||
` padding: 10pt 12pt;`,
|
||||
` border: 0.5pt solid #e0e0e0;`,
|
||||
` border-radius: 3pt;`,
|
||||
` margin: 12pt 0;`,
|
||||
` overflow: hidden;`,
|
||||
` white-space: pre-wrap;`,
|
||||
`}`,
|
||||
`pre code { background: none; border: 0; padding: 0; font-size: inherit; }`,
|
||||
// highlight.js minimal palette (kept neutral, prints well)
|
||||
`.hljs-keyword { color: #8b0000; font-weight: 500; }`,
|
||||
`.hljs-string { color: #0d6608; }`,
|
||||
`.hljs-comment { color: #888; font-style: italic; }`,
|
||||
`.hljs-function, .hljs-title { color: #0044aa; }`,
|
||||
`.hljs-number { color: #a64d00; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function quoteRules(): string {
|
||||
return [
|
||||
`blockquote {`,
|
||||
` margin: 12pt 0;`,
|
||||
` padding: 0 0 0 18pt;`,
|
||||
` border-left: 2pt solid #111;`,
|
||||
` color: #333;`,
|
||||
` font-size: 11pt;`,
|
||||
` line-height: 1.5;`,
|
||||
`}`,
|
||||
`blockquote p { margin-bottom: 6pt; text-align: left; }`,
|
||||
`blockquote cite { display: block; margin-top: 6pt; font-style: normal; font-size: 9.5pt; color: #666; letter-spacing: 0.02em; }`,
|
||||
`blockquote cite::before { content: "— "; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function figureRules(): string {
|
||||
return [
|
||||
`figure { margin: 12pt 0; }`,
|
||||
`figure img { display: block; max-width: 100%; height: auto; }`,
|
||||
`figcaption { font-size: 9pt; color: #666; margin-top: 6pt; font-style: italic; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function tableRules(): string {
|
||||
return [
|
||||
`table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 10pt; }`,
|
||||
`th, td { border-bottom: 0.5pt solid #ccc; padding: 5pt 8pt; text-align: left; vertical-align: top; }`,
|
||||
`th { font-weight: 700; border-bottom: 1pt solid #111; background: transparent; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function listRules(): string {
|
||||
return [
|
||||
`ul, ol { margin: 0 0 12pt 0; padding-left: 20pt; }`,
|
||||
`li { margin-bottom: 3pt; line-height: 1.45; }`,
|
||||
`li > ul, li > ol { margin-top: 3pt; margin-bottom: 0; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function footnoteRules(): string {
|
||||
return [
|
||||
`.footnote-ref { font-size: 0.75em; vertical-align: super; line-height: 0; text-decoration: none; color: #0055cc; }`,
|
||||
`.footnotes { margin-top: 24pt; padding-top: 12pt; border-top: 0.5pt solid #ccc; font-size: 9.5pt; line-height: 1.4; }`,
|
||||
`.footnotes ol { padding-left: 18pt; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function watermarkRules(): string {
|
||||
return [
|
||||
`.watermark {`,
|
||||
` position: fixed;`,
|
||||
` top: 50%;`,
|
||||
` left: 50%;`,
|
||||
` transform: translate(-50%, -50%) rotate(-30deg);`,
|
||||
` font-size: 140pt;`,
|
||||
` font-weight: 700;`,
|
||||
` color: rgba(200, 0, 0, 0.06);`,
|
||||
` letter-spacing: 0.08em;`,
|
||||
` pointer-events: none;`,
|
||||
` z-index: 9999;`,
|
||||
` user-select: none;`,
|
||||
` white-space: nowrap;`,
|
||||
`}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function breakAvoidRules(): string {
|
||||
return `blockquote, pre, code, table, figure, li, .keep-together { break-inside: avoid; page-break-inside: avoid; }`;
|
||||
}
|
||||
|
||||
function escapeCssString(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Markdown → HTML renderer. Pure function, no I/O, no Playwright.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. marked parses markdown → HTML
|
||||
* 2. Sanitize: strip <script>, <iframe>, <object>, <embed>, <link>,
|
||||
* <meta>, <base>, <form>, and all on* event handlers + javascript:
|
||||
* URLs. (Codex round 2 #9: untrusted markdown can embed raw HTML.)
|
||||
* 3. Smartypants transform (code/URL-safe).
|
||||
* 4. Assemble full HTML document with print CSS inlined and
|
||||
* semantic structure (cover, TOC placeholder, body).
|
||||
*/
|
||||
|
||||
import { marked } from "marked";
|
||||
import { smartypants } from "./smartypants";
|
||||
import { printCss, type PrintCssOptions } from "./print-css";
|
||||
|
||||
export interface RenderOptions {
|
||||
markdown: string;
|
||||
|
||||
// Document-level metadata (used for cover, PDF metadata, running header).
|
||||
title?: string;
|
||||
author?: string;
|
||||
date?: string; // ISO or human string
|
||||
subtitle?: string;
|
||||
|
||||
// Features
|
||||
cover?: boolean;
|
||||
toc?: boolean;
|
||||
watermark?: string;
|
||||
noChapterBreaks?: boolean;
|
||||
confidential?: boolean; // default: true
|
||||
|
||||
// Page layout
|
||||
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
||||
margins?: string;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
html: string; // full HTML document, ready for $B load-html
|
||||
printCss: string; // for debugging / preview
|
||||
bodyHtml: string; // just the rendered body (tests, snapshots)
|
||||
meta: {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
wordCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer. No side effects.
|
||||
*/
|
||||
export function render(opts: RenderOptions): RenderResult {
|
||||
// 1. Markdown → HTML
|
||||
const rawHtml = marked.parse(opts.markdown, { async: false }) as string;
|
||||
|
||||
// 2. Sanitize
|
||||
const cleanHtml = sanitizeUntrustedHtml(rawHtml);
|
||||
|
||||
// 3. Decode common entities so smartypants can match raw " and '.
|
||||
// marked HTML-encodes quotes in text ("hello" → "hello");
|
||||
// without decoding, smartypants' regex never fires. These get re-encoded
|
||||
// implicitly by the browser's HTML parser downstream, and for the ones
|
||||
// that should stay as curly-quote Unicode, that IS the final form.
|
||||
const decoded = decodeTypographicEntities(cleanHtml);
|
||||
|
||||
// 4. Smartypants (code-safe)
|
||||
const typographicHtml = smartypants(decoded);
|
||||
|
||||
// 4. Derive metadata (title from first H1 if not provided)
|
||||
const derivedTitle = opts.title ?? extractFirstHeading(typographicHtml) ?? "Document";
|
||||
const derivedAuthor = opts.author ?? "";
|
||||
const derivedDate = opts.date ?? formatToday();
|
||||
|
||||
// 5. Build CSS
|
||||
const cssOptions: PrintCssOptions = {
|
||||
cover: opts.cover,
|
||||
toc: opts.toc,
|
||||
noChapterBreaks: opts.noChapterBreaks,
|
||||
watermark: opts.watermark,
|
||||
confidential: opts.confidential !== false,
|
||||
runningHeader: derivedTitle,
|
||||
pageSize: opts.pageSize,
|
||||
margins: opts.margins,
|
||||
};
|
||||
const css = printCss(cssOptions);
|
||||
|
||||
// 6. Assemble document
|
||||
const coverBlock = opts.cover
|
||||
? buildCoverBlock({
|
||||
title: derivedTitle,
|
||||
subtitle: opts.subtitle,
|
||||
author: derivedAuthor,
|
||||
date: derivedDate,
|
||||
})
|
||||
: "";
|
||||
|
||||
const tocBlock = opts.toc
|
||||
? buildTocBlock(typographicHtml)
|
||||
: "";
|
||||
|
||||
// Wrap body in .chapter sections at H1 boundaries if chapter breaks are on.
|
||||
const chapterHtml = opts.noChapterBreaks
|
||||
? `<section class="chapter">${typographicHtml}</section>`
|
||||
: wrapChaptersByH1(typographicHtml);
|
||||
|
||||
const watermarkBlock = opts.watermark
|
||||
? `<div class="watermark">${escapeHtml(opts.watermark)}</div>`
|
||||
: "";
|
||||
|
||||
const fullHtml = [
|
||||
`<!doctype html>`,
|
||||
`<html lang="en">`,
|
||||
`<head>`,
|
||||
`<meta charset="utf-8">`,
|
||||
`<title>${escapeHtml(derivedTitle)}</title>`,
|
||||
derivedAuthor ? `<meta name="author" content="${escapeHtml(derivedAuthor)}">` : ``,
|
||||
`<style>`,
|
||||
css,
|
||||
`</style>`,
|
||||
`</head>`,
|
||||
`<body>`,
|
||||
watermarkBlock,
|
||||
coverBlock,
|
||||
tocBlock,
|
||||
chapterHtml,
|
||||
`</body>`,
|
||||
`</html>`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
html: fullHtml,
|
||||
printCss: css,
|
||||
bodyHtml: typographicHtml,
|
||||
meta: {
|
||||
title: derivedTitle,
|
||||
author: derivedAuthor,
|
||||
date: derivedDate,
|
||||
wordCount: countWords(stripTags(typographicHtml)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the HTML entities that marked emits for text-node quotes/apostrophes.
|
||||
* Only the four that matter for smartypants — leaves & alone because it
|
||||
* can be legitimately doubled (&amp;) and we don't want to double-decode.
|
||||
*/
|
||||
function decodeTypographicEntities(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ─── Sanitizer ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip dangerous HTML from markdown-produced output.
|
||||
*
|
||||
* We can't use DOMPurify (server-side; adds a jsdom dep). A conservative
|
||||
* regex sanitizer is fine for this use case because:
|
||||
* 1. marked produces structured HTML (never malformed)
|
||||
* 2. we only need to strip a fixed blacklist of elements + attrs
|
||||
* 3. the output goes through Chromium's parser again, which normalizes
|
||||
*
|
||||
* What's stripped:
|
||||
* - <script>, <iframe>, <object>, <embed>, <link>, <meta>, <base>, <form>
|
||||
* (and their content).
|
||||
* - on* event handler attributes (onclick, ONCLICK, etc.).
|
||||
* - href/src with javascript: scheme.
|
||||
* - <svg> tags with <script> inside them.
|
||||
*/
|
||||
export function sanitizeUntrustedHtml(html: string): string {
|
||||
let s = html;
|
||||
|
||||
// Elements to remove entirely (including content).
|
||||
const DANGER_TAGS = [
|
||||
"script", "iframe", "object", "embed", "link", "meta", "base", "form",
|
||||
"applet", "frame", "frameset",
|
||||
];
|
||||
for (const tag of DANGER_TAGS) {
|
||||
const re = new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}>`, "gi");
|
||||
s = s.replace(re, "");
|
||||
// Self-closing / unclosed variants
|
||||
const selfRe = new RegExp(`<${tag}\\b[^>]*/?>`, "gi");
|
||||
s = s.replace(selfRe, "");
|
||||
}
|
||||
|
||||
// SVG <script>
|
||||
s = s.replace(/<svg([^>]*)>([\s\S]*?)<\/svg>/gi, (_, attrs, body) => {
|
||||
return `<svg${attrs}>${body.replace(/<script\b[\s\S]*?<\/script>/gi, "")}</svg>`;
|
||||
});
|
||||
|
||||
// Event handler attributes (on* in any case).
|
||||
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*"[^"]*"/gi, "");
|
||||
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*'[^']*'/gi, "");
|
||||
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*[^\s>]+/gi, "");
|
||||
|
||||
// javascript: URLs in href/src/action/formaction
|
||||
s = s.replace(
|
||||
/(\s(?:href|src|action|formaction|xlink:href)\s*=\s*)(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
|
||||
'$1"#"',
|
||||
);
|
||||
|
||||
// srcdoc attribute (iframe escape hatch — already stripped via iframe above,
|
||||
// but defense-in-depth).
|
||||
s = s.replace(/\s+srcdoc\s*=\s*"[^"]*"/gi, "");
|
||||
s = s.replace(/\s+srcdoc\s*=\s*'[^']*'/gi, "");
|
||||
|
||||
// style="url(javascript:..)" — strip javascript: inside style attrs.
|
||||
s = s.replace(/url\(\s*javascript:[^)]*\)/gi, "url(#)");
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// ─── Cover / TOC / Chapter helpers ────────────────────────────────────
|
||||
|
||||
function buildCoverBlock(opts: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
author?: string;
|
||||
date: string;
|
||||
}): string {
|
||||
const title = escapeHtml(opts.title);
|
||||
const subtitle = opts.subtitle ? escapeHtml(opts.subtitle) : "";
|
||||
const author = opts.author ? escapeHtml(opts.author) : "";
|
||||
const date = escapeHtml(opts.date);
|
||||
return [
|
||||
`<section class="cover">`,
|
||||
` <h1 class="cover-title">${title}</h1>`,
|
||||
subtitle ? ` <p class="cover-subtitle">${subtitle}</p>` : ``,
|
||||
` <hr class="rule">`,
|
||||
` <div class="cover-meta">`,
|
||||
author ? ` <div><strong>${author}</strong></div>` : ``,
|
||||
` <div>${date}</div>`,
|
||||
` </div>`,
|
||||
`</section>`,
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML for H1/H2/H3 headings and emit a TOC placeholder.
|
||||
* Page numbers are filled in by Paged.js (when --toc is passed and Paged.js
|
||||
* polyfill is injected).
|
||||
*/
|
||||
function buildTocBlock(html: string): string {
|
||||
const headings = extractHeadings(html);
|
||||
if (headings.length === 0) return "";
|
||||
|
||||
const items = headings.map((h, i) => {
|
||||
const level = h.level >= 2 ? "level-2" : "level-1";
|
||||
const id = `toc-${i}`;
|
||||
return [
|
||||
` <li class="${level}">`,
|
||||
` <span class="toc-title"><a href="#${id}">${escapeHtml(h.text)}</a></span>`,
|
||||
` <span class="toc-dots"></span>`,
|
||||
` <span class="toc-page" data-toc-target="${id}"></span>`,
|
||||
` </li>`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
return [
|
||||
`<section class="toc">`,
|
||||
` <h2>Contents</h2>`,
|
||||
` <ol>`,
|
||||
items,
|
||||
` </ol>`,
|
||||
`</section>`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractHeadings(html: string): Array<{ level: number; text: string }> {
|
||||
const re = /<(h[1-3])[^>]*>([\s\S]*?)<\/\1>/gi;
|
||||
const headings: Array<{ level: number; text: string }> = [];
|
||||
let match;
|
||||
while ((match = re.exec(html)) !== null) {
|
||||
const level = parseInt(match[1].slice(1), 10);
|
||||
const text = stripTags(match[2]).trim();
|
||||
if (text) headings.push({ level, text });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap H1-rooted sections in <section class="chapter">. When chapter breaks
|
||||
* are on (default), CSS `.chapter { break-before: page }` fires between them.
|
||||
*/
|
||||
function wrapChaptersByH1(html: string): string {
|
||||
// Split on H1 openings. Everything before the first H1 is a preamble.
|
||||
const h1Re = /<h1\b[^>]*>/gi;
|
||||
const matches: number[] = [];
|
||||
let m;
|
||||
while ((m = h1Re.exec(html)) !== null) {
|
||||
matches.push(m.index);
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return `<section class="chapter">${html}</section>`;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
const preamble = html.slice(0, matches[0]);
|
||||
if (preamble.trim().length > 0) {
|
||||
chunks.push(`<section class="chapter">${preamble}</section>`);
|
||||
}
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i];
|
||||
const end = i + 1 < matches.length ? matches[i + 1] : html.length;
|
||||
chunks.push(`<section class="chapter">${html.slice(start, end)}</section>`);
|
||||
}
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
function extractFirstHeading(html: string): string | null {
|
||||
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
return m ? stripTags(m[1]).trim() : null;
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, "");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/).filter(w => w.length > 0).length;
|
||||
}
|
||||
|
||||
function formatToday(): string {
|
||||
const now = new Date();
|
||||
return now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* `$P setup` — guided smoke test.
|
||||
*
|
||||
* Flow (per the CEO plan CLI UX spec):
|
||||
* 1. Verify browse binary exists and responds
|
||||
* 2. Verify Chromium launches via $B goto about:blank
|
||||
* 3. Verify pdftotext is installed (warn, don't fail)
|
||||
* 4. Generate a smoke-test PDF from an inline 2-paragraph fixture
|
||||
* 5. Open it
|
||||
* 6. Print a 3-command cheatsheet
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import * as browseClient from "./browseClient";
|
||||
import { resolvePdftotext, PdftotextUnavailableError } from "./pdftotext";
|
||||
import { generate } from "./orchestrator";
|
||||
|
||||
export async function runSetup(): Promise<void> {
|
||||
process.stderr.write("make-pdf setup — verifying install\n\n");
|
||||
|
||||
// 1. Resolve browse binary
|
||||
process.stderr.write(" [1/5] Checking browse binary...");
|
||||
try {
|
||||
const bin = browseClient.resolveBrowseBin();
|
||||
process.stderr.write(` OK (${bin})\n`);
|
||||
} catch (err: any) {
|
||||
process.stderr.write(" FAIL\n");
|
||||
process.stderr.write(`\n${err.message}\n`);
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// 2. Chromium smoke (navigate a dedicated tab to about:blank)
|
||||
process.stderr.write(" [2/5] Launching Chromium...");
|
||||
let chromiumTab: number | null = null;
|
||||
try {
|
||||
chromiumTab = browseClient.newtab("about:blank");
|
||||
process.stderr.write(` OK (tab ${chromiumTab})\n`);
|
||||
} catch (err: any) {
|
||||
process.stderr.write(" FAIL\n");
|
||||
process.stderr.write(`\nChromium failed to launch: ${err.message}\n`);
|
||||
process.stderr.write("\nTo fix: run gstack setup from the gstack repo:\n");
|
||||
process.stderr.write(" cd ~/.claude/skills/gstack && ./setup\n");
|
||||
process.exit(4);
|
||||
} finally {
|
||||
if (chromiumTab !== null) {
|
||||
try { browseClient.closetab(chromiumTab); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. pdftotext (optional — CI gate only)
|
||||
process.stderr.write(" [3/5] Checking pdftotext (optional)...");
|
||||
try {
|
||||
const info = resolvePdftotext();
|
||||
process.stderr.write(` OK (${info.flavor}, ${info.version.split(" ").slice(-1)[0] || "version unknown"})\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(" SKIP\n");
|
||||
if (err instanceof PdftotextUnavailableError) {
|
||||
process.stderr.write(
|
||||
" pdftotext not installed. This is optional — only the CI\n" +
|
||||
" copy-paste gate needs it. To enable:\n" +
|
||||
" macOS: brew install poppler\n" +
|
||||
" Ubuntu: sudo apt-get install poppler-utils\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Render smoke-test PDF
|
||||
process.stderr.write(" [4/5] Generating smoke-test PDF...\n");
|
||||
const fixture = [
|
||||
"# Hello from make-pdf",
|
||||
"",
|
||||
"This is a two-paragraph smoke test. If you can read this sentence in the PDF that just opened, the pipeline works end-to-end.",
|
||||
"",
|
||||
"The second paragraph contains curly quotes (\"hello\"), an em dash -- like this, and an ellipsis... all of which should render correctly.",
|
||||
"",
|
||||
].join("\n");
|
||||
const fixturePath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.md`);
|
||||
const outPath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.pdf`);
|
||||
fs.writeFileSync(fixturePath, fixture, "utf8");
|
||||
|
||||
try {
|
||||
await generate({
|
||||
input: fixturePath,
|
||||
output: outPath,
|
||||
quiet: true,
|
||||
pageNumbers: true,
|
||||
});
|
||||
process.stderr.write(` PASSED. Smoke test saved to ${outPath}\n`);
|
||||
} catch (err: any) {
|
||||
process.stderr.write(` FAILED: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
} finally {
|
||||
try { fs.unlinkSync(fixturePath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 5. Cheatsheet
|
||||
process.stderr.write(" [5/5] All checks passed.\n\n");
|
||||
process.stderr.write([
|
||||
"make-pdf is ready. Try:",
|
||||
" $P generate letter.md # default memo mode",
|
||||
" $P generate --cover --toc essay.md # full publication",
|
||||
" $P generate --watermark DRAFT memo.md # diagonal watermark",
|
||||
"",
|
||||
`Smoke-test PDF: ${outPath}`,
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Inline typographic transform (smartypants).
|
||||
*
|
||||
* Converts ASCII typography to real Unicode:
|
||||
* "quoted" → "quoted" (U+201C/U+201D)
|
||||
* 'quoted' → 'quoted' (U+2018/U+2019)
|
||||
* don't → don't (apostrophe: U+2019)
|
||||
* -- → — (em dash U+2014)
|
||||
* ... → … (ellipsis U+2026)
|
||||
*
|
||||
* Critical: must NOT touch code, URLs, or HTML attributes. The Codex round
|
||||
* 2 review flagged this specifically — smartypants run over a fenced code
|
||||
* block corrupts the code and tokens inside tag attributes can break
|
||||
* parsing.
|
||||
*
|
||||
* This operates on HTML (marked already produced it) and walks text nodes
|
||||
* only via a lightweight regex that recognizes code/pre/URL zones and
|
||||
* skips them entirely.
|
||||
*/
|
||||
|
||||
const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
||||
const TAG_RE = /<[^>]+>/g;
|
||||
const URL_RE = /\bhttps?:\/\/\S+/g;
|
||||
|
||||
/**
|
||||
* Apply smartypants to an HTML string. Zones that should not be touched:
|
||||
* - <pre>, <code>, <script>, <style> blocks (content unchanged)
|
||||
* - HTML tags themselves (attributes unchanged)
|
||||
* - URLs (http:// and https:// spans unchanged)
|
||||
*/
|
||||
export function smartypants(html: string): string {
|
||||
// Step 1: split into preserved + transformed zones.
|
||||
// Preserved zones: code/pre/script/style, tags, URLs.
|
||||
// We carve them out with placeholder tokens, transform the rest, and
|
||||
// splice them back.
|
||||
const preserved: string[] = [];
|
||||
const PLACEHOLDER = (i: number) => `\u0000SMARTPANTS_PRESERVED_${i}\u0000`;
|
||||
|
||||
const carve = (source: string, pattern: RegExp): string => {
|
||||
return source.replace(pattern, (match) => {
|
||||
const idx = preserved.length;
|
||||
preserved.push(match);
|
||||
return PLACEHOLDER(idx);
|
||||
});
|
||||
};
|
||||
|
||||
let s = html;
|
||||
s = carve(s, CODE_ZONE_RE);
|
||||
s = carve(s, TAG_RE);
|
||||
s = carve(s, URL_RE);
|
||||
|
||||
s = transformText(s);
|
||||
|
||||
// Step 2: restore preserved zones.
|
||||
// Use a function to avoid $-substitution gotchas.
|
||||
s = s.replace(/\u0000SMARTPANTS_PRESERVED_(\d+)\u0000/g, (_, idx) => {
|
||||
return preserved[parseInt(idx, 10)] ?? "";
|
||||
});
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform plain text (no HTML, no code, no URLs).
|
||||
*
|
||||
* Order matters:
|
||||
* 1. Triple dots first (so they don't collide with later apostrophes)
|
||||
* 2. Em dashes (two hyphens → em dash)
|
||||
* 3. Apostrophes (contractions + possessives)
|
||||
* 4. Double quotes (open/close pairing)
|
||||
* 5. Single quotes (open/close pairing — after apostrophes)
|
||||
*/
|
||||
function transformText(text: string): string {
|
||||
let s = text;
|
||||
|
||||
// Ellipsis: three literal dots (with optional spaces) → …
|
||||
s = s.replace(/\.\s?\.\s?\./g, "\u2026");
|
||||
|
||||
// Em dash: -- → —. Require space or word-char boundary on both sides so
|
||||
// we don't mangle ARGV-style flags in prose like `--verbose`.
|
||||
s = s.replace(/(\w|\s)--(\w|\s)/g, "$1\u2014$2");
|
||||
// Standalone -- at start/end
|
||||
s = s.replace(/^--\s/gm, "\u2014 ");
|
||||
s = s.replace(/\s--$/gm, " \u2014");
|
||||
|
||||
// Apostrophes in contractions and possessives.
|
||||
// "don't", "it's", "they're", "Garry's"
|
||||
s = s.replace(/(\w)'(\w)/g, "$1\u2019$2");
|
||||
|
||||
// Double quotes: open if preceded by whitespace/bol, close if preceded
|
||||
// by word char or punctuation.
|
||||
s = s.replace(/(^|[\s\(\[\{\-])"/g, "$1\u201c"); // opening "
|
||||
s = s.replace(/"/g, "\u201d"); // remaining " are closing
|
||||
|
||||
// Single quotes (after apostrophe pass):
|
||||
s = s.replace(/(^|[\s\(\[\{\-])'/g, "$1\u2018"); // opening '
|
||||
s = s.replace(/'/g, "\u2019"); // remaining ' are closing
|
||||
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* make-pdf — shared types.
|
||||
*
|
||||
* No runtime code. Imports are safe from any module.
|
||||
*/
|
||||
|
||||
export type PageSize = "letter" | "a4" | "legal" | "tabloid";
|
||||
export type FontMode = "sans"; // v1: Helvetica only. Future: "serif" | "custom".
|
||||
|
||||
/**
|
||||
* Options for `$P generate` — the public CLI contract.
|
||||
* Matches the flag set documented in the CEO plan.
|
||||
*/
|
||||
export interface GenerateOptions {
|
||||
input: string; // markdown input path
|
||||
output?: string; // PDF output path (default: /tmp/<slug>.pdf)
|
||||
|
||||
// Page layout
|
||||
margins?: string; // "1in" | "72pt" | "25mm" | "2.54cm"
|
||||
marginTop?: string;
|
||||
marginRight?: string;
|
||||
marginBottom?: string;
|
||||
marginLeft?: string;
|
||||
pageSize?: PageSize; // default "letter"
|
||||
|
||||
// Document structure
|
||||
cover?: boolean;
|
||||
toc?: boolean;
|
||||
noChapterBreaks?: boolean; // default: chapter breaks ON
|
||||
|
||||
// Branding
|
||||
watermark?: string; // e.g. "DRAFT"
|
||||
headerTemplate?: string; // raw HTML
|
||||
footerTemplate?: string; // raw HTML, mutex with pageNumbers
|
||||
confidential?: boolean; // default: true
|
||||
|
||||
// Output control
|
||||
pageNumbers?: boolean; // default: true
|
||||
tagged?: boolean; // default: true (accessible PDF)
|
||||
outline?: boolean; // default: true (PDF bookmarks)
|
||||
quiet?: boolean; // suppress progress on stderr
|
||||
verbose?: boolean; // per-stage timings on stderr
|
||||
|
||||
// Network
|
||||
allowNetwork?: boolean; // default: false
|
||||
|
||||
// Metadata
|
||||
title?: string;
|
||||
author?: string;
|
||||
date?: string; // ISO-ish; default: today
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `$P preview`.
|
||||
*/
|
||||
export interface PreviewOptions {
|
||||
input: string;
|
||||
quiet?: boolean;
|
||||
verbose?: boolean;
|
||||
// Same render flags as generate so preview matches output
|
||||
cover?: boolean;
|
||||
toc?: boolean;
|
||||
watermark?: string;
|
||||
noChapterBreaks?: boolean;
|
||||
confidential?: boolean;
|
||||
allowNetwork?: boolean;
|
||||
title?: string;
|
||||
author?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed page.pdf() options passed to browse.
|
||||
*/
|
||||
export interface BrowsePdfOptions {
|
||||
output: string;
|
||||
tabId: number;
|
||||
format?: PageSize;
|
||||
width?: string;
|
||||
height?: string;
|
||||
margins?: {
|
||||
top: string;
|
||||
right: string;
|
||||
bottom: string;
|
||||
left: string;
|
||||
};
|
||||
headerTemplate?: string;
|
||||
footerTemplate?: string;
|
||||
pageNumbers?: boolean;
|
||||
displayHeaderFooter?: boolean;
|
||||
tagged?: boolean;
|
||||
outline?: boolean;
|
||||
printBackground?: boolean;
|
||||
preferCSSPageSize?: boolean;
|
||||
toc?: boolean; // signals browse to wait for Paged.js
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit codes for $P generate.
|
||||
* Mirror these in orchestrator error paths.
|
||||
*/
|
||||
export const ExitCode = {
|
||||
Success: 0,
|
||||
BadArgs: 1,
|
||||
RenderError: 2,
|
||||
PagedJsTimeout: 3,
|
||||
BrowseUnavailable: 4,
|
||||
} as const;
|
||||
export type ExitCode = typeof ExitCode[keyof typeof ExitCode];
|
||||
|
||||
/**
|
||||
* Structured error for browse CLI shell-out failures.
|
||||
*/
|
||||
export class BrowseClientError extends Error {
|
||||
constructor(
|
||||
public readonly exitCode: number,
|
||||
public readonly command: string,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`browse ${command} exited ${exitCode}: ${stderr.trim()}`);
|
||||
this.name = "BrowseClientError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* browseClient unit tests — binary resolution and error mapping.
|
||||
*
|
||||
* These are pure unit tests; they do NOT require a running browse daemon.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { BrowseClientError } from "../src/types";
|
||||
import { resolveBrowseBin } from "../src/browseClient";
|
||||
|
||||
describe("resolveBrowseBin", () => {
|
||||
test("throws BrowseClientError with setup hint when nothing is found", () => {
|
||||
// Point every candidate path to a non-existent location.
|
||||
const originalEnv = process.env.BROWSE_BIN;
|
||||
process.env.BROWSE_BIN = "/nonexistent/browse-does-not-exist";
|
||||
|
||||
// We can't easily mock the sibling and global paths without touching
|
||||
// the filesystem, so in a typical dev environment this will usually
|
||||
// find the real browse. That's fine — on CI it will throw, and the
|
||||
// error message shape is what we're actually asserting.
|
||||
let thrown: any = null;
|
||||
try {
|
||||
resolveBrowseBin();
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
if (thrown) {
|
||||
expect(thrown).toBeInstanceOf(BrowseClientError);
|
||||
expect(thrown.message).toContain("browse binary not found");
|
||||
expect(thrown.message).toContain("./setup");
|
||||
expect(thrown.message).toContain("BROWSE_BIN");
|
||||
}
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.BROWSE_BIN;
|
||||
} else {
|
||||
process.env.BROWSE_BIN = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("honors BROWSE_BIN when it points at a real executable", () => {
|
||||
const originalEnv = process.env.BROWSE_BIN;
|
||||
// `/bin/sh` exists on every POSIX system and is executable.
|
||||
process.env.BROWSE_BIN = "/bin/sh";
|
||||
|
||||
try {
|
||||
const resolved = resolveBrowseBin();
|
||||
expect(resolved).toBe("/bin/sh");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.BROWSE_BIN;
|
||||
} else {
|
||||
process.env.BROWSE_BIN = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowseClientError", () => {
|
||||
test("captures exit code, command, and stderr", () => {
|
||||
const err = new BrowseClientError(127, "pdf", "Chromium not found");
|
||||
expect(err.exitCode).toBe(127);
|
||||
expect(err.command).toBe("pdf");
|
||||
expect(err.stderr).toBe("Chromium not found");
|
||||
expect(err.message).toContain("browse pdf exited 127");
|
||||
expect(err.message).toContain("Chromium not found");
|
||||
expect(err.name).toBe("BrowseClientError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Combined-features copy-paste gate — the P0 CI gate.
|
||||
*
|
||||
* This test runs the compiled `make-pdf/dist/pdf` binary against a fixture
|
||||
* that has every v1 typography feature on (smartypants, hyphens, chapter
|
||||
* breaks, bold/italic, inline code, blockquote, lists, headings). It then
|
||||
* pipes the output through pdftotext and asserts the extracted text
|
||||
* matches the handwritten expected.txt.
|
||||
*
|
||||
* Codex round 2 told us this (not per-feature gates) is the real gate a
|
||||
* user actually cares about — features interact, and the combined
|
||||
* extraction is what predicts production quality.
|
||||
*
|
||||
* Gating: only runs when the compiled binary + browse + pdftotext are all
|
||||
* available. Skipped cleanly otherwise (local dev without full install).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { copyPasteGate, resolvePdftotext } from "../../src/pdftotext";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/combined-gate.md");
|
||||
const EXPECTED = path.resolve(__dirname, "../fixtures/combined-gate.expected.txt");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!fs.existsSync(EXPECTED)) return { ok: false, reason: `expected.txt missing (${EXPECTED}).` };
|
||||
try { resolvePdftotext(); } catch (err: any) { return { ok: false, reason: err.message }; }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
describe("combined-features copy-paste gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("fixture PDF extracts cleanly through pdftotext", () => {
|
||||
if (!avail.ok) return; // satisfies the type checker
|
||||
// Use /tmp directly (browse's validateOutputPath allows /private/tmp,
|
||||
// which macOS resolves /tmp to). os.tmpdir() returns /var/folders/...
|
||||
// which is outside the safe-dirs allowlist.
|
||||
const outputPdf = `/tmp/make-pdf-combined-gate-${process.pid}.pdf`;
|
||||
try {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
expect(fs.existsSync(outputPdf)).toBe(true);
|
||||
|
||||
const expected = fs.readFileSync(EXPECTED, "utf8");
|
||||
const result = copyPasteGate(outputPdf, expected);
|
||||
if (!result.ok) {
|
||||
// Attach the extracted text so CI logs make the failure diagnosable
|
||||
process.stderr.write(`\n--- EXTRACTED ---\n${result.extracted}\n--- END ---\n\n`);
|
||||
process.stderr.write(`--- REASONS ---\n${result.reasons.join("\n")}\n--- END ---\n`);
|
||||
}
|
||||
expect(result.ok).toBe(true);
|
||||
} finally {
|
||||
try { fs.unlinkSync(outputPdf); } catch { /* ignore */ }
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
if (!avail.ok) {
|
||||
test("prerequisites check", () => {
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
The Horizon
|
||||
This is the combined-features fixture. Every feature turned on simultaneously. The gate asserts that all of these paragraphs extract cleanly from the PDF with pdftotext.
|
||||
|
||||
A paragraph with bold, italic, and inline code tokens — each of which gets a different HTML treatment. None should fragment text on copy-paste.
|
||||
|
||||
A paragraph with “curly quotes”, ‘single quotes’, an em dash — like this, and an ellipsis… All three get smartypants transforms.
|
||||
|
||||
A subsection heading
|
||||
|
||||
First list item with some words that keep it on one line.
|
||||
Second list item with more words.
|
||||
Third list item.
|
||||
|
||||
A blockquote from Van Dyke. Her diminished size is in me, not in her.
|
||||
|
||||
A second chapter
|
||||
|
||||
This content begins on a fresh page because the default chapter-breaks rule fires. Extract must still find these paragraphs.
|
||||
|
||||
A final paragraph with enough words to trigger hyphenation across the line wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary ones certainly do.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# The Horizon
|
||||
|
||||
This is the combined-features fixture. Every feature turned on simultaneously.
|
||||
The gate asserts that all of these paragraphs extract cleanly from the PDF
|
||||
with pdftotext.
|
||||
|
||||
A paragraph with **bold**, *italic*, and `inline code` tokens — each of which
|
||||
gets a different HTML treatment. None should fragment text on copy-paste.
|
||||
|
||||
A paragraph with "curly quotes", 'single quotes', an em dash -- like this,
|
||||
and an ellipsis... All three get smartypants transforms.
|
||||
|
||||
## A subsection heading
|
||||
|
||||
Lists must not break mid-item:
|
||||
|
||||
- First list item with some words that keep it on one line.
|
||||
- Second list item with more words.
|
||||
- Third list item.
|
||||
|
||||
> A blockquote from Van Dyke. Her diminished size is in me, not in her.
|
||||
|
||||
# A second chapter
|
||||
|
||||
This content begins on a fresh page because the default chapter-breaks rule
|
||||
fires. Extract must still find these paragraphs.
|
||||
|
||||
A final paragraph with enough words to trigger hyphenation across the line
|
||||
wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary
|
||||
ones certainly do.
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* pdftotext unit tests — normalize() and copyPasteGate() assertions.
|
||||
*
|
||||
* These tests are pure unit tests of the normalization + assertion logic.
|
||||
* They do NOT require pdftotext to be installed (the actual binary is
|
||||
* mocked by manipulating strings directly).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalize, copyPasteGate } from "../src/pdftotext";
|
||||
|
||||
describe("normalize", () => {
|
||||
test("strips trailing spaces", () => {
|
||||
expect(normalize("hello \nworld")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("collapses runs of 3+ blank lines to 2", () => {
|
||||
expect(normalize("a\n\n\n\nb")).toBe("a\n\nb");
|
||||
});
|
||||
|
||||
test("converts form feeds to double newlines (page break boundary)", () => {
|
||||
expect(normalize("page1\fpage2")).toBe("page1\n\npage2");
|
||||
});
|
||||
|
||||
test("normalizes CRLF and CR to LF (Windows Xpdf)", () => {
|
||||
expect(normalize("a\r\nb\rc")).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
test("removes soft hyphens (hyphens: auto artifact)", () => {
|
||||
expect(normalize("extra\u00adordinary")).toBe("extraordinary");
|
||||
});
|
||||
|
||||
test("replaces non-breaking space with regular space", () => {
|
||||
expect(normalize("hello\u00a0world")).toBe("hello world");
|
||||
});
|
||||
|
||||
test("strips zero-width characters", () => {
|
||||
expect(normalize("a\u200bb\u200cc")).toBe("abc");
|
||||
});
|
||||
|
||||
test("NFC-normalizes composed glyphs (macOS NFD → Linux NFC)", () => {
|
||||
// "é" composed vs decomposed
|
||||
const decomposed = "e\u0301";
|
||||
const composed = "\u00e9";
|
||||
expect(normalize(decomposed)).toBe(composed);
|
||||
});
|
||||
|
||||
test("trims leading/trailing whitespace on whole string", () => {
|
||||
expect(normalize("\n\n hello \n\n")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyPasteGate — assertion logic", () => {
|
||||
// These tests exercise the gate's internal assertions by mocking the
|
||||
// pdftotext step. We can't easily run the real binary in every test
|
||||
// env, so we verify the assertion logic directly via fake inputs.
|
||||
//
|
||||
// The gate takes a PDF path — but assertion #1 (paragraph presence) and
|
||||
// #2 (per-glyph emission) are string operations we can validate here.
|
||||
|
||||
test("flags 'S ai li ng' per-glyph emission when reassembled letters appear in source", () => {
|
||||
// Build expected/extracted strings that would trip the gate.
|
||||
const expected = "Sailing on the open sea.";
|
||||
const extracted = "S a i l i n g on the open sea.";
|
||||
// Simulate by running normalize + assertion manually; the regex is
|
||||
// looked at in the gate.
|
||||
const fragRegex = /((?:\b\w\s){4,})/g;
|
||||
const match = fragRegex.exec(extracted);
|
||||
expect(match).not.toBeNull();
|
||||
if (match) {
|
||||
const letters = match[1].replace(/\s/g, "");
|
||||
expect(letters.toLowerCase()).toBe("sailing");
|
||||
expect(expected.toLowerCase().includes(letters.toLowerCase())).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does NOT flag 'A B C D' as per-glyph when letters don't appear in source", () => {
|
||||
const expected = "The quick brown fox.";
|
||||
const extracted = "The quick A B C D brown fox.";
|
||||
const fragRegex = /((?:\b\w\s){4,})/g;
|
||||
const match = fragRegex.exec(extracted);
|
||||
if (match) {
|
||||
const letters = match[1].replace(/\s/g, "");
|
||||
// "ABCD" is not a substring of expected
|
||||
expect(expected.toLowerCase().includes(letters.toLowerCase())).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("paragraph boundary count drift calculation", () => {
|
||||
const expected = "para1\n\npara2\n\npara3";
|
||||
const extractedOk = "para1\n\npara2\n\npara3";
|
||||
const extractedTooFew = "para1 para2 para3";
|
||||
const extractedTooMany = "para1\n\n\n\npara2\n\n\n\npara3\n\n\n\npara4\n\n\n\npara5";
|
||||
|
||||
const expectedBreaks = (expected.match(/\n\n/g) || []).length;
|
||||
const okBreaks = (extractedOk.match(/\n\n/g) || []).length;
|
||||
const tooFewBreaks = (extractedTooFew.match(/\n\n/g) || []).length;
|
||||
const tooManyBreaksNormalized = (normalize(extractedTooMany).match(/\n\n/g) || []).length;
|
||||
|
||||
expect(Math.abs(expectedBreaks - okBreaks)).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs(expectedBreaks - tooFewBreaks)).toBeGreaterThan(1);
|
||||
// After normalize, 3+ newlines become 2, so the count matches
|
||||
expect(Math.abs(expectedBreaks - tooManyBreaksNormalized)).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Renderer unit tests — pure-function assertions for render.ts, smartypants.ts,
|
||||
* and print-css.ts. No Playwright, no PDF generation.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { render, sanitizeUntrustedHtml } from "../src/render";
|
||||
import { smartypants } from "../src/smartypants";
|
||||
import { printCss } from "../src/print-css";
|
||||
|
||||
// ─── smartypants ──────────────────────────────────────────────
|
||||
|
||||
describe("smartypants", () => {
|
||||
test("converts straight double quotes to curly", () => {
|
||||
const out = smartypants(`<p>She said "hello" to him.</p>`);
|
||||
expect(out).toContain("\u201chello\u201d");
|
||||
});
|
||||
|
||||
test("converts em dash (--)", () => {
|
||||
const out = smartypants(`<p>This is it -- the answer.</p>`);
|
||||
expect(out).toContain("\u2014");
|
||||
});
|
||||
|
||||
test("converts ellipsis (...)", () => {
|
||||
const out = smartypants(`<p>Wait...</p>`);
|
||||
expect(out).toContain("\u2026");
|
||||
});
|
||||
|
||||
test("converts apostrophes in contractions", () => {
|
||||
const out = smartypants(`<p>don't you know?</p>`);
|
||||
expect(out).toContain("don\u2019t");
|
||||
});
|
||||
|
||||
test("does NOT touch content inside <code> blocks", () => {
|
||||
const input = `<pre><code>const x = "hello"; // it's fine</code></pre>`;
|
||||
const out = smartypants(input);
|
||||
expect(out).toBe(input); // unchanged
|
||||
});
|
||||
|
||||
test("does NOT touch content inside <pre> blocks", () => {
|
||||
const input = `<pre>"quoted" -- don't</pre>`;
|
||||
const out = smartypants(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
test("does NOT touch inline code", () => {
|
||||
const out = smartypants(`<p>Use <code>it's</code> like this: "hello".</p>`);
|
||||
expect(out).toContain("<code>it's</code>");
|
||||
expect(out).toContain("\u201chello\u201d");
|
||||
});
|
||||
|
||||
test("does NOT touch URLs", () => {
|
||||
const out = smartypants(`<p>Visit https://example.com/it's-page for "details".</p>`);
|
||||
expect(out).toContain("https://example.com/it's-page");
|
||||
expect(out).toContain("\u201cdetails\u201d");
|
||||
});
|
||||
|
||||
test("does NOT touch HTML attribute values", () => {
|
||||
const out = smartypants(`<a href="it's-a-test.html">link</a>`);
|
||||
expect(out).toContain(`href="it's-a-test.html"`);
|
||||
});
|
||||
|
||||
test("does NOT convert -- in CLI flags", () => {
|
||||
// Prose like "try --verbose mode" should not turn -- into em dash
|
||||
const out = smartypants(`<p>Try --verbose mode.</p>`);
|
||||
// Since "--" is followed by a word char but not preceded by word/space,
|
||||
// it should remain intact. We're lenient here — acceptable either way.
|
||||
expect(out).toMatch(/--verbose|—verbose/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizer ──────────────────────────────────────────────
|
||||
|
||||
describe("sanitizeUntrustedHtml", () => {
|
||||
test("strips <script> tags and content", () => {
|
||||
const input = `<p>hello</p><script>alert(1)</script><p>world</p>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("<script");
|
||||
expect(out).not.toContain("alert");
|
||||
expect(out).toContain("<p>hello</p>");
|
||||
expect(out).toContain("<p>world</p>");
|
||||
});
|
||||
|
||||
test("strips <iframe>", () => {
|
||||
const input = `<p>hi</p><iframe src="evil.com"></iframe>`;
|
||||
expect(sanitizeUntrustedHtml(input)).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
test("strips onclick attribute", () => {
|
||||
const input = `<a href="#" onclick="alert(1)">click</a>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("onclick");
|
||||
expect(out).toContain("href=\"#\"");
|
||||
});
|
||||
|
||||
test("strips event handlers with mixed case (onClick, ONCLICK)", () => {
|
||||
const input1 = `<a href="#" onClick="x()">a</a>`;
|
||||
const input2 = `<a href="#" ONCLICK="x()">b</a>`;
|
||||
expect(sanitizeUntrustedHtml(input1)).not.toContain("onClick");
|
||||
expect(sanitizeUntrustedHtml(input2)).not.toContain("ONCLICK");
|
||||
});
|
||||
|
||||
test("rewrites javascript: URLs in href to #", () => {
|
||||
const input = `<a href="javascript:alert(1)">bad</a>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("javascript:");
|
||||
expect(out).toContain('href="#"');
|
||||
});
|
||||
|
||||
test("strips inline SVG <script>", () => {
|
||||
const input = `<svg><script>alert(1)</script><circle r="5"/></svg>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("<script");
|
||||
expect(out).toContain("<circle");
|
||||
});
|
||||
|
||||
test("strips <object>, <embed>, <link>, <meta>, <base>, <form>", () => {
|
||||
const input = `
|
||||
<object data="x.swf"></object>
|
||||
<embed src="y.mov">
|
||||
<link rel="stylesheet" href="evil.css">
|
||||
<meta http-equiv="refresh" content="0;url=evil">
|
||||
<base href="evil.com">
|
||||
<form action="evil"><input/></form>
|
||||
`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("<object");
|
||||
expect(out).not.toContain("<embed");
|
||||
expect(out).not.toContain("<link");
|
||||
expect(out).not.toContain("<meta");
|
||||
expect(out).not.toContain("<base");
|
||||
expect(out).not.toContain("<form");
|
||||
});
|
||||
|
||||
test("strips srcdoc attribute (iframe escape vector)", () => {
|
||||
const input = `<div srcdoc="<script>bad</script>">hi</div>`;
|
||||
expect(sanitizeUntrustedHtml(input)).not.toContain("srcdoc");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── end-to-end render ──────────────────────────────────────────────
|
||||
|
||||
describe("render (end-to-end)", () => {
|
||||
test("produces a full HTML document with title, body, and CSS", () => {
|
||||
const result = render({
|
||||
markdown: `# Hello\n\nA paragraph with "quotes" and -- dashes.\n`,
|
||||
});
|
||||
expect(result.html).toContain("<!doctype html>");
|
||||
expect(result.html).toContain("<title>Hello</title>");
|
||||
expect(result.html).toContain("<h1");
|
||||
expect(result.html).toContain("Hello");
|
||||
// CSS should be inlined as <style>...
|
||||
expect(result.html).toMatch(/<style>[\s\S]*font-family: Helvetica/);
|
||||
// Smartypants ran
|
||||
expect(result.html).toContain("\u201cquotes\u201d");
|
||||
expect(result.html).toContain("\u2014");
|
||||
});
|
||||
|
||||
test("derives title from first H1 when --title is not passed", () => {
|
||||
const result = render({ markdown: `# My Title\n\nBody.` });
|
||||
expect(result.meta.title).toBe("My Title");
|
||||
});
|
||||
|
||||
test("uses --title override when provided", () => {
|
||||
const result = render({
|
||||
markdown: `# Auto-derived\n\nBody.`,
|
||||
title: "Explicit Title",
|
||||
});
|
||||
expect(result.meta.title).toBe("Explicit Title");
|
||||
});
|
||||
|
||||
test("includes cover block when cover=true", () => {
|
||||
const result = render({
|
||||
markdown: `# Doc\n\nBody.`,
|
||||
cover: true,
|
||||
subtitle: "A subtitle",
|
||||
author: "Garry Tan",
|
||||
});
|
||||
expect(result.html).toContain(`class="cover"`);
|
||||
expect(result.html).toContain(`class="cover-title"`);
|
||||
expect(result.html).toContain("A subtitle");
|
||||
expect(result.html).toContain("Garry Tan");
|
||||
});
|
||||
|
||||
test("omits cover block when cover=false", () => {
|
||||
const result = render({ markdown: `# Memo\n\nBody.` });
|
||||
expect(result.html).not.toContain(`class="cover"`);
|
||||
});
|
||||
|
||||
test("injects watermark element when --watermark is set", () => {
|
||||
const result = render({ markdown: `# Doc`, watermark: "DRAFT" });
|
||||
expect(result.html).toContain(`class="watermark"`);
|
||||
expect(result.html).toContain("DRAFT");
|
||||
// And the CSS rule for it must be present
|
||||
expect(result.html).toContain("position: fixed");
|
||||
expect(result.html).toContain("rotate(-30deg)");
|
||||
});
|
||||
|
||||
test("wraps each H1 in its own .chapter section (default)", () => {
|
||||
const result = render({
|
||||
markdown: `# One\n\nbody 1\n\n# Two\n\nbody 2\n`,
|
||||
});
|
||||
const chapterMatches = result.html.match(/class="chapter"/g);
|
||||
expect(chapterMatches).toBeTruthy();
|
||||
if (chapterMatches) expect(chapterMatches.length).toBe(2);
|
||||
});
|
||||
|
||||
test("does NOT create chapter sections when noChapterBreaks=true", () => {
|
||||
const result = render({
|
||||
markdown: `# One\n\nbody\n\n# Two\n\nbody\n`,
|
||||
noChapterBreaks: true,
|
||||
});
|
||||
const chapterMatches = result.html.match(/class="chapter"/g) ?? [];
|
||||
expect(chapterMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
test("builds a TOC with H1/H2 entries when toc=true", () => {
|
||||
const result = render({
|
||||
markdown: `# One\n\n## Sub\n\nbody\n\n# Two\n\nbody\n`,
|
||||
toc: true,
|
||||
});
|
||||
expect(result.html).toContain(`class="toc"`);
|
||||
expect(result.html).toContain(`<h2>Contents</h2>`);
|
||||
expect(result.html).toContain("One");
|
||||
expect(result.html).toContain("Sub");
|
||||
expect(result.html).toContain("Two");
|
||||
});
|
||||
|
||||
test("strips dangerous HTML from untrusted markdown", () => {
|
||||
const result = render({
|
||||
markdown: `# Safe\n\n<script>alert('xss')</script>\n\nBody.`,
|
||||
});
|
||||
expect(result.html).not.toContain("<script");
|
||||
expect(result.html).not.toContain("alert");
|
||||
expect(result.html).toContain("Safe");
|
||||
});
|
||||
|
||||
test("respects text-align: left — no justify in print CSS", () => {
|
||||
const result = render({ markdown: `para1\n\npara2\n` });
|
||||
// The rule from the design-review fix: no p + p indent, text-align: left.
|
||||
expect(result.printCss).toContain("text-align: left");
|
||||
expect(result.printCss).not.toContain("text-align: justify");
|
||||
expect(result.printCss).not.toContain("text-indent");
|
||||
});
|
||||
|
||||
test("includes CJK font fallback in body", () => {
|
||||
const result = render({ markdown: `body` });
|
||||
expect(result.printCss).toContain("Hiragino Kaku Gothic");
|
||||
expect(result.printCss).toContain("Noto Sans CJK");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── print-css ──────────────────────────────────────────────
|
||||
|
||||
describe("printCss", () => {
|
||||
test("emits 1in margins by default", () => {
|
||||
const css = printCss();
|
||||
expect(css).toContain("margin: 1in");
|
||||
});
|
||||
|
||||
test("respects custom margins flag", () => {
|
||||
const css = printCss({ margins: "72pt" });
|
||||
expect(css).toContain("margin: 72pt");
|
||||
});
|
||||
|
||||
test("emits letter page size by default", () => {
|
||||
const css = printCss();
|
||||
expect(css).toContain("size: letter");
|
||||
});
|
||||
|
||||
test("respects custom page size", () => {
|
||||
const css = printCss({ pageSize: "a4" });
|
||||
expect(css).toContain("size: a4");
|
||||
});
|
||||
|
||||
test("suppresses running header and footer on cover page", () => {
|
||||
const css = printCss();
|
||||
expect(css).toMatch(/@page\s*:first\s*\{[\s\S]*?content:\s*none[\s\S]*?content:\s*none/);
|
||||
});
|
||||
|
||||
test("omits CONFIDENTIAL when confidential=false", () => {
|
||||
const css = printCss({ confidential: false });
|
||||
expect(css).not.toContain("CONFIDENTIAL");
|
||||
});
|
||||
|
||||
test("emits watermark CSS only when watermark is set", () => {
|
||||
const withWatermark = printCss({ watermark: "DRAFT" });
|
||||
expect(withWatermark).toContain(".watermark");
|
||||
expect(withWatermark).toContain("rotate(-30deg)");
|
||||
|
||||
const withoutWatermark = printCss();
|
||||
expect(withoutWatermark).not.toContain(".watermark");
|
||||
});
|
||||
|
||||
test("drops chapter break rule when noChapterBreaks=true", () => {
|
||||
const on = printCss({ noChapterBreaks: false });
|
||||
expect(on).toContain("break-before: page");
|
||||
|
||||
const off = printCss({ noChapterBreaks: true });
|
||||
expect(off).not.toContain(".chapter { break-before: page");
|
||||
});
|
||||
|
||||
test("always sets p { text-align: left }", () => {
|
||||
const css = printCss();
|
||||
expect(css).toContain("text-align: left");
|
||||
});
|
||||
|
||||
test("never sets text-indent on p", () => {
|
||||
const css = printCss();
|
||||
// Confirm no p-indent slipped in
|
||||
expect(css).not.toMatch(/p\s*\+\s*p\s*\{[^}]*text-indent/);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -110,12 +118,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -49,6 +49,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"open-gstack-browser","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -99,12 +107,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+7
-4
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.4.0.0",
|
||||
"version": "1.5.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"browse": "./browse/dist/browse"
|
||||
"browse": "./browse/dist/browse",
|
||||
"make-pdf": "./make-pdf/dist/pdf"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run gen:skill-docs --host all; bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile design/src/cli.ts --outfile design/dist/design && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && git rev-parse HEAD > design/dist/.version && chmod +x browse/dist/browse browse/dist/find-browse design/dist/design bin/gstack-global-discover && (rm -f .*.bun-build || true)",
|
||||
"build": "bun run gen:skill-docs --host all; bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile design/src/cli.ts --outfile design/dist/design && bun build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && git rev-parse HEAD > design/dist/.version && git rev-parse HEAD > make-pdf/dist/.version && chmod +x browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover && (rm -f .*.bun-build || true)",
|
||||
"dev:make-pdf": "bun run make-pdf/src/cli.ts",
|
||||
"dev:design": "bun run design/src/cli.ts",
|
||||
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
|
||||
"dev": "bun run browse/src/cli.ts",
|
||||
"server": "bun run browse/src/server.ts",
|
||||
"test": "bun test browse/test/ test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)",
|
||||
"test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)",
|
||||
"test:evals": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
|
||||
"test:evals:all": "EVALS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
|
||||
"test:e2e": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
|
||||
@@ -41,6 +43,7 @@
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"diff": "^7.0.0",
|
||||
"marked": "^18.0.2",
|
||||
"playwright": "^1.58.2",
|
||||
"puppeteer-core": "^24.40.0"
|
||||
},
|
||||
|
||||
+8
-6
@@ -50,6 +50,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"pair-agent","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -100,12 +108,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -56,6 +56,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -106,12 +114,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -53,6 +53,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"plan-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -103,12 +111,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -57,6 +57,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"plan-devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -107,12 +115,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"plan-eng-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -63,6 +63,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"plan-tune","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -113,12 +121,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -51,6 +51,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"qa-only","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -101,12 +109,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -57,6 +57,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -107,12 +115,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -50,6 +50,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"retro","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -100,12 +108,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -54,6 +54,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -104,12 +112,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -21,6 +21,7 @@ import { generateDxFramework } from './dx';
|
||||
import { generateModelOverlay } from './model-overlay';
|
||||
import { generateGBrainContextLoad, generateGBrainSaveResults } from './gbrain';
|
||||
import { generateQuestionPreferenceCheck, generateQuestionLog, generateInlineTuneFeedback } from './question-tuning';
|
||||
import { generateMakePdfSetup } from './make-pdf';
|
||||
|
||||
export const RESOLVERS: Record<string, ResolverFn> = {
|
||||
SLUG_EVAL: generateSlugEval,
|
||||
@@ -74,4 +75,5 @@ export const RESOLVERS: Record<string, ResolverFn> = {
|
||||
QUESTION_PREFERENCE_CHECK: generateQuestionPreferenceCheck,
|
||||
QUESTION_LOG: generateQuestionLog,
|
||||
INLINE_TUNE_FEEDBACK: generateInlineTuneFeedback,
|
||||
MAKE_PDF_SETUP: generateMakePdfSetup,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TemplateContext } from './types';
|
||||
|
||||
/**
|
||||
* {{MAKE_PDF_SETUP}} — emits the shell preamble that resolves $P to the
|
||||
* make-pdf binary. Mirrors generateBrowseSetup / generateDesignSetup.
|
||||
*
|
||||
* $P = make-pdf/dist/pdf.
|
||||
*
|
||||
* Resolution order (matches src/browseClient.ts::resolveBrowseBin):
|
||||
* 1. Local skill root: $_ROOT/{localSkillRoot}/make-pdf/dist/pdf
|
||||
* 2. Global: ~/{globalRoot}/make-pdf/dist/pdf
|
||||
* 3. Env override (MAKE_PDF_BIN) — for contributor dev builds
|
||||
*/
|
||||
export function generateMakePdfSetup(ctx: TemplateContext): string {
|
||||
return `## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)
|
||||
|
||||
\`\`\`bash
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
P=""
|
||||
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
|
||||
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" ] && P="$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf"
|
||||
[ -z "$P" ] && P="$HOME${ctx.paths.makePdfDir.replace(/^~/, '')}/pdf"
|
||||
if [ -x "$P" ]; then
|
||||
echo "MAKE_PDF_READY: $P"
|
||||
alias _p_="$P" # shellcheck alias helper (not exported)
|
||||
export P # available as $P in subsequent blocks within the same skill invocation
|
||||
else
|
||||
echo "MAKE_PDF_NOT_AVAILABLE (run './setup' in the gstack repo to build it)"
|
||||
fi
|
||||
\`\`\`
|
||||
|
||||
If \`MAKE_PDF_NOT_AVAILABLE\` is printed: tell the user the binary is not
|
||||
built. Have them run \`./setup\` from the gstack repo, then retry.
|
||||
|
||||
If \`MAKE_PDF_READY\` is printed: \`$P\` is the binary path for the rest of
|
||||
the skill. Use \`$P\` (not an explicit path) so the skill body stays portable.
|
||||
|
||||
Core commands:
|
||||
- \`$P generate <input.md> [output.pdf]\` — render markdown to PDF (80% use case)
|
||||
- \`$P generate --cover --toc essay.md out.pdf\` — full publication layout
|
||||
- \`$P generate --watermark DRAFT memo.md draft.pdf\` — diagonal DRAFT watermark
|
||||
- \`$P preview <input.md>\` — render HTML and open in browser (fast iteration)
|
||||
- \`$P setup\` — verify browse + Chromium + pdftotext and run a smoke test
|
||||
- \`$P --help\` — full flag reference
|
||||
|
||||
Output contract:
|
||||
- \`stdout\`: ONLY the output path on success. One line.
|
||||
- \`stderr\`: progress (\`Rendering HTML... Generating PDF...\`) unless \`--quiet\`.
|
||||
- Exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.`;
|
||||
}
|
||||
@@ -41,6 +41,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: \${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(${ctx.paths.binDir}/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(${ctx.paths.binDir}/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -91,12 +99,6 @@ _CHECKPOINT_MODE=$(${ctx.paths.binDir}/gstack-config get checkpoint_mode 2>/dev/
|
||||
_CHECKPOINT_PUSH=$(${ctx.paths.binDir}/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(${ctx.paths.binDir}/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(${ctx.paths.binDir}/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true${ctx.host === 'gbrain' || ctx.host === 'hermes' ? `
|
||||
# GBrain health check (gbrain/hermes host only)
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface HostPaths {
|
||||
binDir: string;
|
||||
browseDir: string;
|
||||
designDir: string;
|
||||
makePdfDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ function buildHostPaths(): Record<string, HostPaths> {
|
||||
binDir: '$GSTACK_BIN',
|
||||
browseDir: '$GSTACK_BROWSE',
|
||||
designDir: '$GSTACK_DESIGN',
|
||||
makePdfDir: '$GSTACK_MAKE_PDF',
|
||||
};
|
||||
} else {
|
||||
const root = `~/${config.globalRoot}`;
|
||||
@@ -39,6 +41,7 @@ function buildHostPaths(): Record<string, HostPaths> {
|
||||
binDir: `${root}/bin`,
|
||||
browseDir: `${root}/browse/dist`,
|
||||
designDir: `${root}/design/dist`,
|
||||
makePdfDir: `${root}/make-pdf/dist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then
|
||||
# signature block is corrupt. This is idempotent and costs <1s.
|
||||
# See: https://github.com/garrytan/gstack/issues/997
|
||||
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
|
||||
for _bin in browse/dist/browse browse/dist/find-browse design/dist/design bin/gstack-global-discover; do
|
||||
for _bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do
|
||||
_bin_path="$SOURCE_GSTACK_DIR/$_bin"
|
||||
[ -f "$_bin_path" ] && [ -x "$_bin_path" ] || continue
|
||||
codesign --remove-signature "$_bin_path" 2>/dev/null || true
|
||||
|
||||
@@ -47,6 +47,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"setup-browser-cookies","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -97,12 +105,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -53,6 +53,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"setup-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -103,12 +111,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -55,6 +55,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -105,12 +113,6 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -44,6 +44,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$($GSTACK_BIN/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -94,12 +102,6 @@ _CHECKPOINT_MODE=$($GSTACK_BIN/gstack-config get checkpoint_mode 2>/dev/null ||
|
||||
_CHECKPOINT_PUSH=$($GSTACK_BIN/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$($GSTACK_BIN/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
+8
-6
@@ -46,6 +46,14 @@ _TEL_START=$(date +%s)
|
||||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
|
||||
# Read on every skill run so terse mode takes effect without a restart.)
|
||||
_EXPLAIN_LEVEL=$($GSTACK_BIN/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning (see /plan-tune). Observational only in V1.
|
||||
_QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
@@ -96,12 +104,6 @@ _CHECKPOINT_MODE=$($GSTACK_BIN/gstack-config get checkpoint_mode 2>/dev/null ||
|
||||
_CHECKPOINT_PUSH=$($GSTACK_BIN/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Writing style: default = jargon-glossed + outcome-framed, terse = V0 prose
|
||||
_EXPLAIN_LEVEL=$($GSTACK_BIN/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
# Question tuning: true = check preferences + log per-question, false = classic
|
||||
_QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null || echo "true")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
# Detect spawned session (OpenClaw or other orchestrator)
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -1103,7 +1103,9 @@ describe('Step 3.4 test coverage audit', () => {
|
||||
test('ship/SKILL.md contains Step 7', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Step 7: Test Coverage Audit');
|
||||
expect(content).toContain('CODE PATHS');
|
||||
// The coverage diagram collapses code-path and user-flow counts onto one
|
||||
// summary line. Verify that summary is present (labels are stable).
|
||||
expect(content).toContain('Code paths:');
|
||||
});
|
||||
|
||||
test('Step 3.4 includes quality scoring rubric', () => {
|
||||
@@ -1153,9 +1155,11 @@ describe('Step 3.4 test coverage audit', () => {
|
||||
expect(content).toContain('Empty/zero/boundary states');
|
||||
});
|
||||
|
||||
test('Step 3.4 diagram includes USER FLOWS section', () => {
|
||||
test('Step 3.4 diagram includes user-flow coverage summary', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('USER FLOWS');
|
||||
// The diagram was compressed from separate CODE PATH COVERAGE / USER FLOW
|
||||
// COVERAGE section headers into a single summary line. Assert on the
|
||||
// labels that still appear on that summary line.
|
||||
expect(content).toContain('Code paths:');
|
||||
expect(content).toContain('User flows:');
|
||||
});
|
||||
@@ -1165,8 +1169,8 @@ describe('Step 3.4 test coverage audit', () => {
|
||||
|
||||
describe('ship step numbering', () => {
|
||||
// Allowed sub-steps that are resolver-generated and intentionally nested:
|
||||
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
|
||||
// 15.0 (WIP Commit Squash), 15.1 (Bisectable Commits)
|
||||
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge),
|
||||
// 9.3 (Cross-review dedup), 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
|
||||
const ALLOWED_SUBSTEPS = new Set(['8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
|
||||
|
||||
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
|
||||
|
||||
Reference in New Issue
Block a user