diff --git a/AGENTS.md b/AGENTS.md index 74e79c1efc..f8e7f32724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ GStack 2 exposes exactly five default public skills. Treat this as a routing surface over preserved specialist judgment, not permission to simplify that -judgment into generic checklists. +judgment into generic checklists. The `make-pdf` tool skill also ships in the +canonical tree (`skills/make-pdf`), so `npx skills add time-attack/gstack/skills` +installs six discoverable skills: the five judgment dispatchers plus make-pdf. ## Public skills diff --git a/CLAUDE.md b/CLAUDE.md index 8214929ffb..dfc2d8096b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,12 +2,15 @@ ## GStack 2 canonical contract -The public surface is exactly `/plan`, `/qa`, `/debug`, `/review`, and `/ship`. +The judgment surface is exactly `/plan`, `/qa`, `/debug`, `/review`, and `/ship`. They are lazy dispatchers over the preserved modules under `skills/*/references/legacy/`; do not rewrite or summarize away the original question pressure, rubrics, pushback, approval gates, evidence, artifacts, mutation boundaries, recommendations, or voice. Compatibility files under -`compat/` route old names and contain no judgment. +`compat/` route old names and contain no judgment. `make-pdf` is a tool skill +(not a judgment dispatcher) emitted into `skills/make-pdf/`; it installs with the +same canonical tree, so `npx skills add time-attack/gstack/skills` yields six +discoverable skills (five dispatchers plus make-pdf). `/plan` has exactly six top-level modes: Discovery, Product, Engineering, DX, Specification, and Full chain. Do not expose its internal aliases as more diff --git a/README.md b/README.md index 3c80c1d953..1eb1c7ef78 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > compatibility surface. GStack 2 presents one engineering judgment layer through exactly five public -skills: +skills (plus the `make-pdf` tool skill, which installs with the same tree): | Skill | Start here when… | |---|---| diff --git a/bun.lock b/bun.lock index 31f309ef14..8d67517343 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@anthropic-ai/sdk": "^0.112.4", "@ngrok/ngrok": "^1.7.0", "diff": "^9.0.0", + "html-to-docx": "1.8.0", "marked": "^18.0.6", "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", @@ -220,6 +221,14 @@ "@ngrok/ngrok-win32-x64-msvc": ["@ngrok/ngrok-win32-x64-msvc@1.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA=="], + "@oozcitak/dom": ["@oozcitak/dom@1.15.6", "", { "dependencies": { "@oozcitak/infra": "1.0.5", "@oozcitak/url": "1.0.0", "@oozcitak/util": "8.3.4" } }, "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g=="], + + "@oozcitak/infra": ["@oozcitak/infra@1.0.5", "", { "dependencies": { "@oozcitak/util": "8.0.0" } }, "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ=="], + + "@oozcitak/url": ["@oozcitak/url@1.0.0", "", { "dependencies": { "@oozcitak/infra": "1.0.3", "@oozcitak/util": "1.0.2" } }, "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ=="], + + "@oozcitak/util": ["@oozcitak/util@8.3.4", "", {}, "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], @@ -256,12 +265,16 @@ "braintrust": ["braintrust@3.24.0", "", { "dependencies": { "@next/env": "^14.2.3", "@vercel/functions": "^1.0.2", "acorn": "^8.16.0", "acorn-import-attributes": "^1.9.5", "ajv": "^8.20.0", "argparse": "^2.0.1", "astring": "^1.9.0", "cjs-module-lexer": "^2.2.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "cors": "^2.8.5", "dc-browser": "^1.0.4", "dotenv": "^16.4.5", "esbuild": "0.28.1", "esquery": "^1.7.0", "eventsource-parser": "^1.1.2", "express": "^5.2.1", "http-errors": "^2.0.0", "meriyah": "^6.1.4", "minimatch": "^10.2.5", "module-details-from-path": "^1.0.4", "mustache": "^4.2.0", "pluralize": "^8.0.0", "semifies": "^1.0.0", "simple-git": "^3.36.0", "source-map": "^0.7.4", "termi-link": "^1.0.1", "unplugin": "^2.3.5", "uuid": "^11.1.1", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@braintrust/bt-darwin-arm64": "0.12.0", "@braintrust/bt-darwin-x64": "0.12.0", "@braintrust/bt-linux-arm64": "0.12.0", "@braintrust/bt-linux-x64": "0.12.0", "@braintrust/bt-linux-x64-musl": "0.12.0", "@braintrust/bt-win32-arm64": "0.12.0", "@braintrust/bt-win32-x64": "0.12.0" }, "peerDependencies": { "zod": "^3.25.34 || ^4.0" }, "bin": { "braintrust": "dist/cli.js", "bt": "bin/bt" } }, "sha512-jF2XK1AImY2jI6uPxxU/KNGyYIIsr4PhaUD1IsbdggSNLIBkwzmluKvCzs1OUVy6LbpL4T2ekv8t+83UQ64hEg=="], + "browser-split": ["browser-split@0.0.1", "", {}, "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "cheminfo-types": ["cheminfo-types@1.15.0", "", {}, "sha512-shv45WN2u0yN9EHH1bisNrv+fy4Cw+eLM5lOoriP67mePrwbHZ1kJqg90C8GEU7K1A8gJsicEoVZHcuBbuul/w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -270,6 +283,8 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "compute-cosine-similarity": ["compute-cosine-similarity@1.1.0", "", { "dependencies": { "compute-dot": "^1.1.0", "compute-l2norm": "^1.1.0", "validate.io-array": "^1.0.5", "validate.io-function": "^1.0.2" } }, "sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw=="], "compute-dot": ["compute-dot@1.1.0", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2" } }, "sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg=="], @@ -284,6 +299,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -298,6 +315,16 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dom-serializer": ["dom-serializer@0.2.2", "", { "dependencies": { "domelementtype": "^2.0.1", "entities": "^2.0.0" } }, "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g=="], + + "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + + "domelementtype": ["domelementtype@1.3.1", "", {}, "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="], + + "domhandler": ["domhandler@2.4.2", "", { "dependencies": { "domelementtype": "1" } }, "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA=="], + + "domutils": ["domutils@1.7.0", "", { "dependencies": { "dom-serializer": "0", "domelementtype": "1" } }, "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -308,6 +335,12 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "ent": ["ent@2.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "punycode": "^1.4.1", "safe-regex-test": "^1.1.0" } }, "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw=="], + + "entities": ["entities@1.1.2", "", {}, "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="], + + "error": ["error@4.4.0", "", { "dependencies": { "camelize": "^1.0.0", "string-template": "~0.2.0", "xtend": "~4.0.0" } }, "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -324,6 +357,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "ev-store": ["ev-store@7.0.0", "", { "dependencies": { "individual": "^3.0.0" } }, "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@1.1.2", "", {}, "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA=="], @@ -352,18 +387,38 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + + "html-to-docx": ["html-to-docx@1.8.0", "", { "dependencies": { "@oozcitak/dom": "1.15.6", "@oozcitak/util": "8.3.4", "color-name": "^1.1.4", "html-entities": "^2.3.3", "html-to-vdom": "^0.7.0", "image-size": "^1.0.0", "image-to-base64": "^2.2.0", "jszip": "^3.7.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "^3.1.25", "virtual-dom": "^2.1.1", "xmlbuilder2": "2.1.2" } }, "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA=="], + + "html-to-vdom": ["html-to-vdom@0.7.0", "", { "dependencies": { "ent": "^2.0.0", "htmlparser2": "^3.8.2" } }, "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ=="], + + "htmlparser2": ["htmlparser2@3.10.1", "", { "dependencies": { "domelementtype": "^1.3.1", "domhandler": "^2.3.0", "domutils": "^1.5.1", "entities": "^1.1.1", "inherits": "^2.0.1", "readable-stream": "^3.1.1" } }, "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + + "image-to-base64": ["image-to-base64@2.2.0", "", { "dependencies": { "node-fetch": "^2.6.0" } }, "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "individual": ["individual@3.0.0", "", {}, "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -374,8 +429,14 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-object": ["is-object@1.0.2", "", {}, "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], @@ -390,8 +451,14 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "linear-sum-assignment": ["linear-sum-assignment@1.0.9", "", { "dependencies": { "cheminfo-types": "^1.8.1", "ml-matrix": "^6.12.1", "ml-spectra-processing": "^14.18.0" } }, "sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -402,9 +469,11 @@ "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -426,8 +495,14 @@ "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "next-tick": ["next-tick@0.2.2", "", {}, "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -438,6 +513,8 @@ "openai": ["openai@6.48.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -452,18 +529,32 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], @@ -474,6 +565,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -502,14 +595,20 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-template": ["string-template@0.2.1", "", {}, "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "termi-link": ["termi-link@1.1.0", "", {}, "sha512-2qSN6TnomHgVLtk+htSWbaYs4Rd2MH/RU7VpHTy6MBstyNyWbM4yKd1DCYpE3fDg8dmGWojXCngNi/MHCzGuAA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -520,6 +619,8 @@ "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], "validate.io-array": ["validate.io-array@1.0.6", "", {}, "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg=="], @@ -528,12 +629,26 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "virtual-dom": ["virtual-dom@2.1.1", "", { "dependencies": { "browser-split": "0.0.1", "error": "^4.3.0", "ev-store": "^7.0.0", "global": "^4.3.0", "is-object": "^1.0.1", "next-tick": "^0.2.2", "x-is-array": "0.1.0", "x-is-string": "0.1.0" } }, "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "x-is-array": ["x-is-array@0.1.0", "", {}, "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="], + + "x-is-string": ["x-is-string@0.1.0", "", {}, "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="], + + "xmlbuilder2": ["xmlbuilder2@2.1.2", "", { "dependencies": { "@oozcitak/dom": "1.15.5", "@oozcitak/infra": "1.0.5", "@oozcitak/util": "8.3.3" } }, "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], @@ -542,10 +657,46 @@ "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], + + "@oozcitak/url/@oozcitak/infra": ["@oozcitak/infra@1.0.3", "", { "dependencies": { "@oozcitak/util": "1.0.1" } }, "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag=="], + + "@oozcitak/url/@oozcitak/util": ["@oozcitak/util@1.0.2", "", {}, "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA=="], + + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "braintrust/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "braintrust/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "htmlparser2/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "xmlbuilder2/@oozcitak/dom": ["@oozcitak/dom@1.15.5", "", { "dependencies": { "@oozcitak/infra": "1.0.5", "@oozcitak/url": "1.0.0", "@oozcitak/util": "8.0.0" } }, "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A=="], + + "xmlbuilder2/@oozcitak/util": ["@oozcitak/util@8.3.3", "", {}, "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w=="], + + "@oozcitak/url/@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@1.0.1", "", {}, "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg=="], + + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "xmlbuilder2/@oozcitak/dom/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], } } diff --git a/docs/gstack-2/ARCHITECTURE.md b/docs/gstack-2/ARCHITECTURE.md index 42d545352b..956302d2d6 100644 --- a/docs/gstack-2/ARCHITECTURE.md +++ b/docs/gstack-2/ARCHITECTURE.md @@ -12,6 +12,11 @@ GStack 2 is an engineering judgment layer with exactly five public skills: plan -> qa -> debug -> review -> ship ``` +The canonical tree also ships one tool skill, `make-pdf` (Markdown to +publication-quality PDF). It is not a mode-based judgment dispatcher, but it is +emitted into `skills/make-pdf/` so it installs with the same +`npx skills add time-attack/gstack/skills` (six discoverable skills total). + This is a navigation surface, not a linear workflow and not a compression of specialists into generic prompts. Each dispatcher selects a mode from structured stage, surface, artifact, mutation-authority, evidence, and deploy diff --git a/docs/gstack-2/JUDGMENT-PARITY.md b/docs/gstack-2/JUDGMENT-PARITY.md index 299e91cf0f..101a4cbb0d 100644 --- a/docs/gstack-2/JUDGMENT-PARITY.md +++ b/docs/gstack-2/JUDGMENT-PARITY.md @@ -2,11 +2,11 @@ Parity is executable, not a prose claim. Run `bun run scripts/gstack2/run-parity.ts` or the dedicated Bun tests. -The pinned release inventory passes **4,359 checks** across 47 specialist sources, 14 carved sections, 19 routing scenarios, 24 regression ports, and **70 assets**. +The pinned release inventory passes **4,367 checks** across 47 specialist sources, 14 carved sections, 19 routing scenarios, 24 regression ports, and **70 assets**. The suite verifies: -- exactly five discoverable public skills and 47 internal legacy modules; +- exactly five discoverable judgment dispatchers plus the make-pdf tool skill (six discoverable skills) and 47 internal legacy modules; - 47 canonical templates plus 14 carved section templates at base `bb57306d98c97011b0919c6132705a15b1579781`; - immutable full 1.x render hashes plus canonical specialist-render equality, with the excluded onboarding wrapper and lazy section references asserted explicitly; - preservation of nine behavioral contract dimensions per module; diff --git a/docs/gstack-2/JUDGMENT-PROVENANCE.json b/docs/gstack-2/JUDGMENT-PROVENANCE.json index 40f9bf6ca6..ee1df7b659 100644 --- a/docs/gstack-2/JUDGMENT-PROVENANCE.json +++ b/docs/gstack-2/JUDGMENT-PROVENANCE.json @@ -8,8 +8,12 @@ "review", "ship" ], + "tool_skills": [ + "make-pdf" + ], "counts": { "public_skills": 5, + "tool_skills": 1, "mandatory_inputs": 25, "templates": 47, "section_templates": 14, @@ -19,7 +23,7 @@ "bug_fix_ports": 24, "assets": 70, "dependency_copies": 3, - "runtime_helpers": 38 + "runtime_helpers": 39 }, "sources": [ { @@ -83,7 +87,7 @@ "source_path": "office-hours/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "8568fe73cca76a80805fab3092cacd10db7e1d7f", - "normalized_render_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "normalized_render_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "target": "skills/plan/references/legacy/office-hours.md", "disposition": "BUG_FIX", "overlays": [ @@ -203,7 +207,7 @@ "source_path": "plan-ceo-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c43cfe64146fe79df74a974f1673fe36defcce00", - "normalized_render_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "normalized_render_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "target": "skills/plan/references/legacy/plan-ceo-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -345,7 +349,7 @@ "source_path": "plan-eng-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1d5be0e6f47f0896ee831b53c56818850b1dcfe4", - "normalized_render_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "normalized_render_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "target": "skills/plan/references/legacy/plan-eng-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -469,7 +473,7 @@ "source_path": "plan-devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1ef723c10997a08ef87940daceb08bf8d60dd810", - "normalized_render_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "normalized_render_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "target": "skills/plan/references/legacy/plan-devex-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -605,7 +609,7 @@ "source_path": "autoplan/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b2eaca9fde8f46001bea9961b8ed651d0f6f9e13", - "normalized_render_sha256": "690d7a2550c7be6105dcd61ecd5a1f5c39799b2ff36e0ccf271d9e067b5c494b", + "normalized_render_sha256": "08e1fc76cb9ef5335a365f008faff5c37558a648c9ff938caa28097f1ca8e60c", "target": "skills/plan/references/legacy/autoplan.md", "disposition": "BUG_FIX", "overlays": [ @@ -699,7 +703,7 @@ "source_path": "spec/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "6c0c14e1b37e1e56d85427e9c8d080a6df908992", - "normalized_render_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "normalized_render_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "target": "skills/plan/references/legacy/spec.md", "disposition": "BUG_FIX", "overlays": [ @@ -834,7 +838,7 @@ "source_path": "plan-tune/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "dc1214d4c023ed2b126aca8aedb4153b222e73c5", - "normalized_render_sha256": "a8d16b743982d7ad9cbe54f52aeb84a17139229d310c80fd80a30d1b67bd8c4c", + "normalized_render_sha256": "97e6b1468ce1382245c4d5e92bd71a2a16d0d171508cd32e7f1501ed2e24f54c", "target": "skills/plan/references/legacy/plan-tune.md", "disposition": "BUG_FIX", "overlays": [ @@ -914,7 +918,7 @@ "source_path": "context-save/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "a3702bc95439cddd87841aba836708bf511ef55d", - "normalized_render_sha256": "9202f9bf88569b19240967ba8e3ab3608e205f8b1b40dca2fff182ff125c5958", + "normalized_render_sha256": "c7cf8af4b2eb818f444fa3169efa9282771e4f08680bc8246cd49bb2e83d216e", "target": "skills/plan/references/legacy/context-save.md", "disposition": "BUG_FIX", "overlays": [ @@ -990,7 +994,7 @@ "source_path": "context-restore/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "55889f6e06d3ba896f33a16969959c700bc24f1e", - "normalized_render_sha256": "6f51edee130fa663b79978bcea4ecab0f853e38cf147cbceb7c19e7ef438bae4", + "normalized_render_sha256": "4a7a0ff4687bf1a037c93e9a6f61206b63aacbb651a2056ea6d6ccee8277b9db", "target": "skills/plan/references/legacy/context-restore.md", "disposition": "BUG_FIX", "overlays": [ @@ -1063,7 +1067,7 @@ "source_path": "learn/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "90d08d2298cccd0d5090f194a2cd76a5361b4323", - "normalized_render_sha256": "4daa0e7ba0d850896f79f3246249fe1f00dda750cbe9141d4f98d2a18a57ac89", + "normalized_render_sha256": "df5c0ca631e7dac93e1121ba2e8c566459d822bb7411c12dc58d3590ceeb2dac", "target": "skills/plan/references/legacy/learn.md", "disposition": "BUG_FIX", "overlays": [ @@ -1137,7 +1141,7 @@ "source_path": "retro/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b0819c8a6b736baf489179ba587442cf9973b9d2", - "normalized_render_sha256": "586bcfe4c4dd8267514a0ddc7243c63229b65bddd99ea64d5fbbe6f75c7d47d5", + "normalized_render_sha256": "6170c5cb16cb9408f8501150c9b97748cc769847da4b6babf92e4c318dd9bbef", "target": "skills/plan/references/legacy/retro.md", "disposition": "BUG_FIX", "overlays": [ @@ -1265,7 +1269,7 @@ "source_path": "setup-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f48581543b46ecf889f4f86fb7a0d9e75d0bd6ca", - "normalized_render_sha256": "1170cb7fb045d5eb6f2d86dbddf11488aa0e47691470a66a163002aaaa57cc13", + "normalized_render_sha256": "c18de976c888b7e5ed90470bd4c94adcc8ba702afa33ecb86b07b7b38a0822d7", "target": "skills/plan/references/legacy/setup-gbrain.md", "disposition": "BUG_FIX", "overlays": [ @@ -1366,7 +1370,7 @@ "source_path": "sync-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "2ec065472e263a07f3818ed87ee9a6a2e13ca3ae", - "normalized_render_sha256": "8ad4c387540025298a0cb856ace1c3d302e91df722e57908d6bb350dbbea0d6f", + "normalized_render_sha256": "c3e8c6bee0e7b7eb04aed78c3097135a380c3bc09e49b7ec3e8affe0c40d7246", "target": "skills/plan/references/legacy/sync-gbrain.md", "disposition": "BUG_FIX", "overlays": [ @@ -1443,7 +1447,7 @@ "source_path": "qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "11997f7b878282c34b6bfd3d4b7a8131f9ad4da8", - "normalized_render_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "normalized_render_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "target": "skills/qa/references/legacy/qa.md", "disposition": "BUG_FIX", "overlays": [ @@ -1570,7 +1574,7 @@ "source_path": "qa-only/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75c4123cc5c406ffdd36c71a094335c137135b1e", - "normalized_render_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "normalized_render_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "target": "skills/qa/references/legacy/qa-only.md", "disposition": "BUG_FIX", "overlays": [ @@ -1672,7 +1676,7 @@ "source_path": "ios-qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "e93d2831a31df65ec8e4e8693919ef5bf148457f", - "normalized_render_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "normalized_render_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "target": "skills/qa/references/legacy/ios-qa.md", "disposition": "BUG_FIX", "overlays": [ @@ -1750,7 +1754,7 @@ "source_path": "devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "081d4f35bbdec0c6b3da8ae71615ec4d41a84551", - "normalized_render_sha256": "479701a64cfd526fc3b77abc963928b0966bb4adfac538e9d9e93d4c7d6f3095", + "normalized_render_sha256": "c1dfb5bab67f6b91a0146f147528513e82ae63dcf3ad979365a93c2ce2107a8a", "target": "skills/qa/references/legacy/devex-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -1850,7 +1854,7 @@ "source_path": "benchmark/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "038f16f5fba4ae4e9eae922e3276bba8ef88149e", - "normalized_render_sha256": "cb9f851d81a2c42a6783081e39e972735f41eed17eaccd088418d92f071d10db", + "normalized_render_sha256": "067727275b1a2c35135c8057be3b4d825c109c331c059d47167ee05fb73419b1", "target": "skills/qa/references/legacy/benchmark.md", "disposition": "BUG_FIX", "overlays": [ @@ -1917,7 +1921,7 @@ "source_path": "canary/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d1eb2950aba2fa2b09d90f13143492c60d46793c", - "normalized_render_sha256": "ee19b289f8863a257e182c762955d0c830bb3990267d6e8029da2ccbc32c626a", + "normalized_render_sha256": "fe54271351a13f54f6aee084cc6bc29303453f6bafcd8e8aec3a539cdf377c20", "target": "skills/qa/references/legacy/canary.md", "disposition": "BUG_FIX", "overlays": [ @@ -1997,7 +2001,7 @@ "source_path": "browse/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "9a159e4c9820172c229e2174d4a62a8f9668ab93", - "normalized_render_sha256": "baf29ae180652d04060ec2f51ee5783c1c1afe7e508afbba4fde5172d6d97644", + "normalized_render_sha256": "7f68a4f9a973ea7910a20bd3b96d5943ddf23343fc7378acb00a681c2d6efd3d", "target": "skills/qa/references/legacy/browse.md", "disposition": "BUG_FIX", "overlays": [ @@ -2086,7 +2090,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "a596b6eb8d0bc719bb217a3616c0ea0a0bf96618f82b27bef3dcd2524c9c1e16", + "normalized_render_sha256": "4df3d0f861e3bd36e5b67b3fa327af2c37abcc8157795b7c053888e5cba91f36", "target": "skills/qa/references/legacy/open-gstack-browser.md", "disposition": "BUG_FIX", "overlays": [ @@ -2162,7 +2166,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "d44e3ca601a2033a8798e3202e6c30d9070a6dcecefd2ba04d0c93a1fef33a46", + "normalized_render_sha256": "a4fc163026fb9897a9b8ba0aca0a99330b0cc112d6acd420ad58b688794e672b", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "disposition": "BUG_FIX", "overlays": [ @@ -2224,7 +2228,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "f32de827c5c57451130c1d9d18275964d66a1f8cdaf688e05fcea8a1cfe42c1e", + "normalized_render_sha256": "478f84d29a9591e4161103a711e6dfcefd5c42645c43dd11ffc63a66785437ea", "target": "skills/qa/references/legacy/pair-agent.md", "disposition": "BUG_FIX", "overlays": [ @@ -2308,7 +2312,7 @@ "source_path": "scrape/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "4cb4f17c074edcdce0bc8d133f19a6a739964851", - "normalized_render_sha256": "559c8df2926d86ef132bb2242ea41d4ccd5dfe3789d74da4f0b2a19e44fe1a41", + "normalized_render_sha256": "f45abd249c961f2278e92dbf42e58e4e78331b46699ba253b5d1ba9048524494", "target": "skills/qa/references/legacy/scrape.md", "disposition": "BUG_FIX", "overlays": [ @@ -2386,7 +2390,7 @@ "source_path": "skillify/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "21fb2f503e3d40683fa782b21abf05a8f0fef69b", - "normalized_render_sha256": "b375c752d99badc39ee557ae788ede75314a41181aff239db9ed3980be44afca", + "normalized_render_sha256": "27d2b8f38c8ad846df38a846dcdbfa89729bbd02036eb31768138a5435748302", "target": "skills/qa/references/legacy/skillify.md", "disposition": "BUG_FIX", "overlays": [ @@ -2470,7 +2474,7 @@ "source_path": "benchmark-models/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "034cda182406dc04a82c4336ac3ebc36b5fc41b1", - "normalized_render_sha256": "969ad2e89ab87e2b094f93def85df1fcba425b4d5435b628797fb2439392372b", + "normalized_render_sha256": "4bec3634223002fa47999bd4582dbe566d0561a5c9dbbd41f665787371940027", "target": "skills/qa/references/legacy/benchmark-models.md", "disposition": "BUG_FIX", "overlays": [ @@ -2531,7 +2535,7 @@ "source_path": "investigate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "67e254d743ffb9060f48e3f6d4b715c077ee688d", - "normalized_render_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "normalized_render_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "target": "skills/debug/references/legacy/investigate.md", "disposition": "BUG_FIX", "overlays": [ @@ -2730,7 +2734,7 @@ "source_path": "freeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c0b31aa7f9f216fc5a351d91f4bcff68c828d090", - "normalized_render_sha256": "92970ea04923e860250dd6c2988a846ed418a0d3630a90ff8fd5669db11c348f", + "normalized_render_sha256": "8cf265b54adbfd46db597bfc7a2af912df734e4a429e73c369eb7585bfd8749a", "target": "skills/debug/references/legacy/freeze.md", "disposition": "BUG_FIX", "overlays": [ @@ -2775,7 +2779,7 @@ "source_path": "guard/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "3d34ee0c181ec7b263bf6092ba8f384619c5efb6", - "normalized_render_sha256": "3ee5b6635542094fe1f3762a894db9eb441936ed768c9d71f1f28497f7a28e06", + "normalized_render_sha256": "a9c98d9f8d175f1e1024bfc1b37049fe6add831a8b4d86b37c31463d1a36852f", "target": "skills/debug/references/legacy/guard.md", "disposition": "BUG_FIX", "overlays": [ @@ -2819,7 +2823,7 @@ "source_path": "unfreeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "88e413fe5a49a45d46d8867b2d80ace30b3b45aa", - "normalized_render_sha256": "b0c9ba9578bcffa03777c6e21527d5b3c46c854932e439a3adaa072abe24653c", + "normalized_render_sha256": "0e318fd3ca04ab9a67c1108ed4bef61cdd5f4836a7f5cf47fd6f009b803bb88c", "target": "skills/debug/references/legacy/unfreeze.md", "disposition": "BUG_FIX", "overlays": [ @@ -2862,7 +2866,7 @@ "source_path": "review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ba1ea88068de4b09cf717eb4ae42aa247d198314", - "normalized_render_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "normalized_render_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "target": "skills/review/references/legacy/review.md", "disposition": "BUG_FIX", "overlays": [ @@ -2971,7 +2975,7 @@ "source_path": "cso/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "413fb099597b55dadca116e45a202aa693a94b74", - "normalized_render_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "normalized_render_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "target": "skills/review/references/legacy/cso.md", "disposition": "BUG_FIX", "overlays": [ @@ -3076,7 +3080,7 @@ "source_path": "health/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f92eb7347ec4f77dd8dbf464d63fcdf6a3459908", - "normalized_render_sha256": "181c88c3e10f81f229288b4c352e09b38daf001beddbc17751ba43c7630ae1a3", + "normalized_render_sha256": "f3fee10a1c52fd11344c3c8c3adeba9f6abba967f979ca1bb180ab0927b9cc33", "target": "skills/review/references/legacy/health.md", "disposition": "BUG_FIX", "overlays": [ @@ -3150,7 +3154,7 @@ "source_path": "codex/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "333de7d8d590cc78184b0e2371171f6121408f73", - "normalized_render_sha256": "034b63290d675424f5e4db5841f8f1ba90f8916435fe256d57861754667bae5a", + "normalized_render_sha256": "fda5208c92025ee46cd5cffab2f56bbea4bcb563d4ccacc43768c62b0de923c6", "target": "skills/review/references/legacy/codex.md", "disposition": "BUG_FIX", "overlays": [ @@ -3311,7 +3315,7 @@ "source_path": "ship/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "068ac4fe54bcb46572295d48263b11bd38fcde18", - "normalized_render_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "normalized_render_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "target": "skills/ship/references/legacy/ship.md", "disposition": "BUG_FIX", "overlays": [ @@ -3447,7 +3451,7 @@ "source_path": "land-and-deploy/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "98976ad020d541d251cc7e34802a13458ddc88e2", - "normalized_render_sha256": "0a421a6c1a4872a0b6a3677aab72f262a436682d724c7919f461ee0bbb5435c5", + "normalized_render_sha256": "9a152e3fe424059a59887a2f3a967d70433958e073b276a9efeb397ebba6e840", "target": "skills/ship/references/legacy/land-and-deploy.md", "disposition": "BUG_FIX", "overlays": [ @@ -3556,7 +3560,7 @@ "source_path": "landing-report/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "32a8cc1ab036b1ff8692f0400b68d8b56224a251", - "normalized_render_sha256": "f1b3a158878fd085d328ceb9a606c21d9ca080b7e9b21e017392789c0059a572", + "normalized_render_sha256": "033e7aadf583b06e71c6ce7838ecd82646adf4864c7ddc8215a50b5259da9384", "target": "skills/ship/references/legacy/landing-report.md", "disposition": "BUG_FIX", "overlays": [ @@ -3631,7 +3635,7 @@ "source_path": "document-release/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "7621cb31290217b5c7cc8fb000e910b3dd38128f", - "normalized_render_sha256": "e9870e7cd7b51a34e22cc8b40901f44bde41573b2aa9c0889603fd032347a273", + "normalized_render_sha256": "749676a8528caf0944e4bda2bedbb4178a24e4bc8ed871d883b3699b0e630ead", "target": "skills/ship/references/legacy/document-release.md", "disposition": "BUG_FIX", "overlays": [ @@ -3790,7 +3794,7 @@ "source_path": "document-generate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d3ef0cbc3786c4010b3c692fb94246f56a7e1d7b", - "normalized_render_sha256": "c89176748159781897de676eb68585d7f550b308a87ec7c657de9ab4dcfb1006", + "normalized_render_sha256": "2917a10c27ca092ca6327460cf48959a7bfd6aa65205a2a0296ab43080aa62bb", "target": "skills/ship/references/legacy/document-generate.md", "disposition": "BUG_FIX", "overlays": [ @@ -3995,7 +3999,7 @@ "source_path": "ios-sync/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "156a33c4c35d3bad804e44c93ae4c931878489f3", - "normalized_render_sha256": "d3887cb417a78c6bf7df486e400575eeec0c03ce97817b590847d3015197ae2e", + "normalized_render_sha256": "4cf68fce2aab08125cc68c9b34ae6c936405e26f95ced8dbe0194ff2ac06763d", "target": "skills/ship/references/legacy/ios-sync.md", "disposition": "BUG_FIX", "overlays": [ @@ -4932,7 +4936,7 @@ "owner_tree": "debug", "consumer_tree": "qa", "target": "skills/qa/references/legacy/investigate.md", - "sha256": "25a8505438110d033f4a26c16040c3fc656fe35d31e1818222ca980f07c1d1f4", + "sha256": "b9ce2b36f41eb5e1851c9a96a2d0a2075d78eb1f8b1b888410e7a672b955b6ff", "disposition": "SHARED_MODULE" }, { @@ -4940,7 +4944,7 @@ "owner_tree": "qa", "consumer_tree": "ship", "target": "skills/ship/references/legacy/canary.md", - "sha256": "cc5f314e00ce9e2a03205a33809fdac6912a4f703d7aa34d09be53f16fb8274b", + "sha256": "babddc54ba40967f3d015b76cac69ce9287645ffabb94b467ea91d35eb89e950", "disposition": "SHARED_MODULE" }, { @@ -4948,7 +4952,7 @@ "owner_tree": "plan", "consumer_tree": "ship", "target": "skills/ship/references/legacy/context-restore.md", - "sha256": "4f41a8263c4761164ca03c3b718854e4bc9e4cdc0ad92c8796dc8de96dd8bd4a", + "sha256": "215028aecbbe35a544a90733d8920db81014d1a195a8f6edfd67c285043841fe", "disposition": "SHARED_MODULE" } ], @@ -6076,6 +6080,58 @@ ], "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/gstack-version-bump" }, + { + "name": "make-pdf", + "delivery": "repository-payload", + "source_path": "make-pdf/dist/pdf", + "platform_source_paths": { + "posix": "make-pdf/dist/pdf", + "win32": "make-pdf/dist/pdf.exe" + }, + "consumer_modules": [ + "autoplan", + "benchmark", + "benchmark-models", + "browse", + "canary", + "codex", + "context-restore", + "context-save", + "cso", + "devex-review", + "document-generate", + "document-release", + "freeze", + "guard", + "health", + "investigate", + "ios-qa", + "ios-sync", + "land-and-deploy", + "landing-report", + "learn", + "office-hours", + "open-gstack-browser", + "pair-agent", + "plan-ceo-review", + "plan-devex-review", + "plan-eng-review", + "plan-tune", + "qa", + "qa-only", + "retro", + "review", + "scrape", + "setup-browser-cookies", + "setup-gbrain", + "ship", + "skillify", + "spec", + "sync-gbrain", + "unfreeze" + ], + "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/make-pdf" + }, { "name": "remote-slug", "delivery": "repository-payload", diff --git a/docs/gstack-2/SKILL-MIGRATION.md b/docs/gstack-2/SKILL-MIGRATION.md index bf083cd73a..71f9945033 100644 --- a/docs/gstack-2/SKILL-MIGRATION.md +++ b/docs/gstack-2/SKILL-MIGRATION.md @@ -2,7 +2,7 @@ Pinned baseline: `bb57306d98c97011b0919c6132705a15b1579781`. -GStack 2 exposes exactly five public skills: `plan`, `qa`, `debug`, `review`, and `ship`. The specialist bodies from 47 legacy templates remain provenance-pinned internal reference modules. The retired 1.x shared onboarding wrapper is excluded from canonical execution, and all 14 carved specialist sections are package-local lazy references loaded only at their original workflow point. Twenty-five primary modules are mandatory specialist inputs, and 22 supporting modules remain reachable through compatibility routing. +GStack 2 exposes exactly five judgment dispatchers: `plan`, `qa`, `debug`, `review`, and `ship`, plus the `make-pdf` tool skill that installs with the same canonical tree (six discoverable skills total). The specialist bodies from 47 legacy templates remain provenance-pinned internal reference modules. The retired 1.x shared onboarding wrapper is excluded from canonical execution, and all 14 carved specialist sections are package-local lazy references loaded only at their original workflow point. Twenty-five primary modules are mandatory specialist inputs, and 22 supporting modules remain reachable through compatibility routing. The fixed public modes are: QA = `Report | Fix`; Debug = `Diagnose-only | Fix`; Review = `Normal | Security | Performance | Deep`; Ship = `Prepare | Land | Deploy | Monitor | Resume`. Richer legacy modes are internal aliases only. diff --git a/docs/gstack-2/STATUS.md b/docs/gstack-2/STATUS.md index 2ea68aac38..b9074621b7 100644 --- a/docs/gstack-2/STATUS.md +++ b/docs/gstack-2/STATUS.md @@ -23,9 +23,15 @@ PR, or PR-ready claim is authorized by this status. them candidate regressions. - [x] 755 unique open issue/PR records reconciled and deterministically mapped; all 16 required upstream PR snapshots traced. -- [x] Exactly five canonical skill directories: `plan`, `qa`, `debug`, - `review`, and `ship`. (The `/design` skill was retired; its runtime image / +- [x] Five canonical judgment dispatcher directories: `plan`, `qa`, `debug`, + `review`, and `ship`, plus the `make-pdf` tool skill emitted into + `skills/make-pdf/`. (The `/design` skill was retired; its runtime image / diagram / PDF capabilities remain available to the surviving skills.) + `make-pdf` is a tool skill, not a mode-based dispatcher, but it ships in the + same canonical tree, so `npx skills add time-attack/gstack/skills` now + surfaces six discoverable skills. The install-matrix and native-CI runs + recorded below ("exactly five skills") predate this addition; a fresh install + now finds six, and re-running those matrices to refresh the counts is pending. - [x] The six `/plan` top-level modes are exactly **Discovery, Product, Engineering, DX, Specification, and Full chain**. - [x] Standard installer matrix is green: 510/510 checks, 18 installs and two diff --git a/evals/parity/contracts/autoplan.json b/evals/parity/contracts/autoplan.json index e7909dea9f..d6b7743ab5 100644 --- a/evals/parity/contracts/autoplan.json +++ b/evals/parity/contracts/autoplan.json @@ -9,7 +9,7 @@ "source_path": "autoplan/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b2eaca9fde8f46001bea9961b8ed651d0f6f9e13", - "normalized_render_sha256": "690d7a2550c7be6105dcd61ecd5a1f5c39799b2ff36e0ccf271d9e067b5c494b", + "normalized_render_sha256": "08e1fc76cb9ef5335a365f008faff5c37558a648c9ff938caa28097f1ca8e60c", "target": "skills/plan/references/legacy/autoplan.md", "overlays": [ 679, diff --git a/evals/parity/contracts/benchmark-models.json b/evals/parity/contracts/benchmark-models.json index 9545daeca6..06e5b7a14c 100644 --- a/evals/parity/contracts/benchmark-models.json +++ b/evals/parity/contracts/benchmark-models.json @@ -9,7 +9,7 @@ "source_path": "benchmark-models/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "034cda182406dc04a82c4336ac3ebc36b5fc41b1", - "normalized_render_sha256": "969ad2e89ab87e2b094f93def85df1fcba425b4d5435b628797fb2439392372b", + "normalized_render_sha256": "4bec3634223002fa47999bd4582dbe566d0561a5c9dbbd41f665787371940027", "target": "skills/qa/references/legacy/benchmark-models.md", "overlays": [ 679, diff --git a/evals/parity/contracts/benchmark.json b/evals/parity/contracts/benchmark.json index ac9304b4c7..cc717a25ae 100644 --- a/evals/parity/contracts/benchmark.json +++ b/evals/parity/contracts/benchmark.json @@ -9,7 +9,7 @@ "source_path": "benchmark/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "038f16f5fba4ae4e9eae922e3276bba8ef88149e", - "normalized_render_sha256": "cb9f851d81a2c42a6783081e39e972735f41eed17eaccd088418d92f071d10db", + "normalized_render_sha256": "067727275b1a2c35135c8057be3b4d825c109c331c059d47167ee05fb73419b1", "target": "skills/qa/references/legacy/benchmark.md", "overlays": [ 679, diff --git a/evals/parity/contracts/browse.json b/evals/parity/contracts/browse.json index 1bf2f8f4eb..5bd6731ffd 100644 --- a/evals/parity/contracts/browse.json +++ b/evals/parity/contracts/browse.json @@ -9,7 +9,7 @@ "source_path": "browse/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "9a159e4c9820172c229e2174d4a62a8f9668ab93", - "normalized_render_sha256": "baf29ae180652d04060ec2f51ee5783c1c1afe7e508afbba4fde5172d6d97644", + "normalized_render_sha256": "7f68a4f9a973ea7910a20bd3b96d5943ddf23343fc7378acb00a681c2d6efd3d", "target": "skills/qa/references/legacy/browse.md", "overlays": [ 679, diff --git a/evals/parity/contracts/canary.json b/evals/parity/contracts/canary.json index cba9f7d59e..05b4a08097 100644 --- a/evals/parity/contracts/canary.json +++ b/evals/parity/contracts/canary.json @@ -9,7 +9,7 @@ "source_path": "canary/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d1eb2950aba2fa2b09d90f13143492c60d46793c", - "normalized_render_sha256": "ee19b289f8863a257e182c762955d0c830bb3990267d6e8029da2ccbc32c626a", + "normalized_render_sha256": "fe54271351a13f54f6aee084cc6bc29303453f6bafcd8e8aec3a539cdf377c20", "target": "skills/qa/references/legacy/canary.md", "overlays": [ 679, diff --git a/evals/parity/contracts/codex.json b/evals/parity/contracts/codex.json index a59911cee2..9873d7e4fe 100644 --- a/evals/parity/contracts/codex.json +++ b/evals/parity/contracts/codex.json @@ -9,7 +9,7 @@ "source_path": "codex/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "333de7d8d590cc78184b0e2371171f6121408f73", - "normalized_render_sha256": "034b63290d675424f5e4db5841f8f1ba90f8916435fe256d57861754667bae5a", + "normalized_render_sha256": "fda5208c92025ee46cd5cffab2f56bbea4bcb563d4ccacc43768c62b0de923c6", "target": "skills/review/references/legacy/codex.md", "overlays": [ 679, diff --git a/evals/parity/contracts/context-restore.json b/evals/parity/contracts/context-restore.json index e4f93b088f..05876bcf65 100644 --- a/evals/parity/contracts/context-restore.json +++ b/evals/parity/contracts/context-restore.json @@ -9,7 +9,7 @@ "source_path": "context-restore/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "55889f6e06d3ba896f33a16969959c700bc24f1e", - "normalized_render_sha256": "6f51edee130fa663b79978bcea4ecab0f853e38cf147cbceb7c19e7ef438bae4", + "normalized_render_sha256": "4a7a0ff4687bf1a037c93e9a6f61206b63aacbb651a2056ea6d6ccee8277b9db", "target": "skills/plan/references/legacy/context-restore.md", "overlays": [ 679, diff --git a/evals/parity/contracts/context-save.json b/evals/parity/contracts/context-save.json index d5a1ac879c..da604bce57 100644 --- a/evals/parity/contracts/context-save.json +++ b/evals/parity/contracts/context-save.json @@ -9,7 +9,7 @@ "source_path": "context-save/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "a3702bc95439cddd87841aba836708bf511ef55d", - "normalized_render_sha256": "9202f9bf88569b19240967ba8e3ab3608e205f8b1b40dca2fff182ff125c5958", + "normalized_render_sha256": "c7cf8af4b2eb818f444fa3169efa9282771e4f08680bc8246cd49bb2e83d216e", "target": "skills/plan/references/legacy/context-save.md", "overlays": [ 679, diff --git a/evals/parity/contracts/cso.json b/evals/parity/contracts/cso.json index 7e41896321..d854f551b0 100644 --- a/evals/parity/contracts/cso.json +++ b/evals/parity/contracts/cso.json @@ -9,7 +9,7 @@ "source_path": "cso/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "413fb099597b55dadca116e45a202aa693a94b74", - "normalized_render_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "normalized_render_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "target": "skills/review/references/legacy/cso.md", "overlays": [ 679, diff --git a/evals/parity/contracts/devex-review.json b/evals/parity/contracts/devex-review.json index b990932daa..0ce27d7bef 100644 --- a/evals/parity/contracts/devex-review.json +++ b/evals/parity/contracts/devex-review.json @@ -9,7 +9,7 @@ "source_path": "devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "081d4f35bbdec0c6b3da8ae71615ec4d41a84551", - "normalized_render_sha256": "479701a64cfd526fc3b77abc963928b0966bb4adfac538e9d9e93d4c7d6f3095", + "normalized_render_sha256": "c1dfb5bab67f6b91a0146f147528513e82ae63dcf3ad979365a93c2ce2107a8a", "target": "skills/qa/references/legacy/devex-review.md", "overlays": [ 679, diff --git a/evals/parity/contracts/document-generate.json b/evals/parity/contracts/document-generate.json index ff98a44a60..b07081970a 100644 --- a/evals/parity/contracts/document-generate.json +++ b/evals/parity/contracts/document-generate.json @@ -9,7 +9,7 @@ "source_path": "document-generate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d3ef0cbc3786c4010b3c692fb94246f56a7e1d7b", - "normalized_render_sha256": "c89176748159781897de676eb68585d7f550b308a87ec7c657de9ab4dcfb1006", + "normalized_render_sha256": "2917a10c27ca092ca6327460cf48959a7bfd6aa65205a2a0296ab43080aa62bb", "target": "skills/ship/references/legacy/document-generate.md", "overlays": [ 679, diff --git a/evals/parity/contracts/document-release.json b/evals/parity/contracts/document-release.json index e7b95247c9..be3fdbafe0 100644 --- a/evals/parity/contracts/document-release.json +++ b/evals/parity/contracts/document-release.json @@ -9,7 +9,7 @@ "source_path": "document-release/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "7621cb31290217b5c7cc8fb000e910b3dd38128f", - "normalized_render_sha256": "e9870e7cd7b51a34e22cc8b40901f44bde41573b2aa9c0889603fd032347a273", + "normalized_render_sha256": "749676a8528caf0944e4bda2bedbb4178a24e4bc8ed871d883b3699b0e630ead", "target": "skills/ship/references/legacy/document-release.md", "overlays": [ 679, diff --git a/evals/parity/contracts/freeze.json b/evals/parity/contracts/freeze.json index 3e110780d0..2309882e6c 100644 --- a/evals/parity/contracts/freeze.json +++ b/evals/parity/contracts/freeze.json @@ -9,7 +9,7 @@ "source_path": "freeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c0b31aa7f9f216fc5a351d91f4bcff68c828d090", - "normalized_render_sha256": "92970ea04923e860250dd6c2988a846ed418a0d3630a90ff8fd5669db11c348f", + "normalized_render_sha256": "8cf265b54adbfd46db597bfc7a2af912df734e4a429e73c369eb7585bfd8749a", "target": "skills/debug/references/legacy/freeze.md", "overlays": [ 679, diff --git a/evals/parity/contracts/guard.json b/evals/parity/contracts/guard.json index 548a7c686a..5e9cb89b26 100644 --- a/evals/parity/contracts/guard.json +++ b/evals/parity/contracts/guard.json @@ -9,7 +9,7 @@ "source_path": "guard/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "3d34ee0c181ec7b263bf6092ba8f384619c5efb6", - "normalized_render_sha256": "3ee5b6635542094fe1f3762a894db9eb441936ed768c9d71f1f28497f7a28e06", + "normalized_render_sha256": "a9c98d9f8d175f1e1024bfc1b37049fe6add831a8b4d86b37c31463d1a36852f", "target": "skills/debug/references/legacy/guard.md", "overlays": [ 679, diff --git a/evals/parity/contracts/health.json b/evals/parity/contracts/health.json index fb9655b080..e88861d9d6 100644 --- a/evals/parity/contracts/health.json +++ b/evals/parity/contracts/health.json @@ -9,7 +9,7 @@ "source_path": "health/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f92eb7347ec4f77dd8dbf464d63fcdf6a3459908", - "normalized_render_sha256": "181c88c3e10f81f229288b4c352e09b38daf001beddbc17751ba43c7630ae1a3", + "normalized_render_sha256": "f3fee10a1c52fd11344c3c8c3adeba9f6abba967f979ca1bb180ab0927b9cc33", "target": "skills/review/references/legacy/health.md", "overlays": [ 679, diff --git a/evals/parity/contracts/investigate.json b/evals/parity/contracts/investigate.json index e52cd6928a..22cfac3ff3 100644 --- a/evals/parity/contracts/investigate.json +++ b/evals/parity/contracts/investigate.json @@ -9,7 +9,7 @@ "source_path": "investigate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "67e254d743ffb9060f48e3f6d4b715c077ee688d", - "normalized_render_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "normalized_render_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "target": "skills/debug/references/legacy/investigate.md", "overlays": [ 679, diff --git a/evals/parity/contracts/ios-qa.json b/evals/parity/contracts/ios-qa.json index 49573b037a..865bfc0981 100644 --- a/evals/parity/contracts/ios-qa.json +++ b/evals/parity/contracts/ios-qa.json @@ -9,7 +9,7 @@ "source_path": "ios-qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "e93d2831a31df65ec8e4e8693919ef5bf148457f", - "normalized_render_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "normalized_render_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "target": "skills/qa/references/legacy/ios-qa.md", "overlays": [ 679, diff --git a/evals/parity/contracts/ios-sync.json b/evals/parity/contracts/ios-sync.json index 8a5fd0fcbb..a131982d4f 100644 --- a/evals/parity/contracts/ios-sync.json +++ b/evals/parity/contracts/ios-sync.json @@ -9,7 +9,7 @@ "source_path": "ios-sync/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "156a33c4c35d3bad804e44c93ae4c931878489f3", - "normalized_render_sha256": "d3887cb417a78c6bf7df486e400575eeec0c03ce97817b590847d3015197ae2e", + "normalized_render_sha256": "4cf68fce2aab08125cc68c9b34ae6c936405e26f95ced8dbe0194ff2ac06763d", "target": "skills/ship/references/legacy/ios-sync.md", "overlays": [ 679, diff --git a/evals/parity/contracts/land-and-deploy.json b/evals/parity/contracts/land-and-deploy.json index 61493ff360..9b6a0aca11 100644 --- a/evals/parity/contracts/land-and-deploy.json +++ b/evals/parity/contracts/land-and-deploy.json @@ -9,7 +9,7 @@ "source_path": "land-and-deploy/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "98976ad020d541d251cc7e34802a13458ddc88e2", - "normalized_render_sha256": "0a421a6c1a4872a0b6a3677aab72f262a436682d724c7919f461ee0bbb5435c5", + "normalized_render_sha256": "9a152e3fe424059a59887a2f3a967d70433958e073b276a9efeb397ebba6e840", "target": "skills/ship/references/legacy/land-and-deploy.md", "overlays": [ 679, diff --git a/evals/parity/contracts/landing-report.json b/evals/parity/contracts/landing-report.json index 102124278b..787d40c374 100644 --- a/evals/parity/contracts/landing-report.json +++ b/evals/parity/contracts/landing-report.json @@ -9,7 +9,7 @@ "source_path": "landing-report/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "32a8cc1ab036b1ff8692f0400b68d8b56224a251", - "normalized_render_sha256": "f1b3a158878fd085d328ceb9a606c21d9ca080b7e9b21e017392789c0059a572", + "normalized_render_sha256": "033e7aadf583b06e71c6ce7838ecd82646adf4864c7ddc8215a50b5259da9384", "target": "skills/ship/references/legacy/landing-report.md", "overlays": [ 679, diff --git a/evals/parity/contracts/learn.json b/evals/parity/contracts/learn.json index 281022531f..040e621eb0 100644 --- a/evals/parity/contracts/learn.json +++ b/evals/parity/contracts/learn.json @@ -9,7 +9,7 @@ "source_path": "learn/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "90d08d2298cccd0d5090f194a2cd76a5361b4323", - "normalized_render_sha256": "4daa0e7ba0d850896f79f3246249fe1f00dda750cbe9141d4f98d2a18a57ac89", + "normalized_render_sha256": "df5c0ca631e7dac93e1121ba2e8c566459d822bb7411c12dc58d3590ceeb2dac", "target": "skills/plan/references/legacy/learn.md", "overlays": [ 679, diff --git a/evals/parity/contracts/office-hours.json b/evals/parity/contracts/office-hours.json index b0b5dfe6e8..d8e2221c1d 100644 --- a/evals/parity/contracts/office-hours.json +++ b/evals/parity/contracts/office-hours.json @@ -9,7 +9,7 @@ "source_path": "office-hours/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "8568fe73cca76a80805fab3092cacd10db7e1d7f", - "normalized_render_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "normalized_render_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "target": "skills/plan/references/legacy/office-hours.md", "overlays": [ 679, diff --git a/evals/parity/contracts/open-gstack-browser.json b/evals/parity/contracts/open-gstack-browser.json index eb8f526508..fe7c292889 100644 --- a/evals/parity/contracts/open-gstack-browser.json +++ b/evals/parity/contracts/open-gstack-browser.json @@ -9,7 +9,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "a596b6eb8d0bc719bb217a3616c0ea0a0bf96618f82b27bef3dcd2524c9c1e16", + "normalized_render_sha256": "4df3d0f861e3bd36e5b67b3fa327af2c37abcc8157795b7c053888e5cba91f36", "target": "skills/qa/references/legacy/open-gstack-browser.md", "overlays": [ 679, diff --git a/evals/parity/contracts/pair-agent.json b/evals/parity/contracts/pair-agent.json index f4a3b6c470..e2aa1527e3 100644 --- a/evals/parity/contracts/pair-agent.json +++ b/evals/parity/contracts/pair-agent.json @@ -9,7 +9,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "f32de827c5c57451130c1d9d18275964d66a1f8cdaf688e05fcea8a1cfe42c1e", + "normalized_render_sha256": "478f84d29a9591e4161103a711e6dfcefd5c42645c43dd11ffc63a66785437ea", "target": "skills/qa/references/legacy/pair-agent.md", "overlays": [ 679, diff --git a/evals/parity/contracts/plan-ceo-review.json b/evals/parity/contracts/plan-ceo-review.json index f44cc252a4..f70620e6d0 100644 --- a/evals/parity/contracts/plan-ceo-review.json +++ b/evals/parity/contracts/plan-ceo-review.json @@ -9,7 +9,7 @@ "source_path": "plan-ceo-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c43cfe64146fe79df74a974f1673fe36defcce00", - "normalized_render_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "normalized_render_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "target": "skills/plan/references/legacy/plan-ceo-review.md", "overlays": [ 679, diff --git a/evals/parity/contracts/plan-devex-review.json b/evals/parity/contracts/plan-devex-review.json index b0b79df387..471642a112 100644 --- a/evals/parity/contracts/plan-devex-review.json +++ b/evals/parity/contracts/plan-devex-review.json @@ -9,7 +9,7 @@ "source_path": "plan-devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1ef723c10997a08ef87940daceb08bf8d60dd810", - "normalized_render_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "normalized_render_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "target": "skills/plan/references/legacy/plan-devex-review.md", "overlays": [ 679, diff --git a/evals/parity/contracts/plan-eng-review.json b/evals/parity/contracts/plan-eng-review.json index 875217dab7..75c916efd7 100644 --- a/evals/parity/contracts/plan-eng-review.json +++ b/evals/parity/contracts/plan-eng-review.json @@ -9,7 +9,7 @@ "source_path": "plan-eng-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1d5be0e6f47f0896ee831b53c56818850b1dcfe4", - "normalized_render_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "normalized_render_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "target": "skills/plan/references/legacy/plan-eng-review.md", "overlays": [ 679, diff --git a/evals/parity/contracts/plan-tune.json b/evals/parity/contracts/plan-tune.json index 9833a7faa3..5b0a21009d 100644 --- a/evals/parity/contracts/plan-tune.json +++ b/evals/parity/contracts/plan-tune.json @@ -9,7 +9,7 @@ "source_path": "plan-tune/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "dc1214d4c023ed2b126aca8aedb4153b222e73c5", - "normalized_render_sha256": "a8d16b743982d7ad9cbe54f52aeb84a17139229d310c80fd80a30d1b67bd8c4c", + "normalized_render_sha256": "97e6b1468ce1382245c4d5e92bd71a2a16d0d171508cd32e7f1501ed2e24f54c", "target": "skills/plan/references/legacy/plan-tune.md", "overlays": [ 679, diff --git a/evals/parity/contracts/qa-only.json b/evals/parity/contracts/qa-only.json index 7d3a4d63f1..4491998b7a 100644 --- a/evals/parity/contracts/qa-only.json +++ b/evals/parity/contracts/qa-only.json @@ -9,7 +9,7 @@ "source_path": "qa-only/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75c4123cc5c406ffdd36c71a094335c137135b1e", - "normalized_render_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "normalized_render_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "target": "skills/qa/references/legacy/qa-only.md", "overlays": [ 679, diff --git a/evals/parity/contracts/qa.json b/evals/parity/contracts/qa.json index a065276c3c..600658fafd 100644 --- a/evals/parity/contracts/qa.json +++ b/evals/parity/contracts/qa.json @@ -9,7 +9,7 @@ "source_path": "qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "11997f7b878282c34b6bfd3d4b7a8131f9ad4da8", - "normalized_render_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "normalized_render_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "target": "skills/qa/references/legacy/qa.md", "overlays": [ 679, diff --git a/evals/parity/contracts/retro.json b/evals/parity/contracts/retro.json index 19a1f8b2fb..850548f8b7 100644 --- a/evals/parity/contracts/retro.json +++ b/evals/parity/contracts/retro.json @@ -9,7 +9,7 @@ "source_path": "retro/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b0819c8a6b736baf489179ba587442cf9973b9d2", - "normalized_render_sha256": "586bcfe4c4dd8267514a0ddc7243c63229b65bddd99ea64d5fbbe6f75c7d47d5", + "normalized_render_sha256": "6170c5cb16cb9408f8501150c9b97748cc769847da4b6babf92e4c318dd9bbef", "target": "skills/plan/references/legacy/retro.md", "overlays": [ 679, diff --git a/evals/parity/contracts/review.json b/evals/parity/contracts/review.json index 2f77147624..11d04ce845 100644 --- a/evals/parity/contracts/review.json +++ b/evals/parity/contracts/review.json @@ -9,7 +9,7 @@ "source_path": "review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ba1ea88068de4b09cf717eb4ae42aa247d198314", - "normalized_render_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "normalized_render_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "target": "skills/review/references/legacy/review.md", "overlays": [ 610, diff --git a/evals/parity/contracts/scrape.json b/evals/parity/contracts/scrape.json index a75114f9bc..6a15300a5d 100644 --- a/evals/parity/contracts/scrape.json +++ b/evals/parity/contracts/scrape.json @@ -9,7 +9,7 @@ "source_path": "scrape/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "4cb4f17c074edcdce0bc8d133f19a6a739964851", - "normalized_render_sha256": "559c8df2926d86ef132bb2242ea41d4ccd5dfe3789d74da4f0b2a19e44fe1a41", + "normalized_render_sha256": "f45abd249c961f2278e92dbf42e58e4e78331b46699ba253b5d1ba9048524494", "target": "skills/qa/references/legacy/scrape.md", "overlays": [ 679, diff --git a/evals/parity/contracts/setup-browser-cookies.json b/evals/parity/contracts/setup-browser-cookies.json index 2580d865e6..100ad41cb1 100644 --- a/evals/parity/contracts/setup-browser-cookies.json +++ b/evals/parity/contracts/setup-browser-cookies.json @@ -9,7 +9,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "d44e3ca601a2033a8798e3202e6c30d9070a6dcecefd2ba04d0c93a1fef33a46", + "normalized_render_sha256": "a4fc163026fb9897a9b8ba0aca0a99330b0cc112d6acd420ad58b688794e672b", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "overlays": [ 679, diff --git a/evals/parity/contracts/setup-gbrain.json b/evals/parity/contracts/setup-gbrain.json index dd5d726d05..50c152d1f8 100644 --- a/evals/parity/contracts/setup-gbrain.json +++ b/evals/parity/contracts/setup-gbrain.json @@ -9,7 +9,7 @@ "source_path": "setup-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f48581543b46ecf889f4f86fb7a0d9e75d0bd6ca", - "normalized_render_sha256": "1170cb7fb045d5eb6f2d86dbddf11488aa0e47691470a66a163002aaaa57cc13", + "normalized_render_sha256": "c18de976c888b7e5ed90470bd4c94adcc8ba702afa33ecb86b07b7b38a0822d7", "target": "skills/plan/references/legacy/setup-gbrain.md", "overlays": [ 679, diff --git a/evals/parity/contracts/ship.json b/evals/parity/contracts/ship.json index 2a0d841e4d..7403fcaa0f 100644 --- a/evals/parity/contracts/ship.json +++ b/evals/parity/contracts/ship.json @@ -9,7 +9,7 @@ "source_path": "ship/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "068ac4fe54bcb46572295d48263b11bd38fcde18", - "normalized_render_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "normalized_render_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "target": "skills/ship/references/legacy/ship.md", "overlays": [ 679, diff --git a/evals/parity/contracts/skillify.json b/evals/parity/contracts/skillify.json index 83f48e5d93..ce757f240e 100644 --- a/evals/parity/contracts/skillify.json +++ b/evals/parity/contracts/skillify.json @@ -9,7 +9,7 @@ "source_path": "skillify/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "21fb2f503e3d40683fa782b21abf05a8f0fef69b", - "normalized_render_sha256": "b375c752d99badc39ee557ae788ede75314a41181aff239db9ed3980be44afca", + "normalized_render_sha256": "27d2b8f38c8ad846df38a846dcdbfa89729bbd02036eb31768138a5435748302", "target": "skills/qa/references/legacy/skillify.md", "overlays": [ 679, diff --git a/evals/parity/contracts/spec.json b/evals/parity/contracts/spec.json index 6fd7f035b3..68b26a001d 100644 --- a/evals/parity/contracts/spec.json +++ b/evals/parity/contracts/spec.json @@ -9,7 +9,7 @@ "source_path": "spec/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "6c0c14e1b37e1e56d85427e9c8d080a6df908992", - "normalized_render_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "normalized_render_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "target": "skills/plan/references/legacy/spec.md", "overlays": [ 679, diff --git a/evals/parity/contracts/sync-gbrain.json b/evals/parity/contracts/sync-gbrain.json index 077ae27979..a90df37668 100644 --- a/evals/parity/contracts/sync-gbrain.json +++ b/evals/parity/contracts/sync-gbrain.json @@ -9,7 +9,7 @@ "source_path": "sync-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "2ec065472e263a07f3818ed87ee9a6a2e13ca3ae", - "normalized_render_sha256": "8ad4c387540025298a0cb856ace1c3d302e91df722e57908d6bb350dbbea0d6f", + "normalized_render_sha256": "c3e8c6bee0e7b7eb04aed78c3097135a380c3bc09e49b7ec3e8affe0c40d7246", "target": "skills/plan/references/legacy/sync-gbrain.md", "overlays": [ 679, diff --git a/evals/parity/contracts/unfreeze.json b/evals/parity/contracts/unfreeze.json index 33572289f5..6ef4024f6f 100644 --- a/evals/parity/contracts/unfreeze.json +++ b/evals/parity/contracts/unfreeze.json @@ -9,7 +9,7 @@ "source_path": "unfreeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "88e413fe5a49a45d46d8867b2d80ace30b3b45aa", - "normalized_render_sha256": "b0c9ba9578bcffa03777c6e21527d5b3c46c854932e439a3adaa072abe24653c", + "normalized_render_sha256": "0e318fd3ca04ab9a67c1108ed4bef61cdd5f4836a7f5cf47fd6f009b803bb88c", "target": "skills/debug/references/legacy/unfreeze.md", "overlays": [ 679, diff --git a/evals/parity/manifest.json b/evals/parity/manifest.json index 40f9bf6ca6..ee1df7b659 100644 --- a/evals/parity/manifest.json +++ b/evals/parity/manifest.json @@ -8,8 +8,12 @@ "review", "ship" ], + "tool_skills": [ + "make-pdf" + ], "counts": { "public_skills": 5, + "tool_skills": 1, "mandatory_inputs": 25, "templates": 47, "section_templates": 14, @@ -19,7 +23,7 @@ "bug_fix_ports": 24, "assets": 70, "dependency_copies": 3, - "runtime_helpers": 38 + "runtime_helpers": 39 }, "sources": [ { @@ -83,7 +87,7 @@ "source_path": "office-hours/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "8568fe73cca76a80805fab3092cacd10db7e1d7f", - "normalized_render_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "normalized_render_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "target": "skills/plan/references/legacy/office-hours.md", "disposition": "BUG_FIX", "overlays": [ @@ -203,7 +207,7 @@ "source_path": "plan-ceo-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c43cfe64146fe79df74a974f1673fe36defcce00", - "normalized_render_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "normalized_render_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "target": "skills/plan/references/legacy/plan-ceo-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -345,7 +349,7 @@ "source_path": "plan-eng-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1d5be0e6f47f0896ee831b53c56818850b1dcfe4", - "normalized_render_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "normalized_render_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "target": "skills/plan/references/legacy/plan-eng-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -469,7 +473,7 @@ "source_path": "plan-devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "1ef723c10997a08ef87940daceb08bf8d60dd810", - "normalized_render_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "normalized_render_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "target": "skills/plan/references/legacy/plan-devex-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -605,7 +609,7 @@ "source_path": "autoplan/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b2eaca9fde8f46001bea9961b8ed651d0f6f9e13", - "normalized_render_sha256": "690d7a2550c7be6105dcd61ecd5a1f5c39799b2ff36e0ccf271d9e067b5c494b", + "normalized_render_sha256": "08e1fc76cb9ef5335a365f008faff5c37558a648c9ff938caa28097f1ca8e60c", "target": "skills/plan/references/legacy/autoplan.md", "disposition": "BUG_FIX", "overlays": [ @@ -699,7 +703,7 @@ "source_path": "spec/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "6c0c14e1b37e1e56d85427e9c8d080a6df908992", - "normalized_render_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "normalized_render_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "target": "skills/plan/references/legacy/spec.md", "disposition": "BUG_FIX", "overlays": [ @@ -834,7 +838,7 @@ "source_path": "plan-tune/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "dc1214d4c023ed2b126aca8aedb4153b222e73c5", - "normalized_render_sha256": "a8d16b743982d7ad9cbe54f52aeb84a17139229d310c80fd80a30d1b67bd8c4c", + "normalized_render_sha256": "97e6b1468ce1382245c4d5e92bd71a2a16d0d171508cd32e7f1501ed2e24f54c", "target": "skills/plan/references/legacy/plan-tune.md", "disposition": "BUG_FIX", "overlays": [ @@ -914,7 +918,7 @@ "source_path": "context-save/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "a3702bc95439cddd87841aba836708bf511ef55d", - "normalized_render_sha256": "9202f9bf88569b19240967ba8e3ab3608e205f8b1b40dca2fff182ff125c5958", + "normalized_render_sha256": "c7cf8af4b2eb818f444fa3169efa9282771e4f08680bc8246cd49bb2e83d216e", "target": "skills/plan/references/legacy/context-save.md", "disposition": "BUG_FIX", "overlays": [ @@ -990,7 +994,7 @@ "source_path": "context-restore/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "55889f6e06d3ba896f33a16969959c700bc24f1e", - "normalized_render_sha256": "6f51edee130fa663b79978bcea4ecab0f853e38cf147cbceb7c19e7ef438bae4", + "normalized_render_sha256": "4a7a0ff4687bf1a037c93e9a6f61206b63aacbb651a2056ea6d6ccee8277b9db", "target": "skills/plan/references/legacy/context-restore.md", "disposition": "BUG_FIX", "overlays": [ @@ -1063,7 +1067,7 @@ "source_path": "learn/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "90d08d2298cccd0d5090f194a2cd76a5361b4323", - "normalized_render_sha256": "4daa0e7ba0d850896f79f3246249fe1f00dda750cbe9141d4f98d2a18a57ac89", + "normalized_render_sha256": "df5c0ca631e7dac93e1121ba2e8c566459d822bb7411c12dc58d3590ceeb2dac", "target": "skills/plan/references/legacy/learn.md", "disposition": "BUG_FIX", "overlays": [ @@ -1137,7 +1141,7 @@ "source_path": "retro/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "b0819c8a6b736baf489179ba587442cf9973b9d2", - "normalized_render_sha256": "586bcfe4c4dd8267514a0ddc7243c63229b65bddd99ea64d5fbbe6f75c7d47d5", + "normalized_render_sha256": "6170c5cb16cb9408f8501150c9b97748cc769847da4b6babf92e4c318dd9bbef", "target": "skills/plan/references/legacy/retro.md", "disposition": "BUG_FIX", "overlays": [ @@ -1265,7 +1269,7 @@ "source_path": "setup-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f48581543b46ecf889f4f86fb7a0d9e75d0bd6ca", - "normalized_render_sha256": "1170cb7fb045d5eb6f2d86dbddf11488aa0e47691470a66a163002aaaa57cc13", + "normalized_render_sha256": "c18de976c888b7e5ed90470bd4c94adcc8ba702afa33ecb86b07b7b38a0822d7", "target": "skills/plan/references/legacy/setup-gbrain.md", "disposition": "BUG_FIX", "overlays": [ @@ -1366,7 +1370,7 @@ "source_path": "sync-gbrain/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "2ec065472e263a07f3818ed87ee9a6a2e13ca3ae", - "normalized_render_sha256": "8ad4c387540025298a0cb856ace1c3d302e91df722e57908d6bb350dbbea0d6f", + "normalized_render_sha256": "c3e8c6bee0e7b7eb04aed78c3097135a380c3bc09e49b7ec3e8affe0c40d7246", "target": "skills/plan/references/legacy/sync-gbrain.md", "disposition": "BUG_FIX", "overlays": [ @@ -1443,7 +1447,7 @@ "source_path": "qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "11997f7b878282c34b6bfd3d4b7a8131f9ad4da8", - "normalized_render_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "normalized_render_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "target": "skills/qa/references/legacy/qa.md", "disposition": "BUG_FIX", "overlays": [ @@ -1570,7 +1574,7 @@ "source_path": "qa-only/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75c4123cc5c406ffdd36c71a094335c137135b1e", - "normalized_render_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "normalized_render_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "target": "skills/qa/references/legacy/qa-only.md", "disposition": "BUG_FIX", "overlays": [ @@ -1672,7 +1676,7 @@ "source_path": "ios-qa/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "e93d2831a31df65ec8e4e8693919ef5bf148457f", - "normalized_render_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "normalized_render_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "target": "skills/qa/references/legacy/ios-qa.md", "disposition": "BUG_FIX", "overlays": [ @@ -1750,7 +1754,7 @@ "source_path": "devex-review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "081d4f35bbdec0c6b3da8ae71615ec4d41a84551", - "normalized_render_sha256": "479701a64cfd526fc3b77abc963928b0966bb4adfac538e9d9e93d4c7d6f3095", + "normalized_render_sha256": "c1dfb5bab67f6b91a0146f147528513e82ae63dcf3ad979365a93c2ce2107a8a", "target": "skills/qa/references/legacy/devex-review.md", "disposition": "BUG_FIX", "overlays": [ @@ -1850,7 +1854,7 @@ "source_path": "benchmark/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "038f16f5fba4ae4e9eae922e3276bba8ef88149e", - "normalized_render_sha256": "cb9f851d81a2c42a6783081e39e972735f41eed17eaccd088418d92f071d10db", + "normalized_render_sha256": "067727275b1a2c35135c8057be3b4d825c109c331c059d47167ee05fb73419b1", "target": "skills/qa/references/legacy/benchmark.md", "disposition": "BUG_FIX", "overlays": [ @@ -1917,7 +1921,7 @@ "source_path": "canary/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d1eb2950aba2fa2b09d90f13143492c60d46793c", - "normalized_render_sha256": "ee19b289f8863a257e182c762955d0c830bb3990267d6e8029da2ccbc32c626a", + "normalized_render_sha256": "fe54271351a13f54f6aee084cc6bc29303453f6bafcd8e8aec3a539cdf377c20", "target": "skills/qa/references/legacy/canary.md", "disposition": "BUG_FIX", "overlays": [ @@ -1997,7 +2001,7 @@ "source_path": "browse/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "9a159e4c9820172c229e2174d4a62a8f9668ab93", - "normalized_render_sha256": "baf29ae180652d04060ec2f51ee5783c1c1afe7e508afbba4fde5172d6d97644", + "normalized_render_sha256": "7f68a4f9a973ea7910a20bd3b96d5943ddf23343fc7378acb00a681c2d6efd3d", "target": "skills/qa/references/legacy/browse.md", "disposition": "BUG_FIX", "overlays": [ @@ -2086,7 +2090,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "a596b6eb8d0bc719bb217a3616c0ea0a0bf96618f82b27bef3dcd2524c9c1e16", + "normalized_render_sha256": "4df3d0f861e3bd36e5b67b3fa327af2c37abcc8157795b7c053888e5cba91f36", "target": "skills/qa/references/legacy/open-gstack-browser.md", "disposition": "BUG_FIX", "overlays": [ @@ -2162,7 +2166,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "d44e3ca601a2033a8798e3202e6c30d9070a6dcecefd2ba04d0c93a1fef33a46", + "normalized_render_sha256": "a4fc163026fb9897a9b8ba0aca0a99330b0cc112d6acd420ad58b688794e672b", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "disposition": "BUG_FIX", "overlays": [ @@ -2224,7 +2228,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "f32de827c5c57451130c1d9d18275964d66a1f8cdaf688e05fcea8a1cfe42c1e", + "normalized_render_sha256": "478f84d29a9591e4161103a711e6dfcefd5c42645c43dd11ffc63a66785437ea", "target": "skills/qa/references/legacy/pair-agent.md", "disposition": "BUG_FIX", "overlays": [ @@ -2308,7 +2312,7 @@ "source_path": "scrape/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "4cb4f17c074edcdce0bc8d133f19a6a739964851", - "normalized_render_sha256": "559c8df2926d86ef132bb2242ea41d4ccd5dfe3789d74da4f0b2a19e44fe1a41", + "normalized_render_sha256": "f45abd249c961f2278e92dbf42e58e4e78331b46699ba253b5d1ba9048524494", "target": "skills/qa/references/legacy/scrape.md", "disposition": "BUG_FIX", "overlays": [ @@ -2386,7 +2390,7 @@ "source_path": "skillify/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "21fb2f503e3d40683fa782b21abf05a8f0fef69b", - "normalized_render_sha256": "b375c752d99badc39ee557ae788ede75314a41181aff239db9ed3980be44afca", + "normalized_render_sha256": "27d2b8f38c8ad846df38a846dcdbfa89729bbd02036eb31768138a5435748302", "target": "skills/qa/references/legacy/skillify.md", "disposition": "BUG_FIX", "overlays": [ @@ -2470,7 +2474,7 @@ "source_path": "benchmark-models/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "034cda182406dc04a82c4336ac3ebc36b5fc41b1", - "normalized_render_sha256": "969ad2e89ab87e2b094f93def85df1fcba425b4d5435b628797fb2439392372b", + "normalized_render_sha256": "4bec3634223002fa47999bd4582dbe566d0561a5c9dbbd41f665787371940027", "target": "skills/qa/references/legacy/benchmark-models.md", "disposition": "BUG_FIX", "overlays": [ @@ -2531,7 +2535,7 @@ "source_path": "investigate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "67e254d743ffb9060f48e3f6d4b715c077ee688d", - "normalized_render_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "normalized_render_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "target": "skills/debug/references/legacy/investigate.md", "disposition": "BUG_FIX", "overlays": [ @@ -2730,7 +2734,7 @@ "source_path": "freeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "c0b31aa7f9f216fc5a351d91f4bcff68c828d090", - "normalized_render_sha256": "92970ea04923e860250dd6c2988a846ed418a0d3630a90ff8fd5669db11c348f", + "normalized_render_sha256": "8cf265b54adbfd46db597bfc7a2af912df734e4a429e73c369eb7585bfd8749a", "target": "skills/debug/references/legacy/freeze.md", "disposition": "BUG_FIX", "overlays": [ @@ -2775,7 +2779,7 @@ "source_path": "guard/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "3d34ee0c181ec7b263bf6092ba8f384619c5efb6", - "normalized_render_sha256": "3ee5b6635542094fe1f3762a894db9eb441936ed768c9d71f1f28497f7a28e06", + "normalized_render_sha256": "a9c98d9f8d175f1e1024bfc1b37049fe6add831a8b4d86b37c31463d1a36852f", "target": "skills/debug/references/legacy/guard.md", "disposition": "BUG_FIX", "overlays": [ @@ -2819,7 +2823,7 @@ "source_path": "unfreeze/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "88e413fe5a49a45d46d8867b2d80ace30b3b45aa", - "normalized_render_sha256": "b0c9ba9578bcffa03777c6e21527d5b3c46c854932e439a3adaa072abe24653c", + "normalized_render_sha256": "0e318fd3ca04ab9a67c1108ed4bef61cdd5f4836a7f5cf47fd6f009b803bb88c", "target": "skills/debug/references/legacy/unfreeze.md", "disposition": "BUG_FIX", "overlays": [ @@ -2862,7 +2866,7 @@ "source_path": "review/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ba1ea88068de4b09cf717eb4ae42aa247d198314", - "normalized_render_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "normalized_render_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "target": "skills/review/references/legacy/review.md", "disposition": "BUG_FIX", "overlays": [ @@ -2971,7 +2975,7 @@ "source_path": "cso/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "413fb099597b55dadca116e45a202aa693a94b74", - "normalized_render_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "normalized_render_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "target": "skills/review/references/legacy/cso.md", "disposition": "BUG_FIX", "overlays": [ @@ -3076,7 +3080,7 @@ "source_path": "health/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f92eb7347ec4f77dd8dbf464d63fcdf6a3459908", - "normalized_render_sha256": "181c88c3e10f81f229288b4c352e09b38daf001beddbc17751ba43c7630ae1a3", + "normalized_render_sha256": "f3fee10a1c52fd11344c3c8c3adeba9f6abba967f979ca1bb180ab0927b9cc33", "target": "skills/review/references/legacy/health.md", "disposition": "BUG_FIX", "overlays": [ @@ -3150,7 +3154,7 @@ "source_path": "codex/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "333de7d8d590cc78184b0e2371171f6121408f73", - "normalized_render_sha256": "034b63290d675424f5e4db5841f8f1ba90f8916435fe256d57861754667bae5a", + "normalized_render_sha256": "fda5208c92025ee46cd5cffab2f56bbea4bcb563d4ccacc43768c62b0de923c6", "target": "skills/review/references/legacy/codex.md", "disposition": "BUG_FIX", "overlays": [ @@ -3311,7 +3315,7 @@ "source_path": "ship/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "068ac4fe54bcb46572295d48263b11bd38fcde18", - "normalized_render_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "normalized_render_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "target": "skills/ship/references/legacy/ship.md", "disposition": "BUG_FIX", "overlays": [ @@ -3447,7 +3451,7 @@ "source_path": "land-and-deploy/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "98976ad020d541d251cc7e34802a13458ddc88e2", - "normalized_render_sha256": "0a421a6c1a4872a0b6a3677aab72f262a436682d724c7919f461ee0bbb5435c5", + "normalized_render_sha256": "9a152e3fe424059a59887a2f3a967d70433958e073b276a9efeb397ebba6e840", "target": "skills/ship/references/legacy/land-and-deploy.md", "disposition": "BUG_FIX", "overlays": [ @@ -3556,7 +3560,7 @@ "source_path": "landing-report/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "32a8cc1ab036b1ff8692f0400b68d8b56224a251", - "normalized_render_sha256": "f1b3a158878fd085d328ceb9a606c21d9ca080b7e9b21e017392789c0059a572", + "normalized_render_sha256": "033e7aadf583b06e71c6ce7838ecd82646adf4864c7ddc8215a50b5259da9384", "target": "skills/ship/references/legacy/landing-report.md", "disposition": "BUG_FIX", "overlays": [ @@ -3631,7 +3635,7 @@ "source_path": "document-release/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "7621cb31290217b5c7cc8fb000e910b3dd38128f", - "normalized_render_sha256": "e9870e7cd7b51a34e22cc8b40901f44bde41573b2aa9c0889603fd032347a273", + "normalized_render_sha256": "749676a8528caf0944e4bda2bedbb4178a24e4bc8ed871d883b3699b0e630ead", "target": "skills/ship/references/legacy/document-release.md", "disposition": "BUG_FIX", "overlays": [ @@ -3790,7 +3794,7 @@ "source_path": "document-generate/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "d3ef0cbc3786c4010b3c692fb94246f56a7e1d7b", - "normalized_render_sha256": "c89176748159781897de676eb68585d7f550b308a87ec7c657de9ab4dcfb1006", + "normalized_render_sha256": "2917a10c27ca092ca6327460cf48959a7bfd6aa65205a2a0296ab43080aa62bb", "target": "skills/ship/references/legacy/document-generate.md", "disposition": "BUG_FIX", "overlays": [ @@ -3995,7 +3999,7 @@ "source_path": "ios-sync/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "156a33c4c35d3bad804e44c93ae4c931878489f3", - "normalized_render_sha256": "d3887cb417a78c6bf7df486e400575eeec0c03ce97817b590847d3015197ae2e", + "normalized_render_sha256": "4cf68fce2aab08125cc68c9b34ae6c936405e26f95ced8dbe0194ff2ac06763d", "target": "skills/ship/references/legacy/ios-sync.md", "disposition": "BUG_FIX", "overlays": [ @@ -4932,7 +4936,7 @@ "owner_tree": "debug", "consumer_tree": "qa", "target": "skills/qa/references/legacy/investigate.md", - "sha256": "25a8505438110d033f4a26c16040c3fc656fe35d31e1818222ca980f07c1d1f4", + "sha256": "b9ce2b36f41eb5e1851c9a96a2d0a2075d78eb1f8b1b888410e7a672b955b6ff", "disposition": "SHARED_MODULE" }, { @@ -4940,7 +4944,7 @@ "owner_tree": "qa", "consumer_tree": "ship", "target": "skills/ship/references/legacy/canary.md", - "sha256": "cc5f314e00ce9e2a03205a33809fdac6912a4f703d7aa34d09be53f16fb8274b", + "sha256": "babddc54ba40967f3d015b76cac69ce9287645ffabb94b467ea91d35eb89e950", "disposition": "SHARED_MODULE" }, { @@ -4948,7 +4952,7 @@ "owner_tree": "plan", "consumer_tree": "ship", "target": "skills/ship/references/legacy/context-restore.md", - "sha256": "4f41a8263c4761164ca03c3b718854e4bc9e4cdc0ad92c8796dc8de96dd8bd4a", + "sha256": "215028aecbbe35a544a90733d8920db81014d1a195a8f6edfd67c285043841fe", "disposition": "SHARED_MODULE" } ], @@ -6076,6 +6080,58 @@ ], "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/gstack-version-bump" }, + { + "name": "make-pdf", + "delivery": "repository-payload", + "source_path": "make-pdf/dist/pdf", + "platform_source_paths": { + "posix": "make-pdf/dist/pdf", + "win32": "make-pdf/dist/pdf.exe" + }, + "consumer_modules": [ + "autoplan", + "benchmark", + "benchmark-models", + "browse", + "canary", + "codex", + "context-restore", + "context-save", + "cso", + "devex-review", + "document-generate", + "document-release", + "freeze", + "guard", + "health", + "investigate", + "ios-qa", + "ios-sync", + "land-and-deploy", + "landing-report", + "learn", + "office-hours", + "open-gstack-browser", + "pair-agent", + "plan-ceo-review", + "plan-devex-review", + "plan-eng-review", + "plan-tune", + "qa", + "qa-only", + "retro", + "review", + "scrape", + "setup-browser-cookies", + "setup-gbrain", + "ship", + "skillify", + "spec", + "sync-gbrain", + "unfreeze" + ], + "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/make-pdf" + }, { "name": "remote-slug", "delivery": "repository-payload", diff --git a/evals/parity/runtime-helper-closure.json b/evals/parity/runtime-helper-closure.json index 5ebf739488..ff8af33e3d 100644 --- a/evals/parity/runtime-helper-closure.json +++ b/evals/parity/runtime-helper-closure.json @@ -494,6 +494,58 @@ ], "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/gstack-version-bump" }, + { + "name": "make-pdf", + "delivery": "repository-payload", + "source_path": "make-pdf/dist/pdf", + "platform_source_paths": { + "posix": "make-pdf/dist/pdf", + "win32": "make-pdf/dist/pdf.exe" + }, + "consumer_modules": [ + "autoplan", + "benchmark", + "benchmark-models", + "browse", + "canary", + "codex", + "context-restore", + "context-save", + "cso", + "devex-review", + "document-generate", + "document-release", + "freeze", + "guard", + "health", + "investigate", + "ios-qa", + "ios-sync", + "land-and-deploy", + "landing-report", + "learn", + "office-hours", + "open-gstack-browser", + "pair-agent", + "plan-ceo-review", + "plan-devex-review", + "plan-eng-review", + "plan-tune", + "qa", + "qa-only", + "retro", + "review", + "scrape", + "setup-browser-cookies", + "setup-gbrain", + "ship", + "skillify", + "spec", + "sync-gbrain", + "unfreeze" + ], + "stable_path": "${GSTACK_HOME:-$HOME/.gstack}/bin/make-pdf" + }, { "name": "remote-slug", "delivery": "repository-payload", diff --git a/evals/parity/transcripts/deterministic/ceo-review.json b/evals/parity/transcripts/deterministic/ceo-review.json index f855874648..eafa3d7411 100644 --- a/evals/parity/transcripts/deterministic/ceo-review.json +++ b/evals/parity/transcripts/deterministic/ceo-review.json @@ -58,15 +58,15 @@ } }, "mechanical_port": { - "rendered_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "rendered_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/plan/references/legacy/plan-ceo-review.md", - "rendered_legacy_body_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "rendered_legacy_body_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "semantic_signature": { - "normalized_sha256": "651d60dc117a45e2b7a327eef33453f9c00194915ff5656b319dc51915781caf", + "normalized_sha256": "8b53b9a5362c834edd08b5f50cf8511a532188b8295092ca7b7f285d9270a355", "headings_sha256": "aee8af5dbf376874037f52d130bc6a804d4f27c989afd787b769ef53a9bbc56b", "questions_sha256": "442f02fed4d7a2e9e868795fe12aa5df3fb74a1dd8e538fbc76b8b1907345a81", "obligations_sha256": "305f7bea6815e4aa1d968c20c0823e0f98f34bf475065f0091f918c8eaa5ba85", diff --git a/evals/parity/transcripts/deterministic/code-review.json b/evals/parity/transcripts/deterministic/code-review.json index 0ca44d4a92..49adab0315 100644 --- a/evals/parity/transcripts/deterministic/code-review.json +++ b/evals/parity/transcripts/deterministic/code-review.json @@ -60,15 +60,15 @@ } }, "mechanical_port": { - "rendered_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "rendered_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/review/references/legacy/review.md", - "rendered_legacy_body_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "rendered_legacy_body_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "semantic_signature": { - "normalized_sha256": "2107171657dcda2551a99172bcf5dba8173fed380b83098405c0041a2c4e75d0", + "normalized_sha256": "3e446b7e523f114debd55c5d329e22554cb09af3271f9027db0ba6b88d8b8e80", "headings_sha256": "8ed11544f49943c2974265c86ad258f53207aaed6f289ea2e6afd1239eee26d8", "questions_sha256": "477b52941fada9cd9a3c3863c90310e198fca3dead264006f1431b0fde20f807", "obligations_sha256": "2e21e383f16f209a665d66710ca6d0ebae5a7fea3a200fc23506cfc584ca74b9", diff --git a/evals/parity/transcripts/deterministic/debug.json b/evals/parity/transcripts/deterministic/debug.json index 16bd2051c5..596440b0c7 100644 --- a/evals/parity/transcripts/deterministic/debug.json +++ b/evals/parity/transcripts/deterministic/debug.json @@ -53,15 +53,15 @@ } }, "mechanical_port": { - "rendered_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "rendered_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/debug/references/legacy/investigate.md", - "rendered_legacy_body_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "rendered_legacy_body_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "semantic_signature": { - "normalized_sha256": "dac4bc8e0c194536849c884e77a0f64cb647d15c33f5aefd52a6ae6d0cea6434", + "normalized_sha256": "e8309d8a213e388b079ef66a7d5220d78f791d408ae5d87a3c2aa4bbe1418c63", "headings_sha256": "aae491f6430a9d113ece7954490bd9db9d546e55ee5f10f2ac6cd67dc4d4e284", "questions_sha256": "9e058bbca5f57ed0c9c4724b67d640e15872a0ad283d496b8913d2dd6cfe897d", "obligations_sha256": "d832c32c233beb080da54a3ebdc627da9535a158f9772dce4abee15742996a1c", diff --git a/evals/parity/transcripts/deterministic/dx-review.json b/evals/parity/transcripts/deterministic/dx-review.json index 5304f3044c..4d7ed54b33 100644 --- a/evals/parity/transcripts/deterministic/dx-review.json +++ b/evals/parity/transcripts/deterministic/dx-review.json @@ -59,15 +59,15 @@ } }, "mechanical_port": { - "rendered_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "rendered_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/plan/references/legacy/plan-devex-review.md", - "rendered_legacy_body_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "rendered_legacy_body_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "semantic_signature": { - "normalized_sha256": "b0d4189a531a2c64574ceb718ff7fd21c05edb540da9b8255f511e408f26ab7a", + "normalized_sha256": "1289c366de5492b120ed562b0c0b6bc9c603ee26cad3c9378079cd2cdbfefff6", "headings_sha256": "7aebb4be8ec2a9ba9034b835bb07a244053a62156eabec252e0d78b3523f248a", "questions_sha256": "5d2d6b27d20c553887edf16ec79a25c0ecab599418052d8e5fc52a172ceb8afe", "obligations_sha256": "44fe82d36ea1aca78b396ef65052cff618b4f444fcb6f9ace283c116d0add332", diff --git a/evals/parity/transcripts/deterministic/engineering-review.json b/evals/parity/transcripts/deterministic/engineering-review.json index 90473d81fd..2f198643e7 100644 --- a/evals/parity/transcripts/deterministic/engineering-review.json +++ b/evals/parity/transcripts/deterministic/engineering-review.json @@ -58,15 +58,15 @@ } }, "mechanical_port": { - "rendered_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "rendered_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/plan/references/legacy/plan-eng-review.md", - "rendered_legacy_body_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "rendered_legacy_body_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "semantic_signature": { - "normalized_sha256": "7b2461d9f7bd7cdce0912bba1444f39a6945da99da03596de3c161604d4247d4", + "normalized_sha256": "57d9e304a41b7d348b28220ab7dff075aff74461c796687afc89d5de4a9f83ec", "headings_sha256": "0200c83cdde0bdc56f79527a4e38884bf075c09b1d829a4244e979b324dc52fc", "questions_sha256": "b74758da3f085a457fc0f9cf1b5c34dfe849fd713a5882849da0abb2edc1a73f", "obligations_sha256": "186b9e3bb712898fa6e8544ba2f6fc932a161e9bb388cb7b8f7bdecb782b57f9", diff --git a/evals/parity/transcripts/deterministic/office-hours.json b/evals/parity/transcripts/deterministic/office-hours.json index f6ba5979e7..850e16a53e 100644 --- a/evals/parity/transcripts/deterministic/office-hours.json +++ b/evals/parity/transcripts/deterministic/office-hours.json @@ -58,15 +58,15 @@ } }, "mechanical_port": { - "rendered_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "rendered_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/plan/references/legacy/office-hours.md", - "rendered_legacy_body_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "rendered_legacy_body_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "semantic_signature": { - "normalized_sha256": "2984ade43079ea34d5765ab636c11166d00a422fbd5fdb8f19caf0101813446d", + "normalized_sha256": "c77376fa95348c5c8deb1319f9bd232cc38532d78f3a45802f640749e2c76215", "headings_sha256": "fe80342e907cc2e379f928da7bc0afc2e1602da76aca875d794aaa0685cb8c6c", "questions_sha256": "92d854d47b2f92f30cc34a59cfb6f3e18449beddcb8ebd5490162545bc8384c7", "obligations_sha256": "3653cd3492bb9ceafd621c5e6b81d0343b29c14b50d931a678358bf5eb7707a2", diff --git a/evals/parity/transcripts/deterministic/physical-ios-qa.json b/evals/parity/transcripts/deterministic/physical-ios-qa.json index 756d5fdd59..4a31ce7aac 100644 --- a/evals/parity/transcripts/deterministic/physical-ios-qa.json +++ b/evals/parity/transcripts/deterministic/physical-ios-qa.json @@ -57,15 +57,15 @@ } }, "mechanical_port": { - "rendered_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "rendered_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/qa/references/legacy/ios-qa.md", - "rendered_legacy_body_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "rendered_legacy_body_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "semantic_signature": { - "normalized_sha256": "f52bc2bf974d87d5ae7fcf178d2c56471cc51eff3817bc1cb09cb0d9f21b35ee", + "normalized_sha256": "89f86c6816c9e8316e06206a03fe506cb065c7c4de5ea26c1daee4aa121d7c8e", "headings_sha256": "79304f733fa401249340540c659f15a9da4e7a89a3d505d39437f6bcbcaf4ce5", "questions_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "obligations_sha256": "1d8b9f6b93b844f1a9ad43f1434c7b0a4b891a6e5e0ad8827f41c7eefbef239f", diff --git a/evals/parity/transcripts/deterministic/qa-fix-verify.json b/evals/parity/transcripts/deterministic/qa-fix-verify.json index 09e235a68e..8359c3f855 100644 --- a/evals/parity/transcripts/deterministic/qa-fix-verify.json +++ b/evals/parity/transcripts/deterministic/qa-fix-verify.json @@ -57,15 +57,15 @@ } }, "mechanical_port": { - "rendered_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "rendered_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/qa/references/legacy/qa.md", - "rendered_legacy_body_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "rendered_legacy_body_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "semantic_signature": { - "normalized_sha256": "d183afbd52ef70022ee6379a9fcedeec6a002fea78ed69771112a96bd6c6af9d", + "normalized_sha256": "262eeb8667c1ae1011b135fb2ba4c4340755246d832fc29dd1c570607a3ca2b2", "headings_sha256": "dd0b56f79cee31c4c3c71f32fb0a4438f686c6ae517625a7d59475deecd4c75e", "questions_sha256": "8fece376011d8a8606bcd05d52332f77b56de551b7f1abde94bff1f33ef0a9c1", "obligations_sha256": "8c9e044c4224e4c35838d6ae9241405a5df42c2372c2ce272e0a11f37ab68c86", diff --git a/evals/parity/transcripts/deterministic/qa-report-only.json b/evals/parity/transcripts/deterministic/qa-report-only.json index 6e23b4024a..87ba25e207 100644 --- a/evals/parity/transcripts/deterministic/qa-report-only.json +++ b/evals/parity/transcripts/deterministic/qa-report-only.json @@ -57,15 +57,15 @@ } }, "mechanical_port": { - "rendered_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "rendered_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/qa/references/legacy/qa-only.md", - "rendered_legacy_body_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "rendered_legacy_body_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "semantic_signature": { - "normalized_sha256": "c51cb107dee42b39595816c72555d522eeb947d45231b3c11ab84498db8d1b6d", + "normalized_sha256": "2ba35b1c61520c8b1744228a3a633a6a11664abef4258bcd37719c4cb1ac2731", "headings_sha256": "0241f7efa9ffcaef764bd6517099b03394e09f90c8d1f3f015c7c7a135f78201", "questions_sha256": "8fece376011d8a8606bcd05d52332f77b56de551b7f1abde94bff1f33ef0a9c1", "obligations_sha256": "f16cce08f386155cae52758fb3f7235e1c8090ca7d84117caa4761b76927d9d5", diff --git a/evals/parity/transcripts/deterministic/security-review.json b/evals/parity/transcripts/deterministic/security-review.json index b69d83d8e2..fef780846e 100644 --- a/evals/parity/transcripts/deterministic/security-review.json +++ b/evals/parity/transcripts/deterministic/security-review.json @@ -56,15 +56,15 @@ } }, "mechanical_port": { - "rendered_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "rendered_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/review/references/legacy/cso.md", - "rendered_legacy_body_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "rendered_legacy_body_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "semantic_signature": { - "normalized_sha256": "899f89b47b55a133c0da6140eabd1bd032760d492f431208c3ae650b7b961cc3", + "normalized_sha256": "24b0c9f1d40e10685497cd517e19538f06b91e51f7f2ab6ffe8df66f4fc61490", "headings_sha256": "45365014bc4ff67be80c8747098a368c439104ea76ad4d1c36000fe65192e291", "questions_sha256": "e7e23a9ccc1e3677812d260b4f38b16ef8c438e0b2346c045c1334b7045006de", "obligations_sha256": "2f83f56fa932b0937d9f968a7e27c974f17772ad8062b9577504dfae600622f6", diff --git a/evals/parity/transcripts/deterministic/ship.json b/evals/parity/transcripts/deterministic/ship.json index e7e86164b1..da6f67a6fb 100644 --- a/evals/parity/transcripts/deterministic/ship.json +++ b/evals/parity/transcripts/deterministic/ship.json @@ -56,15 +56,15 @@ } }, "mechanical_port": { - "rendered_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "rendered_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/ship/references/legacy/ship.md", - "rendered_legacy_body_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "rendered_legacy_body_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "semantic_signature": { - "normalized_sha256": "a0893ea65f869b7a08a27851f7b4ce1573583ce4f48c0dbe563bd3ac99b6eb82", + "normalized_sha256": "a6631f29b7ada76c5709ee9093534e950353be54c7cc663a9624302906f52d66", "headings_sha256": "42c12606506cfd2cfd31dc678c91d63b0212aa4e424730f5b9e257ab033c4958", "questions_sha256": "943ac3bc0b28becb558873810d1884e4311865a10cbd44a2b8c8ae0184fece84", "obligations_sha256": "20fb5ccf5136774161e40c7a9f07bb8d355ce614b39fce1329b02a08935e7d26", diff --git a/evals/parity/transcripts/deterministic/specification.json b/evals/parity/transcripts/deterministic/specification.json index b5da19aa7b..a9af0f39e9 100644 --- a/evals/parity/transcripts/deterministic/specification.json +++ b/evals/parity/transcripts/deterministic/specification.json @@ -58,15 +58,15 @@ } }, "mechanical_port": { - "rendered_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "rendered_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "differs_from_baseline": true, "allowed_difference": "Canonical GStack 2 carve: exclude the retired shared onboarding wrapper and host hook advisory; resolve retired invocations to five public routes; relocate host/runtime paths; lazy-load pinned carved sections from package-local references." }, "candidate": { "target_path": "skills/plan/references/legacy/spec.md", - "rendered_legacy_body_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "rendered_legacy_body_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "semantic_signature": { - "normalized_sha256": "e1369668980fb75cb03bc8a5e021eb9e62219f34f871efd8bb711fa3654f329d", + "normalized_sha256": "e4525d525e43fc24fa0d72ee01a21020e115faa49bb94949ba9f4b38dd6332dd", "headings_sha256": "339ccc4b1c0d870b7c696ef28b1116ee22742a81d7b8713040a2766a3e36c3c3", "questions_sha256": "d98a7e322b838c543487c19d59332501a4393513b5f1a58b2ba4ac6cf911aa67", "obligations_sha256": "7f6a3078d1b84288b26cf37943d7a036d8f7a50d7a1dcea6aefe96c297662a6b", diff --git a/gstack/llms.txt b/gstack/llms.txt index 63c0ba602e..ac6a2d05e7 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -37,6 +37,7 @@ Conventions: - [/land-and-deploy](land-and-deploy/SKILL.md): Land and deploy workflow. - [/landing-report](landing-report/SKILL.md): Read-only queue dashboard for workspace-aware ship. - [/learn](learn/SKILL.md): Manage project learnings. +- [/make-pdf](make-pdf/SKILL.md): Turn any markdown file into a publication-quality PDF. - [/office-hours](office-hours/SKILL.md): YC Office Hours — two modes. - [/open-gstack-browser](open-gstack-browser/SKILL.md): Launch GStack Browser — AI-controlled Chromium you can watch in real time. - [/pair-agent](pair-agent/SKILL.md): Pair a remote AI agent with your browser. diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md new file mode 100644 index 0000000000..bbeb159790 --- /dev/null +++ b/make-pdf/SKILL.md @@ -0,0 +1,790 @@ +--- +name: gstack-1-make-pdf +preamble-tier: 1 +version: 1.0.0 +description: Turn any markdown file into a publication-quality PDF. (gstack) +triggers: + - markdown to pdf + - generate pdf + - make pdf + - export pdf +allowed-tools: + - Bash + - Read + - AskUserQuestion +metadata: + internal: true +--- + + + + +## When to invoke this skill + +Proper 1in margins, +intelligent page breaks, page numbers, cover pages, running headers, curly +quotes and em dashes, clickable TOC, diagonal DRAFT watermark. 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". + +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". + +## 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" +_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") +case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac +echo "SESSION_KIND: $_SESSION_KIND" +# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP +# variant flaky), so skills render decisions as prose instead of calling the +# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS) +# still BLOCKs rather than rendering prose to nobody. +if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then + echo "CONDUCTOR_SESSION: true" +fi +_ACTIVATED=$([ -f ~/.gstack/.activated ] && echo "yes" || echo "no") +_FIRST_LOOP_SHOWN=$([ -f ~/.gstack/.first-loop-tip-shown ] && echo "yes" || echo "no") +echo "ACTIVATED: $_ACTIVATED" +echo "FIRST_LOOP_SHOWN: $_FIRST_LOOP_SHOWN" +# First-run project detection: run the detector ONLY on the first-ever skill run +# (ACTIVATED=no, interactive) so it stays off the hot path for every run after. +_FIRST_TASK="" +if [ "$_ACTIVATED" = "no" ] && [ "$_SESSION_KIND" != "headless" ]; then + _FIRST_TASK=$(~/.claude/skills/gstack/bin/gstack-first-task-detect 2>/dev/null || true) +fi +echo "FIRST_TASK: $_FIRST_TASK" +_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" +_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=$(~/.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":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +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 +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 +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"make-pdf","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & +_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" +_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=$(~/.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" +# Plan-mode hint for skills like /spec that branch behavior on plan-mode state. +# Claude Code exposes plan mode via system reminders; we detect best-effort +# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active) and +# fall back to "inactive". Codex hosts and Claude execution mode both end up +# inactive, which is the safe default (defaults to file+execute pipeline). +if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then + export GSTACK_PLAN_MODE="active" +elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then + export GSTACK_PLAN_MODE="active" +else + export GSTACK_PLAN_MODE="inactive" +fi +echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" +[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true +``` + +## 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" ] && [ -x "${GSTACK_HOME:-$HOME/.gstack}/bin/make-pdf" ] && P="${GSTACK_HOME:-$HOME/.gstack}/bin/make-pdf" +[ -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 [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 ` — 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. + +## Plan Mode Safe Operations + +In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`codex review`, writes to `~/.gstack/`, writes to the plan file, and `open` for generated artifacts. + +## Skill Invocation During Plan Mode + +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. + +If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" + +If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`. + +If output shows `UPGRADE_AVAILABLE `: 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 `: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery. + +Feature discovery, max one prompt per session: +- Missing `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`: AskUserQuestion for Continuous checkpoint auto-commits. If accepted, run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. Always touch marker. +- Missing `~/.claude/skills/gstack/.feature-prompted-model-overlay`: inform "Model overlays are active. MODEL_OVERLAY shows the patch." Always touch marker. + +After upgrade prompts, continue workflow. + +If `WRITING_STYLE_PENDING` is `yes`: ask once about writing style: + +> v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse? + +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 +``` + +Skip if `WRITING_STYLE_PENDING` is `no`. + +If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Ocean** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if yes. Always run `touch`. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion: + +> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload. + +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 follow-up: + +> Anonymous mode sends only aggregate usage, no unique ID. + +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 +``` + +Skip if `TEL_PROMPTED` is `yes`. + +If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: ask once: + +> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs? + +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 +``` + +Skip if `PROACTIVE_PROMPTED` is `yes`. + +## First-run guidance (one-time) + +If `ACTIVATED` is `no` (first skill run on this machine) AND the preamble printed a non-empty `FIRST_TASK:` value that is NOT `nongit`: show ONE short, project-specific line mapped from the token, as a heads-up, then CONTINUE with whatever the user actually asked — do NOT halt their task. Map the token: `greenfield` → "Fresh repo — shape it first with `/spec` or `/office-hours`." `code_node`/`code_python`/`code_rust`/`code_go`/`code_ruby`/`code_ios` → "There's code here — `/qa` to see it work, or `/investigate` if something's off." `branch_ahead` → "Unshipped work on this branch — `/review` then `/ship`." `dirty_default` → "Uncommitted changes — `/review` before committing." `clean_default` → "Pick one: `/spec`, `/investigate`, or `/qa`." Then substitute the token you saw for TASK_TOKEN and run (best-effort), and mark activated: +```bash +~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type first_task_scaffold_shown --skill "TASK_TOKEN" --outcome shown 2>/dev/null || true +touch ~/.gstack/.activated 2>/dev/null || true +``` + +If `ACTIVATED` is `no` but `FIRST_TASK:` is empty or `nongit` (headless, non-git, or nothing actionable): show nothing, just run `touch ~/.gstack/.activated 2>/dev/null || true`. + +Else if `ACTIVATED` is `yes` AND `FIRST_LOOP_SHOWN` is `no`: say once as a heads-up (then continue): + +> Tip: gstack pays off when you complete one loop — **plan → review → ship**. A common first loop: `/office-hours` or `/spec` to shape it, `/plan-eng-review` to lock it, then `/ship`. + +Then run `touch ~/.gstack/.first-loop-tip-shown 2>/dev/null || true`. + +Skip this section if `ACTIVATED` and `FIRST_LOOP_SHOWN` are both `yes`. + +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. + +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, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore +- Author a backlog-ready spec/issue → invoke /spec +``` + +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` and say they can re-enable with `gstack-config set routing_declined false`. + +This only happens once per project. Skip if `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`. + +If `VENDORED_GSTACK` is `yes`, warn once via AskUserQuestion unless `~/.gstack/.vendoring-warned-$SLUG` exists: + +> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated. +> Migrate to team mode? + +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} +``` + +If marker exists, skip. + +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. + +## Artifacts Sync (skill start) + +```bash +_GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# Prefer the v1.27.0.0 artifacts file; fall back to brain file for users +# upgrading mid-stream before the migration script runs. +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + _BRAIN_REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" +else + _BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +fi +_BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" +_BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" + +# /sync-gbrain context-load: teach the agent to use gbrain when it's available. +# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the +# git toplevel to scope queries. Look for the pin in the worktree (not a global +# state file) so that opening worktree B without a pin doesn't claim "indexed" +# just because worktree A was synced. Empty string when gbrain is not +# configured (zero context cost for non-gbrain users). +_GBRAIN_CONFIG="$HOME/.gbrain/config.json" +if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then + _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) + if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then + _GBRAIN_PIN_PATH="" + _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then + _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" + fi + if [ -n "$_GBRAIN_PIN_PATH" ]; then + echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" + echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" + echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + echo "Run /sync-gbrain to refresh." + else + echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" + echo "before relying on \`gbrain search\` for code questions in this worktree." + echo "Falls back to Grep until pinned." + fi + fi +fi + +_BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) + +# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is +# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its +# own cadence. Read claude.json directly to keep this preamble fast (no +# subprocess to claude CLI on every skill start). +_GBRAIN_MCP_MODE="none" +if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + case "$_GBRAIN_MCP_TYPE" in + url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; + stdio) _GBRAIN_MCP_MODE="local-stdio" ;; + esac +fi + +if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" = "off" ]; then + _BRAIN_NEW_URL=$(head -1 "$_BRAIN_REMOTE_FILE" 2>/dev/null | tr -d '[:space:]') + if [ -n "$_BRAIN_NEW_URL" ]; then + echo "ARTIFACTS_SYNC: artifacts repo detected: $_BRAIN_NEW_URL" + echo "ARTIFACTS_SYNC: run 'gstack-brain-restore' to pull your cross-machine artifacts (or 'gstack-config set artifacts_sync_mode off' to dismiss forever)" + fi +fi + +if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then + _BRAIN_LAST_PULL_FILE="$_GSTACK_HOME/.brain-last-pull" + _BRAIN_NOW=$(date +%s) + _BRAIN_DO_PULL=1 + if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then + _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) + [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 + fi + if [ "$_BRAIN_DO_PULL" = "1" ]; then + ( cd "$_GSTACK_HOME" && git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true + echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE" + fi + "$_BRAIN_SYNC_BIN" --once 2>/dev/null || true +fi + +if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then + # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server + # pulls from GitHub/GitLab). Show the user this is by design, not broken. + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" +elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then + _BRAIN_QUEUE_DEPTH=0 + [ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') + _BRAIN_LAST_PUSH="never" + [ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never) + echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH" +else + echo "ARTIFACTS_SYNC: off" +fi +``` + + + +Privacy stop-gate: if output shows `ARTIFACTS_SYNC: off`, `artifacts_sync_mode_prompted` is `false`, and gbrain is on PATH or `gbrain doctor --fast --json` works, ask once: + +> gstack can publish your artifacts (CEO plans, designs, reports) to a private GitHub repo that GBrain indexes across machines. How much should sync? + +Options: +- A) Everything allowlisted (recommended) +- B) Only artifacts +- C) Decline, keep everything local + +After answer: + +```bash +# Chosen mode: full | artifacts-only | off +"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode +"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode_prompted true +``` + +If A/B and `~/.gstack/.git` is missing, ask whether to run `gstack-artifacts-init`. Do not block the skill. + +At skill END before telemetry: + +```bash +"~/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true +"~/.claude/skills/gstack/bin/gstack-brain-sync" --once 2>/dev/null || true +``` + + +## 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 + +Direct, concrete, builder-to-builder. Name the file, function, command, and user-visible impact. No filler. + +No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted. Never corporate or academic. Short paragraphs. End with what to do. + +The user has context you do not. 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** — completed with evidence. +- **DONE_WITH_CONCERNS** — completed, but list concerns. +- **BLOCKED** — cannot proceed; state blocker and what was tried. +- **NEEDS_CONTEXT** — missing info; state exactly what is needed. + +Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope you cannot verify. Format: `STATUS`, `REASON`, `ATTEMPTED`, `RECOMMENDATION`. + +## Operational Self-Improvement + +Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it: + +```bash +~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}' +``` + +Do not log obvious facts or one-time transient errors. + +## Telemetry (run last) + +After workflow completion, log telemetry. Use skill `name:` from frontmatter. OUTCOME is success/error/abort/unknown. + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to +`~/.gstack/analytics/`, matching preamble analytics writes. + +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`, `OUTCOME`, and `USED_BROWSE` before running. + +## Plan Status Footer + +Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode. + +# 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". + +On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial +aren't present by default, and Liberation Sans is the standard metric-compatible +fallback. CI and Docker builds install it automatically via Dockerfile.ci. + +Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI +Emoji) ship one; most Linux distros and containers ship none, so emoji render as +empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux +(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple / +Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI +without sudo, managed or offline machines). + +## 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 +``` + +### Diagrams — mermaid and excalidraw fences render as pictures + +A column-0 ` ```mermaid ` or ` ```excalidraw ` fence in the markdown renders +as a crisp vector diagram, fully offline (vendored bundle, no CDN). Indented +fences (inside lists) stay plain code blocks by design. A broken fence +produces a visible red diagnostic block with the parse error — never silent +raw code. + +Fence info-string options: + +``` +```mermaid title="Auth flow" ← caption + aria-label +```mermaid render=false ← keep it as a code block (today's behavior) +```mermaid page=landscape ← force this diagram onto a landscape page +```mermaid page=portrait ← veto auto-landscape for this diagram +``` + +A ` ```excalidraw ` fence contains a full .excalidraw scene file (what +excalidraw.com saves). Authoring NEW diagrams from English is `/diagram`'s +job — it emits an editable triplet (source, .excalidraw, SVG/PNG) and pairs +with this skill: embed the `.mmd` source in your markdown, not the PNG. + +### Images — scaled right, never truncated + +Local images inline automatically (relative paths resolve against the +markdown file). Every image caps at the content box — zero truncation, ever. +Oversized photos downscale to print resolution (300dpi) so payloads stay +small with no visible quality loss. + +Remote (http/https) images are **blocked with a visible placeholder** by +default — offline posture; pass `--allow-network` to fetch them. An image +that resolves outside the markdown's directory (even via symlink) still +inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or +non-regular files (fifos, devices) degrade to a placeholder instead of +hanging the run. + +Per-image directives, written immediately after the image: + +``` +![chart](data.png){width=full} ← stretch to content-box width +![chart](data.png){width=50%} ← percentage or 3in/8cm/200px +![wide](arch.png){page=landscape} ← give it its own landscape page +![wide](shot.png){page=portrait} ← veto auto-landscape +``` + +Wide, small-text diagram images auto-promote to their own landscape page +(conservative: aspect ≥ 1.8, width over ~2.5x the content box, AND a +diagram-ish alt word — diagram/architecture/flowchart/chart/graph). The +promoted page is vertically centered. When the heuristic guesses wrong, +`{page=portrait}` vetoes it; false negatives just need `{page=landscape}`. + +### Other formats — single-file HTML and Word + +```bash +$P generate readme.md out.html --to html # ONE self-contained file: inline + # SVG diagrams, data-URI images, + # zero network refs, screen-readable +$P generate readme.md out.docx --to docx # Word: content fidelity (headings, + # tables, code, diagrams as PNG) — + # layout is Word's, not ours +``` + +`--to` is the output format. `--format` is something else entirely (a +`--page-size` alias) — don't confuse them. + +### CI mode — fail loud on missing assets + +```bash +$P generate docs.md --strict # missing, remote, out-of-tree, oversized, + # and non-regular-file images exit non-zero + # instead of warn + placeholder +``` + +## Common flags + +``` +Page layout: + --margins 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 Diagonal watermark ("DRAFT", "CONFIDENTIAL") + --header-template Custom running header + --footer-template Custom footer (mutex with --page-numbers) + --no-confidential Suppress the CONFIDENTIAL right-footer + +Output: + --to pdf|html|docx Output format (default: pdf). html = single + self-contained file; docx = content fidelity. + --strict Missing, remote, out-of-tree, oversized, or + non-regular-file images fail the run (CI mode). + --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: remote + images render as a visible blocked placeholder + (no tracking pixels fetch at print time). + +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`. +- "[remote image blocked]" placeholder in the output → 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`. diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl new file mode 100644 index 0000000000..9133a711d4 --- /dev/null +++ b/make-pdf/SKILL.md.tmpl @@ -0,0 +1,247 @@ +--- +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. 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". + +On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial +aren't present by default, and Liberation Sans is the standard metric-compatible +fallback. CI and Docker builds install it automatically via Dockerfile.ci. + +Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI +Emoji) ship one; most Linux distros and containers ship none, so emoji render as +empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux +(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple / +Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI +without sudo, managed or offline machines). + +## 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 +``` + +### Diagrams — mermaid and excalidraw fences render as pictures + +A column-0 ` ```mermaid ` or ` ```excalidraw ` fence in the markdown renders +as a crisp vector diagram, fully offline (vendored bundle, no CDN). Indented +fences (inside lists) stay plain code blocks by design. A broken fence +produces a visible red diagnostic block with the parse error — never silent +raw code. + +Fence info-string options: + +``` +```mermaid title="Auth flow" ← caption + aria-label +```mermaid render=false ← keep it as a code block (today's behavior) +```mermaid page=landscape ← force this diagram onto a landscape page +```mermaid page=portrait ← veto auto-landscape for this diagram +``` + +A ` ```excalidraw ` fence contains a full .excalidraw scene file (what +excalidraw.com saves). Authoring NEW diagrams from English is `/diagram`'s +job — it emits an editable triplet (source, .excalidraw, SVG/PNG) and pairs +with this skill: embed the `.mmd` source in your markdown, not the PNG. + +### Images — scaled right, never truncated + +Local images inline automatically (relative paths resolve against the +markdown file). Every image caps at the content box — zero truncation, ever. +Oversized photos downscale to print resolution (300dpi) so payloads stay +small with no visible quality loss. + +Remote (http/https) images are **blocked with a visible placeholder** by +default — offline posture; pass `--allow-network` to fetch them. An image +that resolves outside the markdown's directory (even via symlink) still +inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or +non-regular files (fifos, devices) degrade to a placeholder instead of +hanging the run. + +Per-image directives, written immediately after the image: + +``` +![chart](data.png){width=full} ← stretch to content-box width +![chart](data.png){width=50%} ← percentage or 3in/8cm/200px +![wide](arch.png){page=landscape} ← give it its own landscape page +![wide](shot.png){page=portrait} ← veto auto-landscape +``` + +Wide, small-text diagram images auto-promote to their own landscape page +(conservative: aspect ≥ 1.8, width over ~2.5x the content box, AND a +diagram-ish alt word — diagram/architecture/flowchart/chart/graph). The +promoted page is vertically centered. When the heuristic guesses wrong, +`{page=portrait}` vetoes it; false negatives just need `{page=landscape}`. + +### Other formats — single-file HTML and Word + +```bash +$P generate readme.md out.html --to html # ONE self-contained file: inline + # SVG diagrams, data-URI images, + # zero network refs, screen-readable +$P generate readme.md out.docx --to docx # Word: content fidelity (headings, + # tables, code, diagrams as PNG) — + # layout is Word's, not ours +``` + +`--to` is the output format. `--format` is something else entirely (a +`--page-size` alias) — don't confuse them. + +### CI mode — fail loud on missing assets + +```bash +$P generate docs.md --strict # missing, remote, out-of-tree, oversized, + # and non-regular-file images exit non-zero + # instead of warn + placeholder +``` + +## Common flags + +``` +Page layout: + --margins 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 Diagonal watermark ("DRAFT", "CONFIDENTIAL") + --header-template Custom running header + --footer-template Custom footer (mutex with --page-numbers) + --no-confidential Suppress the CONFIDENTIAL right-footer + +Output: + --to pdf|html|docx Output format (default: pdf). html = single + self-contained file; docx = content fidelity. + --strict Missing, remote, out-of-tree, oversized, or + non-regular-file images fail the run (CI mode). + --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: remote + images render as a visible blocked placeholder + (no tracking pixels fetch at print time). + +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`. +- "[remote image blocked]" placeholder in the output → 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`. diff --git a/make-pdf/src/browseClient.ts b/make-pdf/src/browseClient.ts new file mode 100644 index 0000000000..da25677e59 --- /dev/null +++ b/make-pdf/src/browseClient.ts @@ -0,0 +1,410 @@ +/** + * 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, v1.24-aligned): + * 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern) + * 2. $BROWSE_BIN env override (back-compat alias) + * 3. sibling dir: dirname(argv[0])/../browse/dist/browse[.exe] + * 4. ~/.claude/skills/gstack/browse/dist/browse[.exe] + * 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively + * 6. error with setup hint + * + * Windows quirks: + * - bun build --compile --outfile X emits X.exe on win32, so candidate paths + * need a .exe probe pass (fs.accessSync(X_OK) degrades to existence-checking + * on Windows per Node docs, so the bare path silently misses the .exe file). + * - `which` only exists in Git Bash; Bun.which() handles cmd.exe / PowerShell + * natively via PATHEXT semantics. + */ + +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 +} + +/** + * Resolve an absolute or PATH-resolvable command via Bun.which-style semantics, + * with a Windows .exe/.cmd/.bat extension probe for absolute paths. Mirrors + * the v1.24 claude-bin.ts override-resolution shape. + * + * Returns null if nothing resolves; callers degrade with a typed error rather + * than throwing here. + */ +function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null { + if (!value?.trim()) return null; + const trimmed = value.trim().replace(/^"(.*)"$/, '$1'); + if (path.isAbsolute(trimmed)) return findExecutable(trimmed); + const PATH = env.PATH ?? env.Path ?? ''; + return Bun.which(trimmed, { PATH }) ?? null; +} + +/** + * Probe a base path for executability, honoring Windows extension suffixes. + * + * On POSIX, isExecutable(base) is the only check that matters. On Windows, + * fs.accessSync(p, X_OK) degrades to an existence check — so a bare-path probe + * misses bun-compiled binaries (which land at base.exe). After the bare probe + * fails on win32, try .exe / .cmd / .bat. Linux/macOS behavior is unchanged. + */ +export function findExecutable(base: string): string | null { + if (isExecutable(base)) return base; + if (process.platform === "win32") { + for (const ext of [".exe", ".cmd", ".bat"]) { + const withExt = base + ext; + if (isExecutable(withExt)) return withExt; + } + } + return null; +} + +/** + * Locate the browse binary. Throws a BrowseClientError with a + * canonical setup message if not found. See header for resolution order. + */ +export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string { + // 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat). + const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN; + const override = resolveOverride(overrideRaw, env); + if (override) return override; + + // 3: sibling — make-pdf and browse co-located 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) { + const found = findExecutable(candidate); + if (found) return found; + } + + // 4: global install. + const home = os.homedir(); + const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse"); + const globalFound = findExecutable(globalPath); + if (globalFound) return globalFound; + + // 5: PATH lookup via Bun.which — handles Windows PATHEXT natively (no `which` + // dependency on cmd.exe / PowerShell, no `where`-vs-`which` branch). + const PATH = env.PATH ?? env.Path ?? ''; + const onPath = Bun.which('browse', { PATH }); + if (onPath) return onPath; + + throw new BrowseClientError( + /* exitCode */ 127, + "resolve", + [ + "browse binary not found.", + "", + "make-pdf needs browse (the gstack Chromium daemon) to render PDFs.", + "Tried:", + ` - $GSTACK_BROWSE_BIN (${env.GSTACK_BROWSE_BIN || "unset"})`, + ` - $BROWSE_BIN (${env.BROWSE_BIN || "unset"})`, + ` - sibling: ${siblingCandidates.join(", ")}`, + ` - global: ${globalPath}`, + " - PATH: `browse`", + "", + "To fix: run gstack setup from the gstack repo:", + " cd ~/.claude/skills/gstack && ./setup", + "", + "Or set GSTACK_BROWSE_BIN explicitly:", + process.platform === "win32" + ? ' setx GSTACK_BROWSE_BIN "C:\\path\\to\\browse.exe"' + : " export GSTACK_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"], + // A wedged daemon (or a hostile mermaid source spinning the renderer) + // must fail the run, not hang it forever. + timeout: 120_000, + }); + } 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). + * + * Path must be under the browse safe-dirs allowlist (/tmp or cwd on + * non-Windows; os.tmpdir on Windows). v1.6.0.0 tightened --from-file + * validation to close a CLI/API parity gap (PR #1103), so os.tmpdir() + * on macOS (/var/folders/...) now fails validateReadPath. Use the same + * TEMP_DIR convention as browse/src/platform.ts. + */ +const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp"; + +function writePayloadFile(payload: Record): string { + const hash = crypto.createHash("sha256") + .update(JSON.stringify(payload)) + .digest("hex") + .slice(0, 12); + const tmpPath = path.join(PAYLOAD_TMP_DIR, `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); + } +} + +/** + * Load an HTML file (already under browse's safe dirs, e.g. /tmp) into a tab + * by path. Cheaper than loadHtml for large pages — no JSON payload round-trip; + * browse reads the file directly (diagram-render bundle is ~9MB). + */ +export function loadHtmlFile(opts: { file: string; tabId: number; waitUntil?: "load" | "domcontentloaded" | "networkidle" }): void { + const args = ["load-html", opts.file, "--tab-id", String(opts.tabId)]; + if (opts.waitUntil) args.push("--wait-until", opts.waitUntil); + runBrowse(args); +} + +/** + * 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(); +} + +/** + * Evaluate a JS file in a tab (`browse eval `): the argv-safe transport + * for expressions too large for a command-line element. The file must live + * under browse's safe dirs (/tmp or cwd). + */ +export function evalFile(opts: { file: string; tabId: number }): string { + return runBrowse([ + "eval", + opts.file, + "--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; + // Real sleep, not a busy-wait: this poll now runs on every diagram-render + // bundle load (and after every fence render error), exactly while Chromium + // is parsing a 9MB page on the same machine — spinning a core competes + // with the work being awaited. + Bun.sleepSync(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 { + // Shape mirrors what the browse `pdf` case expects when reading --from-file + const out: Record = {}; + 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"); +} diff --git a/make-pdf/src/cli.ts b/make-pdf/src/cli.ts new file mode 100644 index 0000000000..988e204449 --- /dev/null +++ b/make-pdf/src/cli.ts @@ -0,0 +1,275 @@ +#!/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; +} + +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 = {}; + + 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("Output format:"); + lines.push(" --to pdf|html|docx What to produce (default: pdf)."); + lines.push(" html = single self-contained file, no network refs."); + lines.push(" docx = content fidelity, diagrams as PNG."); + lines.push(""); + lines.push("Page layout:"); + lines.push(" --margins All four margins (default: 1in). in, pt, cm, mm."); + lines.push(" --page-size letter|a4|legal (aliases: --format — page SIZE, not output 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 Diagonal watermark on every page."); + lines.push(" --header-template "); + lines.push(" --footer-template 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("Diagrams & images:"); + lines.push(" ```mermaid / ```excalidraw fences render as vector diagrams."); + lines.push(" Add render=false to a fence info string to keep it as a code block."); + lines.push(" Local images are inlined; oversized rasters downscale to print resolution."); + lines.push(" --strict Missing/remote images fail the run (CI mode)."); + 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 "); + console.error("Usage: $P generate [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; + }; + const to = typeof f.to === "string" ? f.to.toLowerCase() : "pdf"; + if (to !== "pdf" && to !== "html" && to !== "docx") { + console.error(`$P generate: invalid --to '${f.to}'. Expected pdf, html, or docx.`); + console.error("(--format is a --page-size alias, not the output format.)"); + process.exit(ExitCode.BadArgs); + } + return { + input: p[0], + output: p[1], + to: to as GenerateOptions["to"], + 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, + strict: f.strict === 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 "); + console.error("Usage: $P preview [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 { + 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(); diff --git a/make-pdf/src/commands.ts b/make-pdf/src/commands.ts new file mode 100644 index 0000000000..a5e781d1e2 --- /dev/null +++ b/make-pdf/src/commands.ts @@ -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([ + ["generate", { + description: "Render a markdown file to a publication-quality PDF", + usage: "generate [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 [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: [], + }], +]); diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts new file mode 100644 index 0000000000..bf12249bbc --- /dev/null +++ b/make-pdf/src/diagram-prepass.ts @@ -0,0 +1,846 @@ +/** + * Diagram + image pre-pass. Runs between "read markdown" and render() in the + * orchestrator, and owns everything that needs the diagram-render bundle. + * + * markdown ─▶ extractDiagramFences() ──▶ render() (marked+sanitize+smarty) + * │ fences → placeholder tokens │ + * │ ▼ + * └─▶ renderFenceSlots() ───────────▶ substituteSlots(html, slots) + * one browse render tab/run │ + * error ⇒ diagnostic block + page reload ▼ + * inlineLocalImages(html) + * data URIs, probe dims from bytes, + * downscale >2x content box @300dpi, + * remote warn / missing placeholder / + * --strict hard-fail + * + * Placeholders survive marked, the sanitizer, and smartypants because they are + * plain hyphenated lowercase tokens with no quotes or HTML. Slot HTML is run + * through the same sanitizer as user content before substitution (the bundle + * renders with securityLevel strict — the sanitizer is the second layer). + * + * Reset contract (eng-review D6.2): each fence renders with a fresh + * mermaid.render id; after ANY render error the bundle page is reloaded before + * the next fence so a poisoned global can't corrupt diagram N+1. + */ + +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 { fileURLToPath } from "node:url"; + +import * as browseClient from "./browseClient"; +import { escapeHtml, sanitizeUntrustedHtml } from "./render"; +import { imageDims } from "./image-size"; + +// ─── Types ──────────────────────────────────────────────────────────── + +export interface DiagramFence { + /** "mermaid" | "excalidraw" */ + lang: string; + /** Fence body (the diagram source). */ + source: string; + /** Optional title="..." from the fence info string (a11y label, D6.4). */ + title?: string; + /** Optional page=landscape|portrait fence directive (image-policy override). */ + page?: "landscape" | "portrait"; + /** render=false → leave as a plain code block (escape hatch, D6.3). */ + render: boolean; + /** Placeholder token substituted into the markdown. */ + token: string; + /** 1-based ordinal among rendered fences (unique ids, aria fallback). */ + ordinal: number; +} + +export interface FenceExtraction { + markdown: string; + fences: DiagramFence[]; +} + +export interface PrepassWarnings { + warn: (msg: string) => void; +} + +export interface PrepassImageOptions { + /** Directory of the source markdown — relative image paths resolve here. */ + inputDir: string; + /** Hard-fail on missing/remote images instead of warn (D6.1). */ + strict: boolean; + /** Remote images are left untouched when network is explicitly allowed. */ + allowNetwork: boolean; + /** Physical content-box width in inches (page width minus margins). */ + contentWidthIn: number; + warn: (msg: string) => void; + /** Lazily provides a ready bundle tab (only opened when needed). */ + getTab: () => RenderTab | null; +} + +/** Print-resolution policy (eng-review D4): downscale rasters wider than + * 2 × contentWidth × 300dpi down to contentWidth × 300dpi. */ +const PRINT_DPI = 300; +const DOWNSCALE_FACTOR = 2; +/** Per-image read ceiling — bounds memory before any policy runs. */ +const MAX_IMAGE_BYTES = 64 * 1024 * 1024; + +export class StrictModeError extends Error { + constructor(msg: string) { + super(msg); + this.name = "StrictModeError"; + } +} + +// ─── Fence extraction (pure) ────────────────────────────────────────── + +const DIAGRAM_LANGS = new Set(["mermaid", "excalidraw"]); + +/** + * Extract column-0 ```mermaid / ```excalidraw fences, replacing each with a + * unique placeholder token paragraph. Backtick and tilde fences, any length + * >= 3; closers must be at least as long as the opener (CommonMark). Fences + * with `render=false` are left untouched. + * + * Two deliberate conservatisms (red-team finding — the original version + * reconstructed fences at column 0 and restructured lists): + * - Non-diagram fences replay as their ORIGINAL raw lines, byte-for-byte + * (only a render=false flag is removed, in place, preserving indent). + * - INDENTED diagram fences (inside lists/quotes) are NOT extracted — a + * column-0 placeholder would split the list. They replay verbatim as code. + */ +export function extractDiagramFences(markdown: string): FenceExtraction { + const lines = markdown.split("\n"); + const out: string[] = []; + const fences: DiagramFence[] = []; + const runId = crypto.randomBytes(4).toString("hex"); + + let i = 0; + let openFence: { + char: string; len: number; indent: number; info: string; + rawOpener: string; body: string[]; + } | null = null; + let ordinal = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (openFence) { + const close = matchFenceLine(line); + if (close && close.char === openFence.char && close.len >= openFence.len && close.info === "") { + const info = parseInfoString(openFence.info); + if (DIAGRAM_LANGS.has(info.lang) && info.render && openFence.indent === 0) { + ordinal++; + const token = `gstack-diagram-slot-${runId}-${ordinal}`; + fences.push({ + lang: info.lang, + source: openFence.body.join("\n"), + title: info.title, + page: info.page, + render: true, + token, + ordinal, + }); + out.push("", token, ""); + } else { + // Not extracted (other language, render=false, or indented): replay + // the ORIGINAL lines verbatim; only strip a render=false flag. + out.push(stripRenderFalse(openFence.rawOpener)); + out.push(...openFence.body); + out.push(line); + } + openFence = null; + i++; + continue; + } + openFence.body.push(line); + i++; + continue; + } + + const open = matchFenceLine(line); + if (open && open.info !== "") { + openFence = { ...open, rawOpener: line, body: [] }; + i++; + continue; + } + if (open) { + // Anonymous fence (plain code block) — copy through to its closer so a + // ```mermaid example INSIDE a plain fence is never extracted. + out.push(line); + i++; + while (i < lines.length) { + const l = lines[i]; + const close = matchFenceLine(l); + out.push(l); + i++; + if (close && close.char === open.char && close.len >= open.len && close.info === "") break; + } + continue; + } + + out.push(line); + i++; + } + + // Unclosed fence at EOF: replay verbatim (CommonMark treats it as code to EOF). + if (openFence) { + out.push(openFence.rawOpener); + out.push(...openFence.body); + } + + return { markdown: out.join("\n"), fences }; +} + +function matchFenceLine(line: string): { char: string; len: number; indent: number; info: string } | null { + const m = line.match(/^( {0,3})(`{3,}|~{3,})\s*(.*)$/); + if (!m) return null; + return { indent: m[1].length, char: m[2][0], len: m[2].length, info: m[3].trim() }; +} + +/** Remove a render=false flag from a raw opener line, preserving everything else. */ +function stripRenderFalse(rawOpener: string): string { + return rawOpener.replace(/\s*\brender\s*=\s*false\b/i, ""); +} + +/** Parse a fence info string: `mermaid`, `mermaid render=false`, + * `mermaid title="Auth flow"`, `mermaid page=landscape`. */ +export function parseInfoString(info: string): { + lang: string; render: boolean; title?: string; page?: "landscape" | "portrait"; +} { + const lang = (info.match(/^\S+/)?.[0] ?? "").toLowerCase(); + const render = !/\brender\s*=\s*false\b/i.test(info); + const title = info.match(/\btitle\s*=\s*"([^"]*)"/i)?.[1] + ?? info.match(/\btitle\s*=\s*'([^']*)'/i)?.[1]; + const pageRaw = info.match(/\bpage\s*=\s*(landscape|portrait)\b/i)?.[1]?.toLowerCase(); + const page = pageRaw === "landscape" || pageRaw === "portrait" ? pageRaw : undefined; + return { lang, render, title, page }; +} + +// ─── Slot substitution (pure) ───────────────────────────────────────── + +/** + * Replace placeholder tokens in rendered HTML with their final slot HTML. + * marked wraps the bare token line in

; replace the wrapper too so + * the figure isn't nested inside a paragraph. + */ +export function substituteSlots(html: string, slots: Map): string { + let s = html; + for (const [token, slotHtml] of slots) { + // Function replacement is load-bearing: slot HTML carries user/LLM-authored + // diagram label text, and string-form replace() expands $&, $', $` patterns + // inside it — a label containing "$'" would duplicate the document tail. + const wrapped = new RegExp(`

\\s*${token}\\s*

`, "g"); + const replaced = s.replace(wrapped, () => slotHtml); + s = replaced !== s ? replaced : s.split(token).join(slotHtml); + } + return s; +} + +/** + * Visible diagnostic block for a failed fence render — never silent raw code + * (eng-review: explicit error blocks). Sanitizer-safe: all dynamic content is + * HTML-escaped. + */ +export function buildDiagnosticBlock(fence: DiagramFence, errorMessage: string): string { + const excerpt = fence.source.split("\n").slice(0, 8).join("\n"); + const truncated = fence.source.split("\n").length > 8 ? "\n…" : ""; + return [ + ``, + ].join("\n"); +} + +/** + * Wrap a rendered SVG in an accessible figure (D6.4). The raw fence source is + * preserved base64-encoded in a data attribute — an HTML comment would need + * `--` escaping, which corrupts every mermaid arrow (`-->`) and breaks + * round-trip recovery. + */ +export function buildDiagramFigure(fence: DiagramFence, svg: string): string { + const label = diagramLabel(fence); + const cleanSvg = sanitizeUntrustedHtml(svg); + const captioned = fence.title + ? `\n
${escapeHtml(fence.title)}
` + : ""; + const pageAttr = fence.page ? ` data-gstack-page="${fence.page}"` : ""; + const sourceB64 = Buffer.from(fence.source, "utf8").toString("base64"); + return [ + ``, + ].join("\n"); +} + +/** Recover the original fence source from a rendered figure (round-trip). */ +export function decodeFigureSource(figureHtml: string): string | null { + const m = figureHtml.match(/\bdata-gstack-source="([A-Za-z0-9+/=]*)"/); + if (!m) return null; + try { + return Buffer.from(m[1], "base64").toString("utf8"); + } catch { + return null; + } +} + +function diagramLabel(fence: DiagramFence): string { + return fence.title ?? `diagram ${fence.ordinal}`; +} + +// ─── Render tab (bundle page lifecycle) ─────────────────────────────── + +const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp"; +const READY_TIMEOUT_MS = 20_000; +// Expressions bigger than this ship via `browse eval ` instead of argv. +// 8KB is safe on every platform (Windows CreateProcess caps the WHOLE command +// line at 32,767 chars; Linux MAX_ARG_STRLEN is ~128KiB) and the tmp-file +// round-trip costs microseconds — one spawn regardless of payload size. +const MAX_ARGV_EXPR_BYTES = 8_000; + +export class RenderTab { + private constructor( + public readonly tabId: number, + private readonly stagedBundlePath: string, + ) {} + + /** + * Open a tab and load the diagram-render bundle. The bundle HTML is staged + * under /tmp (content-addressed, reused across runs — load-html only reads + * inside its safe dirs) and loaded by PATH, not --from-file: a 9MB JSON + * round-trip per run would be pure waste. + */ + static open(): RenderTab { + const bundleSrc = resolveBundlePath(); + const html = fs.readFileSync(bundleSrc); + const sha = crypto.createHash("sha256").update(html).digest("hex").slice(0, 16); + const staged = path.join(PAYLOAD_TMP_DIR, `gstack-diagram-render-${sha}.html`); + // Never trust an existing file at the predictable shared-/tmp name: verify + // its content hash and re-stage on mismatch (a pre-planted file would + // otherwise be loaded into the render tab as the bundle). + let needsWrite = true; + if (fs.existsSync(staged)) { + try { + const existing = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16); + needsWrite = existing !== sha; + } catch { + needsWrite = true; + } + } + if (needsWrite) { + // Concurrent-safe: write to a unique temp name, then atomic rename. + const tmp = `${staged}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`; + fs.writeFileSync(tmp, html); + try { + fs.renameSync(tmp, staged); + } catch (renameErr) { + try { fs.unlinkSync(tmp); } catch { /* best-effort tmp cleanup */ } + // Only swallow the rename failure when the surviving file HASHES to + // the expected bundle (a concurrent writer won an OS-level race). + // Sticky-bit /tmp makes rename-over-foreign-file fail EPERM — if the + // survivor were trusted on existence alone, a pre-planted file would + // ride through the exact check added to stop it. + let survivorOk = false; + try { + const survivor = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16); + survivorOk = survivor === sha; + } catch { /* unreadable survivor = not ok */ } + if (!survivorOk) throw renameErr; + } + } + const tabId = browseClient.newtab(); + const tab = new RenderTab(tabId, staged); + tab.loadBundle(); + return tab; + } + + /** (Re)load the bundle page — also the reset path after a render error. */ + loadBundle(): void { + browseClient.loadHtmlFile({ file: this.stagedBundlePath, tabId: this.tabId }); + const ready = browseClient.waitForExpression({ + expression: "document.getElementById('status') !== null && document.getElementById('status').textContent === 'ready'", + tabId: this.tabId, + timeoutMs: READY_TIMEOUT_MS, + }); + if (!ready) { + throw new Error( + "diagram-render bundle did not become ready in the browse tab " + + `(${READY_TIMEOUT_MS}ms). Check \`browse js "window.__errors"\` on tab ${this.tabId}.`, + ); + } + } + + /** + * Call one of the bundle's async window functions with JSON-safe string + * args. Errors come back as a recognizable ERR: prefix so a render failure + * is data, not a thrown browse exit. + */ + call(fn: string, ...args: Array): string { + const argList = args.map((a) => JSON.stringify(a)).join(","); + const expression = + `window.${fn}(${argList})` + + `.then(r => "OK:" + r)` + + `.catch(e => "ERR:" + String((e && e.message) || e))`; + const result = this.js(expression); + if (result.startsWith("OK:")) return result.slice(3); + if (result.startsWith("ERR:")) throw new RenderCallError(result.slice(4)); + throw new RenderCallError(`unexpected bundle result: ${result.slice(0, 200)}`); + } + + private js(expression: string): string { + // Large payloads (scene JSON, SVG text, data URIs) blow past argv limits — + // browseClient.js shells out with the expression as an argv element. The + // limit is BYTES, not chars (CJK content is 3x its char count in UTF-8), + // and Windows caps the whole command line at 32,767 chars — so anything + // big ships via `browse eval ` instead: one spawn, any size. + if (Buffer.byteLength(expression, "utf8") <= MAX_ARGV_EXPR_BYTES) { + return browseClient.js({ expression, tabId: this.tabId }); + } + return this.jsViaFile(expression); + } + + /** argv-safe path for big expressions: stage to a tmp file under browse's + * safe dirs and run `browse eval ` (one spawn regardless of size). */ + private jsViaFile(expression: string): string { + const file = path.join( + PAYLOAD_TMP_DIR, + `gstack-diagram-expr-${process.pid}-${crypto.randomBytes(4).toString("hex")}.js`, + ); + fs.writeFileSync(file, expression, "utf8"); + try { + return browseClient.evalFile({ file, tabId: this.tabId }); + } finally { + try { fs.unlinkSync(file); } catch { /* best-effort tmp cleanup */ } + } + } + + close(): void { + try { + browseClient.closetab(this.tabId); + } catch { + // best-effort: orchestrator finally path + } + } +} + +export class RenderCallError extends Error { + constructor(msg: string) { + super(msg); + this.name = "RenderCallError"; + } +} + +/** Resolve dist/diagram-render.html: env override → repo-relative (dev) → global install. */ +export function resolveBundlePath(env: NodeJS.ProcessEnv = process.env): string { + const candidates = [ + env.GSTACK_DIAGRAM_BUNDLE, + // dev: make-pdf/src/* → repo root lib/. (In a compiled binary this is the + // virtual /$bunfs/root and simply never exists — harmless.) + path.resolve(import.meta.dir, "../../lib/diagram-render/dist/diagram-render.html"), + // compiled binary at /make-pdf/dist/pdf → /lib/… — same shape + // in the repo and in the ~/.claude/skills/gstack global install. argv[0] + // is the literal string "bun" in compiled binaries; execPath is real. + path.resolve(path.dirname(process.execPath), "../../lib/diagram-render/dist/diagram-render.html"), + path.join(os.homedir(), ".claude/skills/gstack/lib/diagram-render/dist/diagram-render.html"), + ].filter((p): p is string => !!p); + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + throw new Error( + "diagram-render bundle not found. Tried:\n" + + candidates.map((c) => ` - ${c}`).join("\n") + + "\nRun `bun run build:diagram-render` (repo) or re-run ./setup (install).", + ); +} + +// ─── Fence rendering ────────────────────────────────────────────────── + +/** + * Render every extracted fence to its slot HTML. One bundle tab serves all + * fences; a failed fence yields a diagnostic block and a bundle reload + * (reset contract) before the next fence renders. + */ +export function renderFenceSlots( + fences: DiagramFence[], + tab: RenderTab, + warn: (msg: string) => void, +): Map { + const slots = new Map(); + for (const fence of fences) { + try { + let svg: string; + if (fence.lang === "mermaid") { + svg = tab.call("__renderMermaid", `mermaid-fence-${fence.ordinal}`, fence.source); + } else { + JSON.parse(fence.source); // fail fast with a JSON diagnostic, not a bundle stack + svg = tab.call("__excalidrawToSvg", fence.source); + } + slots.set(fence.token, buildDiagramFigure(fence, svg)); + } catch (err: any) { + const msg = err?.message ?? String(err); + warn(`diagram ${fence.ordinal} (${fence.lang}) failed to render: ${firstLine(msg)}`); + slots.set(fence.token, buildDiagnosticBlock(fence, msg)); + // Reset contract: a poisoned page must not corrupt the next fence. + try { + tab.loadBundle(); + } catch (reloadErr: any) { + warn(`bundle reload after render error failed: ${firstLine(reloadErr?.message ?? String(reloadErr))}`); + } + } + } + return slots; +} + +// ─── DOCX rasterization (eng-review D6.5, P8) ───────────────────────── + +/** + * Replace inline diagram SVGs (and svg data-URI images) with PNG tags + * for the DOCX export — Word's SVG support is unreliable, so the content- + * fidelity contract embeds rasters at 300dpi of the placed width (the + * content box). Diagnostic blocks keep their text form. + */ +export function rasterizeDiagramFigures( + html: string, + tab: RenderTab, + contentWidthIn: number, + warn: (msg: string) => void, +): string { + const targetPx = Math.round(contentWidthIn * PRINT_DPI); + + // 1. Rendered diagram figures → with the figure's aria-label as alt. + let out = html.replace( + /
]*>[\s\S]*?<\/figure>/gi, + (figure) => { + const svgMatch = figure.match(//i); + if (!svgMatch) return figure; + const label = figure.match(/\baria-label\s*=\s*"([^"]*)"/i)?.[1] ?? "diagram"; + try { + const png = tab.call("__rasterize", svgMatch[0], targetPx); + return `

${label}

`; + } catch (err: any) { + const reason = firstLine(err?.message ?? String(err)); + warn(`docx: diagram rasterization failed (${reason}); embedding source text instead`); + // The converter drops
/ entirely, so returning the figure + // would make the diagram vanish without a trace — the exact invisible + // failure the diagnostic contract forbids. Surface the source. + const source = decodeFigureSource(figure) ?? "(source unavailable)"; + return [ + `

Diagram could not be rasterized for DOCX (${escapeHtml(reason)}) — source:

`, + `
${escapeHtml(source)}
`, + ].join("\n"); + } + }, + ); + + // 2. SVG data-URI images (inlined .svg files) → PNG. + out = out.replace(/]*>/gi, (tag) => { + const m = tag.match(SRC_RE); + const src = m?.[2] ?? m?.[3] ?? ""; + if (!src.startsWith("data:image/svg+xml")) return tag; + try { + const b64 = src.slice(src.indexOf(",") + 1); + const svgText = Buffer.from(b64, "base64").toString("utf8"); + const png = tab.call("__rasterize", svgText, targetPx); + // Function replacement: data URIs can contain $-patterns. + return tag.replace(SRC_RE, () => `src="${png}"`); + } catch (err: any) { + warn(`docx: svg image rasterization failed (${firstLine(err?.message ?? String(err))})`); + return tag; + } + }); + + return out; +} + +/** + * Diagnostic figures → plain

/

 for the DOCX converter, which drops
+ * 
elements it can't map. An invisible error is the one thing the + * diagnostic contract forbids. Pure — no render tab needed. + */ +export function convertDiagnosticsForDocx(html: string): string { + return html.replace( + /
]*>([\s\S]*?)<\/figure>/gi, + (_full, body: string) => { + const title = body.match(/]*>([\s\S]*?)<\/figcaption>/i)?.[1] ?? "Diagram failed to render"; + const detail = body.match(/]*>([\s\S]*?)<\/pre>/i)?.[1] ?? ""; + return `

${title}

\n
${detail}
`; + }, + ); +} + +// ─── Image inlining (eng-review D1 + D4 + D6.1) ─────────────────────── + +const IMG_TAG_RE = /]*>/gi; +const SRC_RE = /\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i; + +/** + * Inline every local as a data URI, probe intrinsic dimensions from the + * bytes, and annotate the tag with data-gstack-px-width/-height for the width + * policy. Oversized rasters are downscaled to print resolution via the bundle + * tab. Missing files become visible placeholders (or throw under --strict); + * remote URLs warn (offline posture) unless --allow-network. + */ +export function inlineLocalImages(html: string, opts: PrepassImageOptions): string { + const maxPx = Math.round(opts.contentWidthIn * PRINT_DPI * DOWNSCALE_FACTOR); + const targetPx = Math.round(opts.contentWidthIn * PRINT_DPI); + // An image referenced N times is read/probed/downscaled once; the same data + // URI string is reused (also dedupes memory until the final join). + const memo = new Map(); + + return html.replace(IMG_TAG_RE, (tag) => { + const srcMatch = tag.match(SRC_RE); + if (!srcMatch) return tag; + const src = srcMatch[2] ?? srcMatch[3] ?? ""; + + if (src.startsWith("data:")) return annotateFromDataUri(tag, src); + + // Windows drive-letter paths (C:/x.png, C:\x.png) look like single-letter + // URL schemes — they are local paths, not URLs. + const isDrivePath = /^[a-zA-Z]:[\\/]/.test(src); + + if (!isDrivePath && /^[a-z][a-z0-9+.-]*:/i.test(src)) { + // Absolute URL with a scheme (http, https, file, …) + if (opts.allowNetwork && /^https?:/i.test(src)) return tag; + if (/^https?:/i.test(src)) { + const msg = `remote image blocked (offline posture): ${src}`; + if (opts.strict) throw new StrictModeError(msg + " — re-run without --strict or pass --allow-network"); + opts.warn(msg); + // Leaving the tag would make Chromium fetch it at print time anyway — + // the warn would be a lie. Replace with a visible placeholder. + return buildBlockedRemotePlaceholder(src); + } + // file:// and friends fall through to the local path branch + if (!src.startsWith("file:")) return tag; + } + + // decodeURIComponent throws on malformed escapes (foo%zz.png) — a broken + // URL must degrade to the missing-image path, not crash the run. + let decodedSrc = src; + try { + decodedSrc = decodeURIComponent(src); + } catch { /* keep raw src */ } + + const filePath = src.startsWith("file:") + ? fileURLToPath(src) + : isDrivePath + ? path.resolve(src) + : path.resolve(opts.inputDir, decodedSrc); + + const cached = memo.get(filePath); + if (cached !== undefined) return rewriteImgTag(tag, cached); + + if (!fs.existsSync(filePath)) { + const msg = `image not found: ${src} (resolved to ${filePath})`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + + // Out-of-tree reads are legal (local CLI semantics — like pandoc) but + // never silent: an agent PDF-ing untrusted markdown should not quietly + // embed ~/.ssh/config into a shareable document. --strict makes it fatal. + // Compare REAL paths — a symlink inside the input dir pointing outside + // would otherwise pass a string-prefix check (Codex adversarial finding). + // Runs after the existence check: realpath of a missing file can't + // resolve, and on macOS /var vs /private/var would false-positive. + const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep; + const realFilePath = safeRealpath(filePath); + if (!realFilePath.startsWith(inputRoot)) { + const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`; + if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict"); + opts.warn(msg); + } + + // Bound the read BEFORE reading: a markdown image pointing at a special + // file (fifo, device) would hang readFileSync, and a multi-GB file would + // exhaust memory before any policy ran. + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + opts.warn(`image unreadable: ${src}`); + return buildMissingImagePlaceholder(src); + } + if (!stat.isFile()) { + const msg = `image is not a regular file: ${src}`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + if (stat.size > MAX_IMAGE_BYTES) { + const msg = `image exceeds ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB cap: ${src} (${Math.round(stat.size / 1024 / 1024)}MB)`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + + let buf = fs.readFileSync(filePath); + let dims = imageDims(buf); + let mime = dims?.mime ?? mimeFromExtension(filePath); + + // Print-resolution normalization (D4): rasters only — SVG scales free. + if (dims && mime !== "image/svg+xml" && dims.width > maxPx) { + const tab = opts.getTab(); + if (tab) { + try { + const dataUri = `data:${mime};base64,${buf.toString("base64")}`; + const scaled = tab.call("__downscaleRaster", dataUri, targetPx, mime); + const scaledB64 = scaled.replace(/^data:[^,]*,/, ""); + opts.warn( + `downscaled ${path.basename(filePath)} ${dims.width}px → ${targetPx}px ` + + `(print is ${PRINT_DPI}dpi; original exceeds ${maxPx}px content-box ceiling)`, + ); + buf = Buffer.from(scaledB64, "base64"); + mime = scaled.slice(5, scaled.indexOf(";")); + dims = { ...dims, height: Math.round((dims.height * targetPx) / dims.width), width: targetPx }; + } catch (err: any) { + opts.warn(`downscale failed for ${src}, inlining at full size: ${firstLine(err?.message ?? String(err))}`); + } + } + } + + const dataUri = `data:${mime};base64,${buf.toString("base64")}`; + const attrs = dims + ? ` data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"` + : ""; + memo.set(filePath, { dataUri, attrs }); + return rewriteImgTag(tag, memo.get(filePath)!); + }); +} + +/** Apply a memoized inline result to an img tag. */ +function rewriteImgTag(tag: string, entry: { dataUri: string; attrs: string }): string { + // Function replacement: data URIs are user-content-derived; string-form + // replace() would expand $-patterns inside them. + let out = tag.replace(SRC_RE, () => `src="${entry.dataUri}"`); + if (entry.attrs) out = out.replace(/^ `` + + `[missing image: ${escapeHtml(src)}]` + ); +} + +function buildBlockedRemotePlaceholder(src: string): string { + return ( + `` + + `[remote image blocked (use --allow-network): ${escapeHtml(src)}]` + ); +} + +/** realpath that degrades to the input path when resolution fails. */ +function safeRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +function mimeFromExtension(p: string): string { + switch (path.extname(p).toLowerCase()) { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".gif": return "image/gif"; + case ".webp": return "image/webp"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } +} + +// ─── Content-box math ───────────────────────────────────────────────── + +const PAGE_WIDTHS_IN: Record = { + letter: 8.5, + a4: 8.27, + legal: 8.5, + tabloid: 11, +}; + +/** Parse a CSS dimension ("1in" | "72pt" | "25mm" | "2.54cm") to inches. */ +export function dimToInches(dim: string | undefined, fallbackIn: number): number { + if (!dim) return fallbackIn; + const m = dim.trim().match(/^([0-9.]+)\s*(in|pt|cm|mm|px)?$/i); + if (!m) return fallbackIn; + const v = parseFloat(m[1]); + switch ((m[2] ?? "in").toLowerCase()) { + case "in": return v; + case "pt": return v / 72; + case "cm": return v / 2.54; + case "mm": return v / 25.4; + case "px": return v / 96; + default: return fallbackIn; + } +} + +export function contentWidthInches(opts: { + pageSize?: string; + margins?: string; + marginLeft?: string; + marginRight?: string; +}): number { + const pageW = PAGE_WIDTHS_IN[opts.pageSize ?? "letter"] ?? 8.5; + const left = dimToInches(opts.marginLeft ?? opts.margins, 1); + const right = dimToInches(opts.marginRight ?? opts.margins, 1); + return Math.max(1, pageW - left - right); +} + +const PAGE_HEIGHTS_IN: Record = { + letter: 11, + a4: 11.69, + legal: 14, + tabloid: 17, +}; + +/** + * Content box of the rotated (landscape) named page: portrait page HEIGHT + * becomes the landscape width; portrait WIDTH becomes the landscape height. + * Used by image-policy to vertically center promoted blocks. + */ +export function landscapeContentBox(opts: { + pageSize?: string; + margins?: string; + marginLeft?: string; + marginRight?: string; + marginTop?: string; + marginBottom?: string; +}): { contentWIn: number; contentHIn: number } { + const size = opts.pageSize ?? "letter"; + const pageH = PAGE_HEIGHTS_IN[size] ?? 11; + const pageW = PAGE_WIDTHS_IN[size] ?? 8.5; + const left = dimToInches(opts.marginLeft ?? opts.margins, 1); + const right = dimToInches(opts.marginRight ?? opts.margins, 1); + const top = dimToInches(opts.marginTop ?? opts.margins, 1); + const bottom = dimToInches(opts.marginBottom ?? opts.margins, 1); + return { + contentWIn: Math.max(1, pageH - left - right), + contentHIn: Math.max(1, pageW - top - bottom), + }; +} + +// ─── tiny helpers ───────────────────────────────────────────────────── +// escapeHtml is imported from ./render — single definition, no drift. + +function firstLine(s: string): string { + return s.split("\n")[0].slice(0, 200); +} diff --git a/make-pdf/src/image-policy.ts b/make-pdf/src/image-policy.ts new file mode 100644 index 0000000000..2700ae011a --- /dev/null +++ b/make-pdf/src/image-policy.ts @@ -0,0 +1,236 @@ +/** + * Image width policy + conservative auto-landscape (eng-review P4, D4 spec). + * + * Two pure passes over rendered HTML: + * + * 1. applyImageDirectives — runs inside render() right after marked, before + * the sanitizer. Translates the markdown-adjacent directive suffix + * `![alt](x.png){width=50%}` / `{page=landscape}` into data-gstack-* + * attributes (the sanitizer keeps data- attributes; the brace text is + * consumed so it never reaches smartypants or the page). + * + * 2. applyImagePolicy — runs in the orchestrator after image inlining (which + * annotates data-gstack-px-width/-height from real bytes). Applies the + * width rule and decides landscape promotion: + * + * WIDTH RULE: render at intrinsic CSS-px width, capped at the content box, + * never upscaled — that is exactly `figure img { max-width: 100% }` doing + * its job, so the default needs no inline style. Directives opt into more: + * width=full stretches to the content box; / set explicit width. + * + * LANDSCAPE (conservative, false negatives are cheap): + * promote only when ALL hold — + * aspect ratio ≥ 1.8 + * AND intrinsic CSS-px width > SHRINK_LIMIT × content box + * (content shrunk below ~40% of natural size = unreadable) + * AND diagram provenance (rendered fence) or an alt-text token from + * ALT_HINT_TOKENS (plain images) + * `{page=landscape}` forces, `{page=portrait}` vetoes — both skip the + * heuristics entirely. + * + * Promotion wraps the block in
whose CSS named + * page (`@page wide { size: landscape }`, print-css.ts) rotates + * just that page. Chromium only honors CSS page sizes when the print call + * passes preferCSSPageSize — the orchestrator sets it when hasLandscape. + */ + +import { svgTagDims } from "./image-size"; + +export interface ImagePolicyOptions { + /** Physical content-box width in inches (page width minus margins). */ + contentWidthIn: number; + /** + * Landscape named-page content box (inches). Used to vertically center a + * promoted block via a computed inline margin-top — CSS flex/min-height + * centering fragments into phantom landscape pages in Chromium, so the + * margin is computed here from the block's known aspect ratio instead. + */ + landscape: { contentWIn: number; contentHIn: number }; + warn: (msg: string) => void; +} + +export interface ImagePolicyResult { + html: string; + /** True when at least one block was promoted to the landscape named page. */ + hasLandscape: boolean; +} + +/** Aspect ratio floor for auto-promotion. */ +const MIN_ASPECT = 1.8; +/** + * Auto-promote only when the intrinsic CSS-px width exceeds this multiple of + * the content box (in CSS px @96dpi). 2.5 ≈ the plan's ~1600px threshold on a + * 6.5in letter box; calibrated against fixtures (design doc Open Question 4). + */ +const SHRINK_LIMIT = 2.5; +/** Alt-text tokens that mark a plain image as diagram-like (case-insensitive). */ +const ALT_HINT_TOKENS = ["diagram", "architecture", "flowchart", "chart", "graph"]; + +// ─── Pass 1: directive suffixes ─────────────────────────────────────── + +const IMG_WITH_SUFFIX_RE = /(]*>)\s*\{([^{}<>\n]{1,120})\}/gi; + +/** + * Consume `{...}` directive suffixes adjacent to tags. Unrecognized + * brace groups are left untouched (someone's literal prose). + */ +export function applyImageDirectives(html: string): string { + return html.replace(IMG_WITH_SUFFIX_RE, (full, imgTag: string, body: string) => { + const parsed = parseDirectives(body); + if (!parsed) return full; + let tag = imgTag; + if (parsed.width) tag = addAttr(tag, "data-gstack-width", parsed.width); + if (parsed.page) tag = addAttr(tag, "data-gstack-page", parsed.page); + return tag; + }); +} + +export function parseDirectives(body: string): { width?: string; page?: string } | null { + let width: string | undefined; + let page: string | undefined; + let recognized = false; + for (const part of body.trim().split(/\s+/)) { + const m = part.match(/^(width|page)=(.+)$/i); + if (!m) return null; // any unknown token ⇒ not a directive group + const key = m[1].toLowerCase(); + const value = m[2].toLowerCase(); + if (key === "width" && /^(full|\d{1,3}%|[0-9.]+(in|cm|mm|pt|px))$/.test(value)) { + width = value; + recognized = true; + } else if (key === "page" && /^(landscape|portrait)$/.test(value)) { + page = value; + recognized = true; + } else { + return null; // recognized key, malformed value ⇒ leave visible, not silent + } + } + return recognized ? { width, page } : null; +} + +function addAttr(imgTag: string, name: string, value: string): string { + return imgTag.replace(/^]*>/gi, (tag) => { + const width = attrValue(tag, "data-gstack-width"); + if (!width) return tag; + const css = width === "full" ? "100%" : width; + return mergeStyle(tag, `width: ${css}; height: auto;`); + }); + + // 2b. landscape promotion — standalone images (markdown images render as + //

; promote by swapping the paragraph for the wide wrapper). + out = out.replace(/

\s*(]*>)\s*<\/p>/gi, (full, tag: string) => { + const decision = decideImagePromotion(tag, widthThresholdPx); + if (!decision.promote) return full; + hasLandscape = true; + opts.warn(`promoting image to a landscape page (${decision.reason})`); + const w = num(attrValue(tag, "data-gstack-px-width")); + const h = num(attrValue(tag, "data-gstack-px-height")); + return wrapPageWide(tag, w && h ? h / w : null, opts.landscape); + }); + + // 2c. landscape promotion — rendered diagram figures (provenance is + // automatic; dims come from the SVG's width/height or viewBox). + out = out.replace( + /

]*>[\s\S]*?<\/figure>/gi, + (figure) => { + if (figure.includes("diagram-error")) return figure; + const decision = decideDiagramPromotion(figure, widthThresholdPx); + if (!decision.promote) return figure; + hasLandscape = true; + opts.warn(`promoting diagram to a landscape page (${decision.reason})`); + const dims = svgCssDims(figure); + return wrapPageWide(figure, dims ? dims.height / dims.width : null, opts.landscape); + }, + ); + + return { html: out, hasLandscape }; +} + +/** + * Wrap a promoted block in the wide-page div, vertically centered via a + * computed margin-top: placed height = landscape content width × aspect, + * centered in the landscape content height. Unknown aspect → no margin + * (top placement beats a wrong guess). + */ +function wrapPageWide( + inner: string, + aspectHoverW: number | null, + landscape: { contentWIn: number; contentHIn: number }, +): string { + if (!aspectHoverW) return `
${inner}
`; + const placedHIn = landscape.contentWIn * aspectHoverW; + const marginIn = Math.max(0, (landscape.contentHIn - placedHIn) / 2); + if (marginIn < 0.1) return `
${inner}
`; + return `
${inner}
`; +} + +interface PromotionDecision { + promote: boolean; + reason: string; +} + +function decideImagePromotion(tag: string, widthThresholdPx: number): PromotionDecision { + const page = attrValue(tag, "data-gstack-page"); + if (page === "portrait") return { promote: false, reason: "page=portrait veto" }; + if (page === "landscape") return { promote: true, reason: "page=landscape directive" }; + + const w = num(attrValue(tag, "data-gstack-px-width")); + const h = num(attrValue(tag, "data-gstack-px-height")); + if (!w || !h) return { promote: false, reason: "no intrinsic dimensions" }; + if (w / h < MIN_ASPECT) return { promote: false, reason: "aspect below floor" }; + if (w <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" }; + + const alt = (attrValue(tag, "alt") ?? "").toLowerCase(); + const hinted = ALT_HINT_TOKENS.some((t) => new RegExp(`\\b${t}\\b`).test(alt)); + if (!hinted) return { promote: false, reason: "no diagram hint in alt text" }; + + return { promote: true, reason: `wide diagram-like image (${Math.round(w)}px, alt hint)` }; +} + +function decideDiagramPromotion(figure: string, widthThresholdPx: number): PromotionDecision { + const page = attrValue(figure, "data-gstack-page"); + if (page === "portrait") return { promote: false, reason: "page=portrait veto" }; + if (page === "landscape") return { promote: true, reason: "page=landscape fence directive" }; + + const dims = svgCssDims(figure); + if (!dims) return { promote: false, reason: "no measurable SVG dimensions" }; + if (dims.width / dims.height < MIN_ASPECT) return { promote: false, reason: "aspect below floor" }; + if (dims.width <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" }; + return { promote: true, reason: `wide diagram (${Math.round(dims.width)}px)` }; +} + +/** SVG dimension probing is shared with the byte prober — see image-size.ts. */ +const svgCssDims = svgTagDims; + +function attrValue(tag: string, name: string): string | null { + const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i")) + ?? tag.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, "i")); + return m ? m[1] : null; +} + +function num(s: string | null): number | null { + if (s === null) return null; + const n = parseFloat(s); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function mergeStyle(tag: string, css: string): string { + const existing = attrValue(tag, "style"); + if (existing !== null) { + // Function replacement (no $-pattern expansion from user-controlled style + // values) and the existing declarations are preserved verbatim — attrValue + // already returned the unquoted inner value. + return tag.replace(/\bstyle\s*=\s*(".*?"|'.*?')/i, () => `style="${existing}; ${css}"`); + } + return tag.replace(/^ `= 0xd0 && marker <= 0xd9)) { i += 2; continue; } + const len = b.readUInt16BE(i + 2); + if (len < 2) return null; + // SOF0-SOF15 except DHT(C4)/JPGA(C8)/DAC(CC) carry dimensions + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if (i + 9 >= b.length) return null; + return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7), mime: "image/jpeg" }; + } + i += 2 + len; + } + return null; +} + +function gifDims(b: Buffer): ImageDims | null { + const sig = b.toString("ascii", 0, 6); + if (sig !== "GIF87a" && sig !== "GIF89a") return null; + return { width: b.readUInt16LE(6), height: b.readUInt16LE(8), mime: "image/gif" }; +} + +function webpDims(b: Buffer): ImageDims | null { + if (b.toString("ascii", 0, 4) !== "RIFF" || b.toString("ascii", 8, 12) !== "WEBP") return null; + const fmt = b.toString("ascii", 12, 16); + if (fmt === "VP8X" && b.length >= 30) { + // 24-bit little-endian width-1 / height-1 at offsets 24 / 27 + const w = 1 + (b[24] | (b[25] << 8) | (b[26] << 16)); + const h = 1 + (b[27] | (b[28] << 8) | (b[29] << 16)); + return { width: w, height: h, mime: "image/webp" }; + } + if (fmt === "VP8 " && b.length >= 30) { + // Lossy: dimensions at offset 26, 14 bits each, little-endian + return { + width: b.readUInt16LE(26) & 0x3fff, + height: b.readUInt16LE(28) & 0x3fff, + mime: "image/webp", + }; + } + if (fmt === "VP8L" && b.length >= 25) { + if (b[20] !== 0x2f) return null; + const bits = b.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + mime: "image/webp", + }; + } + return null; +} + +/** + * SVG: parse width/height attributes (px or unitless) off the root element, + * falling back to viewBox. CSS-unit widths (em, %, pt) are ignored — the + * width policy treats them as "no intrinsic size". + */ +function svgDims(b: Buffer): ImageDims | null { + const head = b.toString("utf8", 0, Math.min(b.length, 4096)); + const dims = svgTagDims(head); + return dims ? { ...dims, mime: "image/svg+xml" } : null; +} + +/** + * CSS-px dimensions of the first element in a markup string: explicit + * width/height attributes (px or unitless) first, else viewBox. Shared by the + * byte prober above and image-policy's diagram-figure measurements — one + * regex, no drift. + */ +export function svgTagDims(markup: string): { width: number; height: number } | null { + const tag = markup.match(/]*>/i)?.[0]; + if (!tag) return null; + const attr = (name: string): number | null => { + const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']\\s*([0-9.]+)(px)?\\s*["']`, "i")); + return m ? parseFloat(m[1]) : null; + }; + const w = attr("width"); + const h = attr("height"); + if (w && h) return { width: w, height: h }; + const vb = tag.match(/\bviewBox\s*=\s*["']\s*[-0-9.]+[\s,]+[-0-9.]+[\s,]+([0-9.]+)[\s,]+([0-9.]+)\s*["']/i); + if (vb) return { width: parseFloat(vb[1]), height: parseFloat(vb[2]) }; + return null; +} diff --git a/make-pdf/src/orchestrator.ts b/make-pdf/src/orchestrator.ts new file mode 100644 index 0000000000..12a21570d0 --- /dev/null +++ b/make-pdf/src/orchestrator.ts @@ -0,0 +1,399 @@ +/** + * 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 , 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 { screenCss } from "./print-css"; +import type { GenerateOptions, PreviewOptions } from "./types"; +import { ExitCode } from "./types"; +import * as browseClient from "./browseClient"; +import { + RenderTab, + contentWidthInches, + convertDiagnosticsForDocx, + extractDiagramFences, + inlineLocalImages, + landscapeContentBox, + rasterizeDiagramFigures, + renderFenceSlots, + substituteSlots, +} from "./diagram-prepass"; +import { applyImagePolicy } from "./image-policy"; + +class ProgressReporter { + private readonly quiet: boolean; + private readonly verbose: boolean; + private readonly stageStart = new Map(); + 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 { + const progress = new ProgressReporter(opts); + const input = path.resolve(opts.input); + + if (!fs.existsSync(input)) { + throw new Error(`input file not found: ${input}`); + } + + const to = opts.to ?? "pdf"; + const outputPath = path.resolve( + opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.${to}`), + ); + + // Stage 1: read markdown + progress.begin("Reading markdown"); + const markdown = fs.readFileSync(input, "utf8"); + progress.end("Reading markdown"); + + // Stage 1.5: diagram pre-pass — extract ```mermaid/```excalidraw fences and + // swap in placeholder tokens. Rendering happens after the tab opens below. + const extraction = extractDiagramFences(markdown); + + // Stage 2: render HTML + progress.begin("Rendering HTML"); + const rendered = render({ + markdown: extraction.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, + marginTop: opts.marginTop, + marginRight: opts.marginRight, + marginBottom: opts.marginBottom, + marginLeft: opts.marginLeft, + pageNumbers: opts.pageNumbers, + footerTemplate: opts.footerTemplate, + }); + progress.end("Rendering HTML", `${rendered.meta.wordCount} words`); + + // Stage 2.5: render diagram fences in a dedicated bundle tab, substitute + // slots, then inline + probe + (if oversized) downscale local images. + // The bundle tab is lazy: image-only documents open it only when a raster + // actually needs print-resolution downscaling (eng-review D4). + const warn = (msg: string) => { + if (!opts.quiet) process.stderr.write(`\r\x1b[K[make-pdf] warning: ${msg}\n`); + }; + let renderTab: RenderTab | null = null; + let hasLandscape = false; + const getRenderTab = (): RenderTab | null => { + if (renderTab) return renderTab; + try { + renderTab = RenderTab.open(); + } catch (err: any) { + warn(`diagram-render tab unavailable: ${String(err?.message ?? err).split("\n")[0]}`); + return null; + } + return renderTab; + }; + + let finalHtml = rendered.html; + try { + if (extraction.fences.length > 0) { + progress.begin(`Rendering ${extraction.fences.length} diagram(s)`); + const tab = getRenderTab(); + if (tab) { + const slots = renderFenceSlots(extraction.fences, tab, warn); + finalHtml = substituteSlots(finalHtml, slots); + } else { + // No bundle/tab: visible diagnostic beats silent raw tokens. + const slots = new Map( + extraction.fences.map((f) => [ + f.token, + ``, + ]), + ); + finalHtml = substituteSlots(finalHtml, slots); + } + progress.end(`Rendering ${extraction.fences.length} diagram(s)`); + } + + progress.begin("Inlining images"); + const contentWidthIn = contentWidthInches(opts); + finalHtml = inlineLocalImages(finalHtml, { + inputDir: path.dirname(input), + strict: opts.strict === true, + allowNetwork: opts.allowNetwork === true, + contentWidthIn, + warn, + getTab: getRenderTab, + }); + progress.end("Inlining images"); + + // Width directives + conservative auto-landscape (image-policy). + const policy = applyImagePolicy(finalHtml, { + contentWidthIn, + landscape: landscapeContentBox(opts), + warn, + }); + finalHtml = policy.html; + hasLandscape = policy.hasLandscape; + + // DOCX needs rasters, not inline SVG (Word's SVG support is unreliable) — + // do it while the render tab is still open. + if (to === "docx") { + const needsRaster = /
", + `\n`, + ); + fs.writeFileSync(outputPath, withScreenLayer, "utf8"); + const kb = Math.round(fs.statSync(outputPath).size / 1024); + progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`); + return outputPath; + } + + // ─── --to docx: content-fidelity conversion (eng-review P8) ──────────── + if (to === "docx") { + // Print-only surfaces don't survive the conversion. The watermark div + // would degrade to a literal body paragraph reading "DRAFT" (worse than + // absent) — strip it. Warn once about print-only flags that were set. + finalHtml = finalHtml.replace(/
[\s\S]*?<\/div>/, ""); + const printOnly: string[] = []; + if (opts.watermark) printOnly.push("--watermark"); + if (opts.headerTemplate) printOnly.push("--header-template"); + if (opts.footerTemplate) printOnly.push("--footer-template"); + if (opts.pageSize) printOnly.push("--page-size"); + if (opts.margins || opts.marginTop || opts.marginRight || opts.marginBottom || opts.marginLeft) printOnly.push("--margins"); + if (printOnly.length > 0) { + warn(`docx is content-fidelity: ${printOnly.join(", ")} do not apply to Word output`); + } + progress.begin("Converting to DOCX"); + const { default: HTMLtoDOCX } = await import("html-to-docx"); + const buf = await HTMLtoDOCX(finalHtml, null, { + title: rendered.meta.title, + creator: rendered.meta.author || undefined, + }); + const bytes: Uint8Array = buf instanceof Uint8Array ? buf : new Uint8Array(await (buf as Blob).arrayBuffer()); + fs.writeFileSync(outputPath, bytes); + progress.end("Converting to DOCX"); + const kb = Math.round(fs.statSync(outputPath).size / 1024); + progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath} (content fidelity — layout is Word's)`); + return outputPath; + } + + // 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, finalHtml, "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: finalHtml, + 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, + // CSS is the single source of truth for page numbers (see print-css.ts + // @bottom-center). Chromium's native numbering always off to avoid double + // footers. The CSS layer honors pageNumbers + footerTemplate via render(). + pageNumbers: false, + tagged: opts.tagged !== false, + outline: opts.outline !== false, + printBackground: !!opts.watermark, + // Named landscape pages only take effect when Chromium honors CSS page + // sizes. Flip it ONLY when a promotion exists — minimal behavior change + // for every other document. + preferCSSPageSize: hasLandscape ? true : undefined, + 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 { + 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"); + // Preview deliberately skips the diagram/image pre-pass (no browse daemon + // round-trip — preview is the fast loop). Be loud about the divergence so + // nobody signs off on a preview that lacks what the PDF will have. + if (!opts.quiet) { + const fenceCount = extractDiagramFences(markdown).fences.length; + const hasLocalImages = /!\[[^\]]*\]\((?!https?:|data:)[^)]+\)/.test(markdown); + if (fenceCount > 0 || hasLocalImages) { + process.stderr.write( + `[make-pdf] preview note: ${fenceCount > 0 ? `${fenceCount} diagram fence(s) shown as code` : ""}` + + `${fenceCount > 0 && hasLocalImages ? "; " : ""}` + + `${hasLocalImages ? "local images may not resolve from the preview location" : ""}` + + ` — \`generate\` renders them fully.\n`, + ); + } + } + 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, + pageNumbers: opts.pageNumbers, + }); + 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 }; diff --git a/make-pdf/src/pdftotext.ts b/make-pdf/src/pdftotext.ts new file mode 100644 index 0000000000..5cdb51e81c --- /dev/null +++ b/make-pdf/src/pdftotext.ts @@ -0,0 +1,312 @@ +/** + * 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 (v1.24-aligned): + * 1. $GSTACK_PDFTOTEXT_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern) + * 2. $PDFTOTEXT_BIN env override (back-compat alias) + * 3. PATH lookup via Bun.which('pdftotext') — handles Windows PATHEXT natively + * 4. standard POSIX paths (Homebrew + distro) — no Windows candidates because + * Poppler scatters across Scoop / Chocolatey / oschwartz10612-poppler-windows + * and guessing causes false positives. Set GSTACK_PDFTOTEXT_BIN explicitly. + * 5. 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"; +} + +/** + * Probe a base path for executability, honoring Windows extension suffixes. + * Matches browseClient.ts:findExecutable — duplicated rather than shared + * because the two modules already duplicate isExecutable for compile-isolation. + */ +export function findExecutable(base: string): string | null { + if (isExecutable(base)) return base; + if (process.platform === "win32") { + for (const ext of [".exe", ".cmd", ".bat"]) { + const withExt = base + ext; + if (isExecutable(withExt)) return withExt; + } + } + return null; +} + +function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null { + if (!value?.trim()) return null; + const trimmed = value.trim().replace(/^"(.*)"$/, '$1'); + if (path.isAbsolute(trimmed)) return findExecutable(trimmed); + const PATH = env.PATH ?? env.Path ?? ''; + return Bun.which(trimmed, { PATH }) ?? null; +} + +/** + * Locate pdftotext. Throws PdftotextUnavailableError if none is found. + */ +export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): PdftotextInfo { + // 1 + 2: env overrides (GSTACK_PDFTOTEXT_BIN preferred, PDFTOTEXT_BIN back-compat). + const overrideRaw = env.GSTACK_PDFTOTEXT_BIN ?? env.PDFTOTEXT_BIN; + const override = resolveOverride(overrideRaw, env); + if (override) return describeBinary(override); + + // 3: PATH lookup via Bun.which — handles Windows PATHEXT natively. + const PATH = env.PATH ?? env.Path ?? ''; + const onPath = Bun.which('pdftotext', { PATH }); + if (onPath) return describeBinary(onPath); + + // 4: POSIX-only standard locations. No Windows candidates — Poppler installs + // scatter across Scoop/Chocolatey/portable zips and guessing causes false + // positives. Windows users set GSTACK_PDFTOTEXT_BIN explicitly. + const posixCandidates = [ + "/opt/homebrew/bin/pdftotext", // Apple Silicon Homebrew + "/usr/local/bin/pdftotext", // Intel Mac or Linuxbrew + "/usr/bin/pdftotext", // distro package + ]; + for (const candidate of posixCandidates) { + 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", + " Windows: scoop install poppler (or download from", + " https://github.com/oschwartz10612/poppler-windows)", + "", + "Or set GSTACK_PDFTOTEXT_BIN to an explicit path:", + process.platform === "win32" + ? ' setx GSTACK_PDFTOTEXT_BIN "C:\\path\\to\\pdftotext.exe"' + : " export GSTACK_PDFTOTEXT_BIN=/path/to/pdftotext", + ].join("\n")); +} + +/** + * Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the + * emoji render gate. Mirrors resolvePdftotext's resolution order: + * 1. $GSTACK__BIN env override (e.g. GSTACK_PDFFONTS_BIN) + * 2. PATH via Bun.which + * 3. standard POSIX locations (Homebrew + distro) + * + * Returns null (does NOT throw) when the tool is missing — the emoji gate skips + * cleanly rather than failing on a box without full poppler-utils. + */ +export function resolvePopplerTool( + tool: "pdffonts" | "pdfimages" | "pdftoppm", + env: NodeJS.ProcessEnv = process.env, +): string | null { + const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env); + if (override) return override; + + const PATH = env.PATH ?? env.Path ?? ""; + const onPath = Bun.which(tool, { PATH }); + if (onPath) return onPath; + + for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) { + const candidate = findExecutable(path.join(dir, tool)); + if (candidate) return candidate; + } + return null; +} + +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`); + } +} diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts new file mode 100644 index 0000000000..bf6f862bdc --- /dev/null +++ b/make-pdf/src/print-css.ts @@ -0,0 +1,452 @@ +/** + * 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 first, with Liberation Sans as a metric-compatible Linux + * fallback (Helvetica and Arial aren't installed on most Linux distros; + * Liberation Sans ships via the fonts-liberation package and Playwright's + * install-deps). 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 (v1.58.0.0 poster revision, user-directed): 56pt title, + * 13pt meta, padding-top 1.4in for poster placement. Still no flexbox + * and no vertical centering; the inset is a deliberate top-third drop. + * (Supersedes the original "no inset padding" lock from the first + * /plan-design-review — the 32pt cover read as too small in print.) + * - `@page :first` suppresses running header/footer but does NOT override + * the 1in margin. + * - No , no external CSS/fonts — everything inlined. + * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic + * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif. + * - Emoji fallback: the body and @top-center running-header stacks end in an + * emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color + * Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph + * source for emoji code points instead of emitting .notdef tofu (▯). The + * @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL" + * string, so they get no emoji families. On Linux this requires an + * installed color-emoji font — `setup` installs fonts-noto-color-emoji. + * + * Font stacks are composed from the constants below so each family list has a + * single source of truth (DRY) and every stack stays in sync. + */ + +// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux, +// ships via fonts-liberation), Arial (Windows). Shared by every text surface. +const SANS_STACK = `Helvetica, "Liberation Sans", Arial`; +// CJK fallback families, appended to the body stack only. +const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`; +// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux). +const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`; + +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; + + // Whether to render "N of M" page numbers in the @page @bottom-center rule. + // Default true. Set false to suppress CSS numbering (used when the caller + // supplies a custom Chromium footerTemplate, or when --no-page-numbers). + pageNumbers?: boolean; +} + +/** + * Produce a CSS block (no `, + ``, + ``, + watermarkBlock, + coverBlock, + tocBlock, + chapterHtml, + ``, + ``, + ].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: + * - "); + expect(fig).not.toContain("

world

`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("hello

"); + expect(out).toContain("

world

"); + }); + + test("strips `; + expect(sanitizeUntrustedHtml(input)).not.toContain(" { + const input = `click`; + 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`; + const input2 = `b`; + expect(sanitizeUntrustedHtml(input1)).not.toContain("onClick"); + expect(sanitizeUntrustedHtml(input2)).not.toContain("ONCLICK"); + }); + + test("rewrites javascript: URLs in href to #", () => { + const input = `bad`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("javascript:"); + expect(out).toContain('href="#"'); + }); + + test("strips inline SVG `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain(", , , , ,
", () => { + const input = ` + + + + + +
+ `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain(" { + const input = `
hi
`; + 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(""); + expect(result.html).toContain("Hello"); + expect(result.html).toContain("... + expect(result.html).toMatch(/ + + +
+

GStack browser readiness

+

This local page verifies navigation, reading, interaction, console, and network access.

+ +

WAITING

+
+ + +`; +} + +async function main() { + const fixture = createReadinessServer(); + const started = await fixture.start(); + process.stdout.write(`${JSON.stringify({ ...started, pid: process.pid })}\n`); + const stop = async () => { + await fixture.stop(); + process.exitCode = fixture.completed ? 0 : 2; + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`gstack browser readiness: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/skills/make-pdf/references/support/execution-result-contract.json b/skills/make-pdf/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/make-pdf/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gstack.dev/schemas/execution-result-v1.json", + "title": "GStack execution result", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "status", + "code", + "summary", + "evidence", + "data" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "code": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Z][A-Z0-9_]{2,63}$" + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "data": {} + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "success" + } + } + }, + "then": { + "properties": { + "code": { + "type": "null" + }, + "evidence": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "degraded", + "unsupported", + "failed" + ] + } + } + }, + "then": { + "properties": { + "code": { + "type": "string" + } + } + } + } + ] +} diff --git a/skills/make-pdf/references/support/runtime-bootstrap.mjs b/skills/make-pdf/references/support/runtime-bootstrap.mjs new file mode 100644 index 0000000000..2629db959f --- /dev/null +++ b/skills/make-pdf/references/support/runtime-bootstrap.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// Dependency-free bootstrap copied into each standards-installed GStack skill. +// It installs only the optional local runtime; host skill placement remains the +// responsibility of the Agent Skills installer. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { createHash } from "node:crypto"; +import { constants as fsConstants, createReadStream } from "node:fs"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; + +export const BOOTSTRAP_SCHEMA_VERSION = 2; +export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; +// Keep the runtime compatibility version separate from the immutable release +// channel. Release candidates carry the 2.0.0 runtime contract while letting +// fresh-machine production journeys run before the stable v2.0.0 tag exists. +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; +export const OFFICIAL_MANIFEST_URL = + `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; +const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); +const CAPABILITY_DEPENDENCIES = Object.freeze({ + browser: Object.freeze([]), + "browser-visible": Object.freeze([]), + design: Object.freeze([]), + pdf: Object.freeze(["browser", "diagram"]), + diagram: Object.freeze(["browser"]), + ios: Object.freeze([]), +}); +export const COMPONENT_DEPENDENCIES = Object.freeze({ + core: Object.freeze([]), + "browser-code": Object.freeze(["core"]), + "browser-headless": Object.freeze(["browser-code"]), + "browser-visible": Object.freeze(["browser-code"]), + design: Object.freeze(["core"]), + diagram: Object.freeze(["browser-headless"]), + pdf: Object.freeze(["diagram"]), + ios: Object.freeze(["core"]), +}); +export const CAPABILITY_COMPONENTS = Object.freeze({ + browser: Object.freeze(["browser-code", "browser-headless"]), + "browser-visible": Object.freeze(["browser-code", "browser-visible"]), + design: Object.freeze(["design"]), + diagram: Object.freeze(["diagram"]), + pdf: Object.freeze(["pdf"]), + ios: Object.freeze(["ios"]), +}); +const ALLOWED_DOWNLOAD_HOSTS = new Set([ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]); +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; +const OFFICIAL_CERTIFICATE_IDENTITY = + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; +const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; + +export async function main(argv = process.argv.slice(2), options = {}) { + const io = { + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + }; + try { + const parsed = parseArgs(argv); + if (parsed.help) { + io.stdout.write(usage()); + return 0; + } + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); + } + + const platform = options.platform ?? process.platform; + if (parsed.capabilities.includes("ios") && platform !== "darwin") { + throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); + } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } + if (parsed.source) { + const sourceHome = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); + const active = await inspectReusableRuntime(sourceHome, BOOTSTRAP_RUNTIME_VERSION).catch(() => null); + if (!browserChoice && active?.browserChoice) { + browserChoice = await resolveBrowserChoice(active.browserChoice, { + platform, + env: options.env, + homeDir: options.homeDir, + }); + } + parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice); + if (browserChoiceRequired(parsed.capabilities) && !browserChoice) { + throw bootstrapError( + "The active browser capability does not record a reusable browser provider; choose a browser provider before changing this runtime.", + "BOOTSTRAP_BROWSER_CHOICE_REQUIRED", + ); + } + if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + if (parsed.action === "preview") { + io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); + return 0; + } + if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); + io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); + return await installFromSource(parsed.source, parsed, { + ...options, + ...io, + prepared: false, + replaceCapabilities: true, + browserChoice, + }); + } + + const fetch_ = options.fetch ?? globalThis.fetch; + if (typeof fetch_ !== "function") throw bootstrapError("Node 18+ with fetch is required", "BOOTSTRAP_NODE_UNSUPPORTED"); + const target = platformTarget( + platform, + options.arch ?? process.arch, + options.libc ?? detectLinuxLibc(platform), + ); + const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; + assertOfficialUrl(manifestUrl, { manifest: true }); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); + validateManifest(manifest, target); + const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); + const active = await inspectReusableRuntime(home, manifest.version).catch(() => null); + const reusable = active?.releaseMatches ? active : null; + if (!browserChoice && active?.browserChoice) { + browserChoice = await resolveBrowserChoice(active.browserChoice, { + platform, + env: options.env, + homeDir: options.homeDir, + }); + } + parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice); + if (browserChoiceRequired(parsed.capabilities) && !browserChoice) { + throw bootstrapError( + "The active browser capability does not record a reusable browser provider; preview browser setup options before changing this runtime.", + "BOOTSTRAP_BROWSER_CHOICE_REQUIRED", + ); + } + if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); + else printComponentPlan(io.stdout, plan); + if (parsed.action === "preview") return 0; + if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after reviewing this exact component plan", "BOOTSTRAP_CONSENT_REQUIRED"); + const temporary = await fs.mkdtemp(path.join(options.tmpDir ?? os.tmpdir(), "gstack-bootstrap-")); + try { + const root = path.join(temporary, "merged", "gstack"); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + const claimedFiles = new Set(); + if (reusable) await seedReusableRuntime(reusable, root, claimedFiles); + for (const item of plan.downloads) { + const archive = path.join(temporary, `${item.component}.tar.gz`); + await downloadVerified(fetch_, item.artifact.url, archive, item.artifact.sha256, item.artifact.bytes); + io.stdout.write(`Verified SHA-256 for ${item.component} (${target}).\n`); + await verifyCosignWhenAvailable(archive, item.artifact, path.join(temporary, item.component), { ...options, fetch: fetch_, ...io }); + const extracted = path.join(temporary, "extracted", item.component); + await fs.mkdir(extracted, { recursive: true, mode: 0o700 }); + await extractTarSafely(archive, extracted, options); + const componentRoot = safeArtifactRoot(extracted, item.artifact.root ?? "gstack"); + await assertNoLinks(componentRoot); + await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); + } + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + } catch (error) { + io.stderr.write(`gstack bootstrap: ${error?.message ?? error}\n`); + return 1; + } +} + +function parseArgs(argv) { + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (["-h", "--help"].includes(arg)) result.help = true; + else if (arg === "--yes") result.yes = true; + else if (arg === "--json") result.json = true; + else if (!result.action && !arg.startsWith("-")) result.action = arg; + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { + const value = argv[++index]; + if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); + if (arg === "--capability") result.capabilities.push(value); + else if (arg === "--source") result.source = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; + } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); + } + if (result.help) return result; + if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); + result.capabilities = [...new Set(result.capabilities)].sort(); + for (const capability of result.capabilities) { + if (!CAPABILITIES.has(capability)) throw bootstrapError(`Unknown capability: ${capability}`, "BOOTSTRAP_USAGE"); + } + const expanded = new Set(result.capabilities); + const pending = [...expanded]; + while (pending.length) { + for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) { + if (!expanded.has(dependency)) { + expanded.add(dependency); + pending.push(dependency); + } + } + } + result.capabilities = [...expanded].sort(); + return result; +} + +function validateManifest(manifest, target) { + if (manifest?.schemaVersion !== BOOTSTRAP_SCHEMA_VERSION || manifest?.version !== BOOTSTRAP_RUNTIME_VERSION || + manifest?.skillApi !== "2.0" || typeof manifest?.targets !== "object" || + !sameGraph(manifest.capabilityComponents, CAPABILITY_COMPONENTS) || + !sameGraph(manifest.componentDependencies, COMPONENT_DEPENDENCIES)) { + throw bootstrapError("Official runtime manifest is incompatible", "BOOTSTRAP_MANIFEST_INVALID"); + } + const targetRecord = manifest.targets[target]; + const expected = Object.keys(COMPONENT_DEPENDENCIES) + .filter((component) => component !== "ios" || target.startsWith("darwin-")) + .sort(); + if (!targetRecord || typeof targetRecord.components !== "object" || + JSON.stringify(Object.keys(targetRecord.components).sort()) !== JSON.stringify(expected)) { + throw bootstrapError(`No valid official runtime artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE"); + } + for (const [component, artifact] of Object.entries(targetRecord.components)) { + if (!artifact || artifact.format !== "tar.gz" || !/^[a-f0-9]{64}$/.test(artifact.sha256) || + !Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1 || artifact.bytes > 2 * 1024 * 1024 * 1024) { + throw bootstrapError(`Invalid ${component} artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE"); + } + assertOfficialReleaseAssetUrl(artifact.url); + if (artifact.cosignBundleUrl) { + assertOfficialReleaseAssetUrl(artifact.cosignBundleUrl); + if (artifact.certificateIdentity !== OFFICIAL_CERTIFICATE_IDENTITY || + artifact.certificateOidcIssuer !== GITHUB_OIDC_ISSUER) { + throw bootstrapError("Cosign metadata does not bind the official GStack release workflow", "BOOTSTRAP_MANIFEST_INVALID"); + } + } else if (artifact.certificateIdentity || artifact.certificateOidcIssuer) { + throw bootstrapError("Cosign certificate metadata requires a bundle URL", "BOOTSTRAP_MANIFEST_INVALID"); + } + } + return targetRecord; +} + +function sameGraph(actual, expected) { + if (!actual || typeof actual !== "object" || Array.isArray(actual)) return false; + const normalize = (graph) => Object.fromEntries(Object.entries(graph) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => [key, Array.isArray(values) ? [...values].sort() : values])); + return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); +} + +function selectedComponents(capabilities, browserChoice) { + const selected = new Set(["core"]); + for (const capability of capabilities) { + for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); + } + const pending = [...selected]; + while (pending.length) { + for (const dependency of COMPONENT_DEPENDENCIES[pending.pop()] ?? []) { + if (!selected.has(dependency)) { + selected.add(dependency); + pending.push(dependency); + } + } + } + return applyBrowserProviderToComponents([...selected], browserChoice); +} + +function mergeRetainedCapabilities(requested, reusable, browserChoice) { + const selected = new Set([ + ...(Array.isArray(reusable?.selectedCapabilities) ? reusable.selectedCapabilities : []), + ...requested, + ]); + if (browserChoice?.provider === "installed") selected.delete("browser-visible"); + const pending = [...selected]; + while (pending.length) { + for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) { + if (!selected.has(dependency)) { + selected.add(dependency); + pending.push(dependency); + } + } + } + return [...selected].sort(); +} + +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); + const retained = new Set(reusable?.components ?? []); + const downloads = components + .filter((component) => !retained.has(component)) + .map((component) => ({ component, artifact: manifest.targets[target].components[component] })); + const downloadBytes = downloads.reduce((total, item) => total + item.artifact.bytes, 0); + return { + target, + version: manifest.version, + capabilities, + browser: browserChoice, + components, + reusedComponents: components.filter((component) => retained.has(component)), + downloads, + downloadBytes, + }; +} + +function printComponentPlan(stdout, plan) { + stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); + stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } + stdout.write(`Components: ${plan.components.join(", ")}\n`); + if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); + stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); +} + +async function inspectReusableRuntime(home, version) { + const versions = path.join(home, "versions"); + const pointer = JSON.parse(await fs.readFile(path.join(versions, "current.json"), "utf8")); + if (pointer?.schemaVersion !== 2 || pointer?.status !== "active" || + typeof pointer.current !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(pointer.current)) return null; + const root = path.join(versions, pointer.current); + const stat = await fs.lstat(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + const bundle = JSON.parse(await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8")); + const releaseMatches = bundle?.version === version || + (typeof bundle?.version === "string" && bundle.version.startsWith(`${version}-caps-`)); + if (bundle?.schemaVersion !== 2 || typeof bundle.version !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(bundle.version) || !Array.isArray(bundle.runtimeComponents) || + !Array.isArray(bundle.files)) return null; + const components = [...new Set(bundle.runtimeComponents)]; + if (!components.length || components.some((component) => !Object.hasOwn(COMPONENT_DEPENDENCIES, component))) return null; + const selectedCapabilities = Array.isArray(bundle.selectedCapabilities) + ? [...new Set(bundle.selectedCapabilities)] + : []; + if (selectedCapabilities.some((capability) => !CAPABILITIES.has(capability))) return null; + let browserChoice = null; + if (browserChoiceRequired(selectedCapabilities)) { + const explicit = bundle.browserChoice; + if (!explicit || !["managed", "installed"].includes(explicit.provider)) return null; + if (explicit.provider === "installed") { + if (selectedCapabilities.includes("browser-visible") || + typeof explicit.executablePath !== "string" || !path.isAbsolute(explicit.executablePath) || + components.includes("browser-headless") || components.includes("browser-visible")) return null; + browserChoice = { provider: "installed", executablePath: explicit.executablePath }; + } else { + if (!components.includes("browser-headless") && !components.includes("browser-visible")) return null; + browserChoice = { provider: "managed", executablePath: null }; + } + } + await assertNoLinks(root); + const files = []; + const seen = new Set(); + for (const entry of bundle.files) { + const relative = entry?.path; + if (typeof relative !== "string" || !relative || relative.includes("\\") || path.posix.isAbsolute(relative) || + path.posix.normalize(relative) !== relative || relative.split("/").includes("..") || seen.has(relative) || + !Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) return null; + seen.add(relative); + const file = path.join(root, ...relative.split("/")); + const fileStat = await fs.lstat(file).catch(() => null); + if (!fileStat?.isFile() || fileStat.isSymbolicLink() || fileStat.size !== entry.size || + await sha256File(file) !== entry.sha256) return null; + files.push(relative); + } + return { root, components, files, selectedCapabilities, browserChoice, releaseMatches }; +} + +async function seedReusableRuntime(reusable, destination, claimedFiles) { + for (const relative of reusable.files) { + if (claimedFiles.has(relative)) throw bootstrapError(`Runtime components overlap at ${relative}`, "BOOTSTRAP_MANIFEST_INVALID"); + claimedFiles.add(relative); + const target = path.join(destination, ...relative.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + await fs.copyFile(path.join(reusable.root, ...relative.split("/")), target, fsConstants.COPYFILE_EXCL); + } +} + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(file); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +async function fetchJson(fetch_, url, options = {}) { + const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); + assertFinalDownloadUrl(response.url || url); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } + const value = await response.json(); + if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); + return value; +} + +async function downloadVerified(fetch_, url, destination, expectedSha256, expectedBytes) { + const response = await fetch_(url, { redirect: "follow" }); + assertFinalDownloadUrl(response.url || url); + if (!response.ok) throw bootstrapError(`Artifact download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + const hash = createHash("sha256"); + const file = await fs.open(destination, "wx", 0o600); + let total = 0; + try { + if (response.body?.getReader) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > 2 * 1024 * 1024 * 1024) throw bootstrapError("Runtime artifact exceeds the 2 GiB safety limit", "BOOTSTRAP_DOWNLOAD_FAILED"); + hash.update(value); + await file.write(value); + } + } else { + const bytes = new Uint8Array(await response.arrayBuffer()); + total = bytes.byteLength; + hash.update(bytes); + await file.write(bytes); + } + } catch (error) { + await file.close(); + await fs.rm(destination, { force: true }); + throw error; + } + await file.close(); + if (total !== expectedBytes) { + await fs.rm(destination, { force: true }); + throw bootstrapError(`Runtime artifact size mismatch (expected ${expectedBytes}, received ${total})`, "BOOTSTRAP_INTEGRITY_FAILED"); + } + const actual = hash.digest("hex"); + if (actual !== expectedSha256) { + await fs.rm(destination, { force: true }); + throw bootstrapError("Runtime artifact SHA-256 mismatch", "BOOTSTRAP_INTEGRITY_FAILED"); + } +} + +async function verifyCosignWhenAvailable(archive, artifact, temporary, options) { + if (!artifact.cosignBundleUrl) { + options.stdout.write("No Cosign bundle declared; continuing with verified release-manifest SHA-256.\n"); + return; + } + const available = await run(options.cosignCommand ?? "cosign", ["version"], { capture: true }).then(() => true, () => false); + if (!available) { + options.stdout.write("Cosign metadata is available but Cosign is not installed; SHA-256 verification succeeded.\n"); + return; + } + const bundle = path.join(temporary, "cosign.bundle"); + const response = await options.fetch(artifact.cosignBundleUrl, { redirect: "follow" }); + assertFinalDownloadUrl(response.url || artifact.cosignBundleUrl); + if (!response.ok) throw bootstrapError("Cosign bundle download failed", "BOOTSTRAP_ATTESTATION_FAILED"); + await fs.writeFile(bundle, new Uint8Array(await response.arrayBuffer()), { mode: 0o600, flag: "wx" }); + const args = ["verify-blob", "--bundle", bundle]; + if (artifact.certificateIdentity) args.push("--certificate-identity", artifact.certificateIdentity); + if (artifact.certificateOidcIssuer) args.push("--certificate-oidc-issuer", artifact.certificateOidcIssuer); + args.push(archive); + await run(options.cosignCommand ?? "cosign", args); + options.stdout.write("Verified Cosign release attestation.\n"); +} + +async function extractTarSafely(archive, destination, options) { + const tar = options.tarCommand ?? "tar"; + const listing = await run(tar, ["-tzf", archive], { capture: true }); + for (const name of listing.stdout.split(/\r?\n/).filter(Boolean)) { + const normalized = name.replaceAll("\\", "/"); + if (normalized.includes("\0") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || + normalized.split("/").includes("..")) { + throw bootstrapError("Runtime archive contains an unsafe path", "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + } + const verbose = await run(tar, ["-tvzf", archive], { capture: true }); + for (const line of verbose.stdout.split(/\r?\n/).filter(Boolean)) { + if (!/^[-d]/.test(line)) { + throw bootstrapError("Runtime archive contains a link or special-file entry", "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + } + await run(tar, ["-xzf", archive, "-C", destination]); +} + +async function installFromSource(source, parsed, options) { + const physical = await fs.realpath(path.resolve(source)); + const installer = path.join(physical, "runtime", "install.js"); + const stat = await fs.lstat(installer).catch(() => null); + if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); + const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } + if (parsed.home) args.push("--home", path.resolve(parsed.home)); + if (options.version) args.push("--version", options.version); + if (options.prepared) args.push("--prepared"); + if (options.prepared || options.replaceCapabilities) args.push("--replace-capabilities"); + await run(options.nodeCommand ?? process.execPath, args); + options.stdout.write(`Installed optional capabilities: ${parsed.capabilities.join(", ")}. No coding host was enrolled.\n`); + return 0; +} + +function platformTarget(platform, arch, libc) { + if (!["darwin", "linux", "win32"].includes(platform) || !["arm64", "x64"].includes(arch)) { + throw bootstrapError(`Unsupported platform: ${platform}-${arch}`, "BOOTSTRAP_PLATFORM_UNSUPPORTED"); + } + if (platform === "linux" && libc !== "glibc") { + throw bootstrapError( + "Official GStack runtime artifacts currently require glibc Linux; pure Agent Skills remain portable and the reviewed-source fallback may be used explicitly", + "BOOTSTRAP_PLATFORM_UNSUPPORTED", + ); + } + return `${platform === "win32" ? "windows" : platform}-${arch}`; +} + +function detectLinuxLibc(platform) { + if (platform !== "linux") return null; + const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null; + return report?.header?.glibcVersionRuntime ? "glibc" : "musl"; +} + +function safeArtifactRoot(extracted, relative) { + if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) { + throw bootstrapError("Manifest contains an unsafe artifact root", "BOOTSTRAP_MANIFEST_INVALID"); + } + const target = path.resolve(extracted, relative); + if (path.relative(extracted, target).startsWith(`..${path.sep}`)) throw bootstrapError("Artifact root escaped extraction", "BOOTSTRAP_ARCHIVE_UNSAFE"); + return target; +} + +async function mergeComponentRoot(source, destination, claimedFiles, component) { + async function visit(relative = "") { + for (const entry of await fs.readdir(path.join(source, relative), { withFileTypes: true })) { + const child = relative ? `${relative}/${entry.name}` : entry.name; + const from = path.join(source, ...child.split("/")); + const to = path.join(destination, ...child.split("/")); + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { + throw bootstrapError(`Runtime component ${component} contains a link or special file`, "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + if (entry.isDirectory()) { + await fs.mkdir(to, { recursive: true, mode: 0o700 }); + await visit(child); + } else { + if (claimedFiles.has(child)) { + throw bootstrapError(`Runtime components overlap at ${child}`, "BOOTSTRAP_MANIFEST_INVALID"); + } + claimedFiles.add(child); + await fs.mkdir(path.dirname(to), { recursive: true, mode: 0o700 }); + await fs.copyFile(from, to, fsConstants.COPYFILE_EXCL); + } + } + } + await visit(); +} + +async function assertNoLinks(root) { + const pending = [root]; + while (pending.length) { + const target = pending.pop(); + const stat = await fs.lstat(target); + if (stat.isSymbolicLink()) throw bootstrapError("Runtime archive contains a symbolic link", "BOOTSTRAP_ARCHIVE_UNSAFE"); + if (stat.isDirectory()) for (const child of await fs.readdir(target)) pending.push(path.join(target, child)); + } +} + +function assertOfficialUrl(value, options = {}) { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) { + throw bootstrapError("Bootstrap downloads are restricted to official GitHub release hosts", "BOOTSTRAP_URL_BLOCKED"); + } + if (options.manifest && url.hostname !== "github.com") throw bootstrapError("Manifest must come from the official GitHub release", "BOOTSTRAP_URL_BLOCKED"); +} + +function assertOfficialReleaseAssetUrl(value) { + assertOfficialUrl(value); + const url = new URL(value); + if (url.hostname !== "github.com" || !url.pathname.startsWith(OFFICIAL_RELEASE_PREFIX)) { + throw bootstrapError("Runtime assets must come from the official versioned GStack release", "BOOTSTRAP_URL_BLOCKED"); + } +} + +function assertFinalDownloadUrl(value) { + assertOfficialUrl(value); +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false, windowsHide: true, stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit" }); + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { stdout += chunk; }); + child.stderr?.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code) => code === 0 ? resolve({ stdout, stderr }) : reject(bootstrapError(`${command} failed (${code})`, "BOOTSTRAP_COMMAND_FAILED"))); + }); +} + +function bootstrapError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +function formatBytes(bytes) { + const units = ["B", "KiB", "MiB", "GiB"]; + let value = bytes; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${value.toFixed(index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]} (${bytes} bytes)`; +} + +function usage() { + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; +} + +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + +async function isDirectExecution() { + if (!process.argv[1]) return false; + const [modulePath, invokedPath] = await Promise.all([ + fs.realpath(fileURLToPath(import.meta.url)), + fs.realpath(path.resolve(process.argv[1])).catch(() => path.resolve(process.argv[1])), + ]); + return modulePath === invokedPath; +} + +if (await isDirectExecution()) { + process.exitCode = await main(); +} diff --git a/skills/make-pdf/references/support/runtime-contract.json b/skills/make-pdf/references/support/runtime-contract.json new file mode 100644 index 0000000000..9eb8b82dba --- /dev/null +++ b/skills/make-pdf/references/support/runtime-contract.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "runtimeVersion": "2.0.0", + "skillApi": "2.0" +} diff --git a/skills/plan/references/legacy/autoplan.md b/skills/plan/references/legacy/autoplan.md index 87a0e3eb6e..29688bd941 100644 --- a/skills/plan/references/legacy/autoplan.md +++ b/skills/plan/references/legacy/autoplan.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/plan/references/legacy/context-restore.md b/skills/plan/references/legacy/context-restore.md index b2a6377ecc..91ace6a543 100644 --- a/skills/plan/references/legacy/context-restore.md +++ b/skills/plan/references/legacy/context-restore.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module context-restore — Restore Saved Working Context diff --git a/skills/plan/references/legacy/context-save.md b/skills/plan/references/legacy/context-save.md index 307f98f788..fe4b495ead 100644 --- a/skills/plan/references/legacy/context-save.md +++ b/skills/plan/references/legacy/context-save.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module context-save — Save Working Context diff --git a/skills/plan/references/legacy/learn.md b/skills/plan/references/legacy/learn.md index 3fa21a8e60..d966aef95b 100644 --- a/skills/plan/references/legacy/learn.md +++ b/skills/plan/references/legacy/learn.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # Project Learnings Manager diff --git a/skills/plan/references/legacy/office-hours.md b/skills/plan/references/legacy/office-hours.md index 8661179ba5..58b84bf86c 100644 --- a/skills/plan/references/legacy/office-hours.md +++ b/skills/plan/references/legacy/office-hours.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## SETUP (run this check BEFORE any browse command) diff --git a/skills/plan/references/legacy/plan-ceo-review.md b/skills/plan/references/legacy/plan-ceo-review.md index 7489bd5b0a..2acdb4dd13 100644 --- a/skills/plan/references/legacy/plan-ceo-review.md +++ b/skills/plan/references/legacy/plan-ceo-review.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/plan/references/legacy/plan-devex-review.md b/skills/plan/references/legacy/plan-devex-review.md index 3de9ab586a..8e832d00d7 100644 --- a/skills/plan/references/legacy/plan-devex-review.md +++ b/skills/plan/references/legacy/plan-devex-review.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/plan/references/legacy/plan-eng-review.md b/skills/plan/references/legacy/plan-eng-review.md index 8b7aa42d70..2f32c7377c 100644 --- a/skills/plan/references/legacy/plan-eng-review.md +++ b/skills/plan/references/legacy/plan-eng-review.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # Plan Review Mode diff --git a/skills/plan/references/legacy/plan-tune.md b/skills/plan/references/legacy/plan-tune.md index 41ed4c494a..fd16cfad08 100644 --- a/skills/plan/references/legacy/plan-tune.md +++ b/skills/plan/references/legacy/plan-tune.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module plan-tune — Question Tuning + Developer Profile (v1 observational) diff --git a/skills/plan/references/legacy/retro.md b/skills/plan/references/legacy/retro.md index db86c6c548..34cf8e71b0 100644 --- a/skills/plan/references/legacy/retro.md +++ b/skills/plan/references/legacy/retro.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/plan/references/legacy/setup-gbrain.md b/skills/plan/references/legacy/setup-gbrain.md index cb241c4bae..846f6429bf 100644 --- a/skills/plan/references/legacy/setup-gbrain.md +++ b/skills/plan/references/legacy/setup-gbrain.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module setup-gbrain — Coding-Agent Onboarding for gbrain diff --git a/skills/plan/references/legacy/spec.md b/skills/plan/references/legacy/spec.md index b3efdc7043..edfae01f8c 100644 --- a/skills/plan/references/legacy/spec.md +++ b/skills/plan/references/legacy/spec.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Specification --module spec — Author a Backlog-Ready Spec (issue + optional agent spawn) diff --git a/skills/plan/references/legacy/sync-gbrain.md b/skills/plan/references/legacy/sync-gbrain.md index 0ccb7a7a4a..be972df543 100644 --- a/skills/plan/references/legacy/sync-gbrain.md +++ b/skills/plan/references/legacy/sync-gbrain.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module sync-gbrain — Keep gbrain current and teach the agent to use it diff --git a/skills/qa/references/legacy/benchmark-models.md b/skills/qa/references/legacy/benchmark-models.md index 071a9a7e34..bbe666fe50 100644 --- a/skills/qa/references/legacy/benchmark-models.md +++ b/skills/qa/references/legacy/benchmark-models.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $qa --mode Report --module benchmark-models — Cross-Model Skill Benchmark diff --git a/skills/qa/references/legacy/benchmark.md b/skills/qa/references/legacy/benchmark.md index 4141d405d9..fd56231d92 100644 --- a/skills/qa/references/legacy/benchmark.md +++ b/skills/qa/references/legacy/benchmark.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## SETUP (run this check BEFORE any browse command) diff --git a/skills/qa/references/legacy/browse.md b/skills/qa/references/legacy/browse.md index 9df312c2a2..dca253501e 100644 --- a/skills/qa/references/legacy/browse.md +++ b/skills/qa/references/legacy/browse.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # browse: QA Testing & Dogfooding diff --git a/skills/qa/references/legacy/canary.md b/skills/qa/references/legacy/canary.md index bff07bf095..930f2171dc 100644 --- a/skills/qa/references/legacy/canary.md +++ b/skills/qa/references/legacy/canary.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## SETUP (run this check BEFORE any browse command) diff --git a/skills/qa/references/legacy/devex-review.md b/skills/qa/references/legacy/devex-review.md index f98bc1655a..6cb9ef939f 100644 --- a/skills/qa/references/legacy/devex-review.md +++ b/skills/qa/references/legacy/devex-review.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/qa/references/legacy/investigate.md b/skills/qa/references/legacy/investigate.md index bbd1e52ddb..74a2b30694 100644 --- a/skills/qa/references/legacy/investigate.md +++ b/skills/qa/references/legacy/investigate.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # Systematic Debugging diff --git a/skills/qa/references/legacy/ios-qa.md b/skills/qa/references/legacy/ios-qa.md index 5ac79e02c4..5cd00c8ba8 100644 --- a/skills/qa/references/legacy/ios-qa.md +++ b/skills/qa/references/legacy/ios-qa.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # Live-device iOS QA diff --git a/skills/qa/references/legacy/open-gstack-browser.md b/skills/qa/references/legacy/open-gstack-browser.md index 64ae334f37..e1a8dbea4f 100644 --- a/skills/qa/references/legacy/open-gstack-browser.md +++ b/skills/qa/references/legacy/open-gstack-browser.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Visible-browser point-of-use gate diff --git a/skills/qa/references/legacy/pair-agent.md b/skills/qa/references/legacy/pair-agent.md index 1e1d34bcfa..1cb8fe931f 100644 --- a/skills/qa/references/legacy/pair-agent.md +++ b/skills/qa/references/legacy/pair-agent.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Visible-browser point-of-use gate diff --git a/skills/qa/references/legacy/qa-only.md b/skills/qa/references/legacy/qa-only.md index 347cf760ba..f6281419a4 100644 --- a/skills/qa/references/legacy/qa-only.md +++ b/skills/qa/references/legacy/qa-only.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $qa --mode Report --module qa-only: Report-Only QA Testing diff --git a/skills/qa/references/legacy/qa.md b/skills/qa/references/legacy/qa.md index 092661c9df..0868e494a4 100644 --- a/skills/qa/references/legacy/qa.md +++ b/skills/qa/references/legacy/qa.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/qa/references/legacy/scrape.md b/skills/qa/references/legacy/scrape.md index 058461da8f..d941048b35 100644 --- a/skills/qa/references/legacy/scrape.md +++ b/skills/qa/references/legacy/scrape.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $qa --mode Report --module scrape — pull data from a page diff --git a/skills/qa/references/legacy/setup-browser-cookies.md b/skills/qa/references/legacy/setup-browser-cookies.md index c4540d4b81..bc234de45a 100644 --- a/skills/qa/references/legacy/setup-browser-cookies.md +++ b/skills/qa/references/legacy/setup-browser-cookies.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Visible-browser point-of-use gate diff --git a/skills/qa/references/legacy/skillify.md b/skills/qa/references/legacy/skillify.md index 7e4ee39178..d6f82c6dd5 100644 --- a/skills/qa/references/legacy/skillify.md +++ b/skills/qa/references/legacy/skillify.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $qa --mode Report --module skillify — codify the last scrape into a permanent skill diff --git a/skills/review/references/legacy/codex.md b/skills/review/references/legacy/codex.md index a515dcea61..cbb4a41124 100644 --- a/skills/review/references/legacy/codex.md +++ b/skills/review/references/legacy/codex.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/review/references/legacy/cso.md b/skills/review/references/legacy/cso.md index b4d2239a2e..fbb41fdfd2 100644 --- a/skills/review/references/legacy/cso.md +++ b/skills/review/references/legacy/cso.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $review --mode Security --module cso — Chief Security Officer Audit (v2) diff --git a/skills/review/references/legacy/health.md b/skills/review/references/legacy/health.md index e4417b522c..9d1432fc4c 100644 --- a/skills/review/references/legacy/health.md +++ b/skills/review/references/legacy/health.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $review --mode Deep --module health -- Code Quality Dashboard diff --git a/skills/review/references/legacy/review.md b/skills/review/references/legacy/review.md index f27cf44914..5b811be1bc 100644 --- a/skills/review/references/legacy/review.md +++ b/skills/review/references/legacy/review.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/ship/references/legacy/canary.md b/skills/ship/references/legacy/canary.md index bff07bf095..930f2171dc 100644 --- a/skills/ship/references/legacy/canary.md +++ b/skills/ship/references/legacy/canary.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## SETUP (run this check BEFORE any browse command) diff --git a/skills/ship/references/legacy/context-restore.md b/skills/ship/references/legacy/context-restore.md index b2a6377ecc..91ace6a543 100644 --- a/skills/ship/references/legacy/context-restore.md +++ b/skills/ship/references/legacy/context-restore.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $plan --mode Discovery --module context-restore — Restore Saved Working Context diff --git a/skills/ship/references/legacy/document-generate.md b/skills/ship/references/legacy/document-generate.md index e4494d0cb2..1b423b2abd 100644 --- a/skills/ship/references/legacy/document-generate.md +++ b/skills/ship/references/legacy/document-generate.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/ship/references/legacy/document-release.md b/skills/ship/references/legacy/document-release.md index 7827f67245..1b9f8a75b1 100644 --- a/skills/ship/references/legacy/document-release.md +++ b/skills/ship/references/legacy/document-release.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/skills/ship/references/legacy/ios-sync.md b/skills/ship/references/legacy/ios-sync.md index 691785520f..0cd9bab0f1 100644 --- a/skills/ship/references/legacy/ios-sync.md +++ b/skills/ship/references/legacy/ios-sync.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # Resync the iOS debug bridge diff --git a/skills/ship/references/legacy/land-and-deploy.md b/skills/ship/references/legacy/land-and-deploy.md index 68604175cd..3879463d90 100644 --- a/skills/ship/references/legacy/land-and-deploy.md +++ b/skills/ship/references/legacy/land-and-deploy.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## SETUP (run this check BEFORE any browse command) diff --git a/skills/ship/references/legacy/landing-report.md b/skills/ship/references/legacy/landing-report.md index cbad288ef8..5a6abf1631 100644 --- a/skills/ship/references/legacy/landing-report.md +++ b/skills/ship/references/legacy/landing-report.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` # $ship --mode Prepare --module landing-report — Version Queue Dashboard diff --git a/skills/ship/references/legacy/ship.md b/skills/ship/references/legacy/ship.md index 5de04a5105..7d54d62ba3 100644 --- a/skills/ship/references/legacy/ship.md +++ b/skills/ship/references/legacy/ship.md @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ GSTACK_STATE_ROOT="$GSTACK_HOME" GSTACK_BIN="$GSTACK_HOME/bin" BUN_CMD="$GSTACK_BIN/bun" B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" ``` ## Step 0: Detect platform and base branch diff --git a/test/catalog-trim.test.ts b/test/catalog-trim.test.ts index 36d46cde78..0bab9103f9 100644 --- a/test/catalog-trim.test.ts +++ b/test/catalog-trim.test.ts @@ -304,7 +304,8 @@ describe('proactive-suggestions.json determinism (regression for v1.45.0.0 CI fr .filter((entry: { isDirectory(): boolean; name: string }) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry: { name: string }) => entry.name) .sort(); - expect(publicSkills).toEqual(['debug', 'plan', 'qa', 'review', 'ship']); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); }); test('schema + catalog_mode + note fields are stable', () => { diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 242eafd486..5180b7e531 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -149,7 +149,8 @@ describe('gen-skill-docs', () => { .filter(entry => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map(entry => entry.name) .sort(); - expect(publicSkills).toEqual([...PUBLIC_SKILL_NAMES]); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual([...PUBLIC_SKILL_NAMES, 'make-pdf'].sort()); for (const name of PUBLIC_SKILL_NAMES) { const content = fs.readFileSync(path.join(ROOT, 'skills', name, 'SKILL.md'), 'utf-8'); diff --git a/test/gstack2-installation.test.ts b/test/gstack2-installation.test.ts index fa60f26e9e..f9ebfc1b35 100644 --- a/test/gstack2-installation.test.ts +++ b/test/gstack2-installation.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { AGENT_MATRIX, + ALL_PUBLIC_SKILLS, COLLISION_SKILLS, DEFAULT_REPO_ROOT, PUBLIC_SKILLS, @@ -40,8 +41,8 @@ describe('GStack 2 standard installer surface', () => { const result = inspectRepository(DEFAULT_REPO_ROOT); expect(result.passed).toBe(true); - expect(result.publicSkills).toEqual([...PUBLIC_SKILLS]); - expect(result.skillFiles).toEqual(PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort()); + expect(result.publicSkills).toEqual([...ALL_PUBLIC_SKILLS]); + expect(result.skillFiles).toEqual(ALL_PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort()); for (const skill of COLLISION_SKILLS) { expect(result.checks.find((check) => check.id === `repository.collision.${skill}.canonical`)).toMatchObject({ passed: true, @@ -61,7 +62,7 @@ describe('GStack 2 standard installer surface', () => { expect(fs.realpathSync(linked)).toBe(fs.realpathSync(projected)); expect(inspectRepository(projected)).toMatchObject({ passed: true, - publicSkills: [...PUBLIC_SKILLS], + publicSkills: [...ALL_PUBLIC_SKILLS], }); }); diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index 92e741b981..dbf1b3317b 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -622,6 +622,7 @@ process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset"); ); expect(compiledHelpers).toEqual({ browse: { posix: "browse/dist/browse", win32: "browse/dist/browse.exe" }, + "make-pdf": { posix: "make-pdf/dist/pdf", win32: "make-pdf/dist/pdf.exe" }, }); const bundlePaths = new Set(DEFAULT_RUNTIME_BUNDLE.map((item) => item.path)); for (const dependency of [ diff --git a/test/gstack2-runtime-setup-ux.test.ts b/test/gstack2-runtime-setup-ux.test.ts index ee07a3e9b5..8ca6d92462 100644 --- a/test/gstack2-runtime-setup-ux.test.ts +++ b/test/gstack2-runtime-setup-ux.test.ts @@ -87,7 +87,9 @@ describe("GStack runtime setup UX", () => { expect(paths).toContain("runtime"); expect(paths.some((entry) => entry.startsWith("browse/"))).toBe(true); expect(paths.some((entry) => entry.startsWith("design/"))).toBe(false); - expect(paths.some((entry) => entry.startsWith("make-pdf/"))).toBe(false); + // make-pdf rides with the browser capability (it drives the browse daemon's + // Chromium print round-trip), so selecting `browser` includes it. + expect(paths.some((entry) => entry.startsWith("make-pdf/"))).toBe(true); expect(surface.capabilities.browse).toBeTruthy(); expect(surface.capabilities["gstack-design"]).toBeUndefined(); for (const target of Object.values(surface.capabilities)) { diff --git a/test/gstack2-skill-ux.test.ts b/test/gstack2-skill-ux.test.ts index 7758e9c5cc..109e9858e8 100644 --- a/test/gstack2-skill-ux.test.ts +++ b/test/gstack2-skill-ux.test.ts @@ -33,7 +33,8 @@ describe('GStack 2 canonical skill UX', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map((entry) => entry.name) .sort(); - expect(publicSkills).toEqual([...TREE_NAMES].sort()); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual([...TREE_NAMES, 'make-pdf'].sort()); for (const assignment of SOURCE_ASSIGNMENTS) { const body = fs.readFileSync(ownerModule(assignment.source), 'utf8'); diff --git a/test/gstack2-skills.test.ts b/test/gstack2-skills.test.ts index fe6a458a4c..4684a0b3cd 100644 --- a/test/gstack2-skills.test.ts +++ b/test/gstack2-skills.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { blobShaForPath, pinnedRevisionPath } from '../scripts/gstack2/render-legacy'; import { runParity } from '../scripts/gstack2/run-parity'; @@ -30,6 +30,21 @@ describe('GStack 2 skill parity', () => { expect(result.regressions).toBe(26); }, 30_000); + test('ships make-pdf as a sixth discoverable tool skill in the canonical tree', () => { + const discovered = readdirSync(join(ROOT, 'skills'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(join(ROOT, 'skills', entry.name, 'SKILL.md'))) + .map((entry) => entry.name) + .sort(); + expect(discovered).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); + + const skill = readFileSync(join(ROOT, 'skills', 'make-pdf', 'SKILL.md'), 'utf8'); + expect(skill).toMatch(/^---\nname: make-pdf\n/); + expect(skill).toContain('description: >-'); + expect(skill).toContain('references/RUNTIME.md'); + // The runtime handoff reference is packaged so the skill is self-contained. + expect(existsSync(join(ROOT, 'skills', 'make-pdf', 'references', 'RUNTIME.md'))).toBe(true); + }); + test('does not overclaim safety-hook enforcement in portable installs', () => { const debug = readFileSync(join(ROOT, 'skills', 'debug', 'SKILL.md'), 'utf8'); expect(debug).toContain('inline advisory policy unless the active host explicitly confirms an installed hook'); diff --git a/test/post-rename-doc-regen.test.ts b/test/post-rename-doc-regen.test.ts index 52b85c6093..0ece5d4a86 100644 --- a/test/post-rename-doc-regen.test.ts +++ b/test/post-rename-doc-regen.test.ts @@ -79,6 +79,7 @@ describe('post-rename doc-regen regression (codex Finding #12)', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map((entry) => entry.name) .sort(); - expect(publicSkills).toEqual(['debug', 'plan', 'qa', 'review', 'ship']); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); }); }); diff --git a/test/skill-coverage-matrix.ts b/test/skill-coverage-matrix.ts index 1ee99618c3..f5d746f01a 100644 --- a/test/skill-coverage-matrix.ts +++ b/test/skill-coverage-matrix.ts @@ -64,6 +64,11 @@ export const SKILL_COVERAGE: Record = { periodic: [], rationale: 'browse binary has its own integration suite under browse/test/.', }, + 'make-pdf': { + gate: ['test/skill-coverage-floor.test.ts'], + periodic: [], + rationale: 'Standalone markdown->PDF tool skill; render/e2e behavior has its own suite under make-pdf/test/ (render.test.ts + e2e/*-gate.test.ts).', + }, spec: { gate: [ 'test/spec-template-invariants.test.ts', diff --git a/test/skill-validation.test.ts b/test/skill-validation.test.ts index d3e59f0b6b..b31d2440ad 100644 --- a/test/skill-validation.test.ts +++ b/test/skill-validation.test.ts @@ -31,14 +31,14 @@ function readShipUnion(): string { } describe('SKILL.md command validation', () => { - test('root SKILL.md is absent and the public surface is exactly five dispatchers', () => { + test('root SKILL.md is absent and the public surface is five dispatchers plus the make-pdf tool skill', () => { expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(false); const discovered = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true }) .filter((entry) => entry.isDirectory() && fs.existsSync(publicSkillPath(entry.name))) .map((entry) => entry.name) .sort(); - expect(discovered).toEqual([...PUBLIC_SKILLS]); + expect(discovered).toEqual([...PUBLIC_SKILLS, 'make-pdf'].sort()); for (const skill of PUBLIC_SKILLS) { const md = fs.readFileSync(publicSkillPath(skill), 'utf-8'); @@ -1619,7 +1619,8 @@ describe('Doc inventory cross-check', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(publicSkillPath(entry.name))) .map((entry) => entry.name) .sort(); - expect(publicDirs).toEqual([...PUBLIC_SKILLS]); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicDirs).toEqual([...PUBLIC_SKILLS, 'make-pdf'].sort()); }); test('every legacy template is represented in the compatibility map', () => { @@ -1631,7 +1632,11 @@ describe('Doc inventory cross-check', () => { .sort(); const legacyTemplates = fs.readdirSync(ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) - .map((entry) => entry.name); + .map((entry) => entry.name) + // make-pdf is a standalone tool skill, not a legacy invocation migrated to + // a dispatcher, so it has no compatibility alias (see STANDALONE_ROOT_SKILLS + // in scripts/gstack2/generate-skill-tree.ts). + .filter((name) => name !== 'make-pdf'); expect(mapped).toEqual(['gstack', ...legacyTemplates].sort()); });