sneakoscope 0.7.43 → 0.7.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ sks selftest --mock
43
43
  | Area | What it does |
44
44
  | --- | --- |
45
45
  | CLI runtime | Bare `sks` opens or reuses the default tmux Codex CLI workspace. `sks tmux open` remains the explicit form for session/workspace flags, and `sks --mad` launches the explicit full-access high-reasoning profile. |
46
- | Codex App commands | Installs generated skills so `$Team`, `$From-Chat-IMG`, `$DFix`, `$QA-LOOP`, `$PPT`, `$Goal`, `$DB`, `$Wiki`, `$Help`, and related routes are visible in prompt workflows. |
46
+ | Codex App commands | Installs generated skills so `$Team`, `$From-Chat-IMG`, `$DFix`, `$QA-LOOP`, `$PPT`, `$Goal`, `$DB`, `$Wiki`, `$Help`, and related routes are visible in prompt workflows. `sks codex-app remote-control` wraps Codex CLI 0.130.0+ headless remote control without falling back to older app-server internals. |
47
47
  | OpenClaw agents | Generates an OpenClaw skill package so OpenClaw agents can attach `sneakoscope-codex`, enable the `shell` tool, and discover/use SKS commands from the target repo root. |
48
48
  | Pipeline plans | Writes `pipeline-plan.json` for stateful routes so the runtime lane, kept stages, skipped stages, verification commands, and no-unrequested-fallback invariant are visible with `sks pipeline plan`. |
49
49
  | Team orchestration | Runs substantial work through score-based ambiguity handling, scouts, TriWiki refresh, debate, runtime task graphs, worker inboxes, implementation, review, cleanup, reflection, and Honest Mode; narrow work should use Proof Field evidence to skip unrelated pipeline work instead of expanding Team. |
@@ -166,7 +166,7 @@ sks tmux check
166
166
  sks tmux status --once
167
167
  ```
168
168
 
169
- Bare `sks` creates or reuses the default named tmux session for Codex CLI and attaches to it in an interactive terminal. By default it launches Codex in the SKS fast-high runtime (`--model gpt-5.5 -c model_reasoning_effort="high"`) with a short animated SKS ASCII intro. Override with `SKS_CODEX_MODEL`, `SKS_CODEX_REASONING`, disable the default model profile with `SKS_CODEX_FAST_HIGH=0`, or disable the intro animation with `SKS_TMUX_LOGO_ANIMATION=0`. Use `sks tmux open` when you need explicit `--workspace` / `--session` flags, `sks tmux check` for readiness without launching, and `sks help` for CLI help. Use `--no-attach` or `SKS_TMUX_NO_AUTO_ATTACH=1` when you only want SKS to create/reuse the session and print the manual attach command.
169
+ Bare `sks` creates or reuses the default named tmux session for Codex CLI and attaches to it in an interactive terminal. By default it launches Codex in the SKS fast-high runtime (`--model gpt-5.5 -c model_reasoning_effort="high"`) with a static SKS 3D ASCII intro inside tmux; the animated intro is reserved for non-tmux unauthenticated Codex launches and can be disabled with `SKS_TMUX_LOGO_ANIMATION=0`. Override the runtime with `SKS_CODEX_MODEL`, `SKS_CODEX_REASONING`, or disable the default model profile with `SKS_CODEX_FAST_HIGH=0`. Use `sks tmux open` when you need explicit `--workspace` / `--session` flags, `sks tmux check` for readiness without launching, and `sks help` for CLI help. Use `--no-attach` or `SKS_TMUX_NO_AUTO_ATTACH=1` when you only want SKS to create/reuse the session and print the manual attach command.
170
170
 
171
171
  Before opening tmux, SKS checks the installed Codex CLI against npm `@openai/codex@latest`. If a newer version exists, it asks `Y/n`; answering `y` updates automatically with `npm i -g @openai/codex@latest` and then opens tmux with the updated Codex CLI.
172
172
 
@@ -183,7 +183,7 @@ Bare `sks` asks this before opening Codex when codex-lb is not configured:
183
183
  Authenticate and route Codex through codex-lb? [y/N]
184
184
  ```
185
185
 
186
- Answering `y` asks for the hosted domain and API key, writes `~/.codex/config.toml`, stores the key in `~/.codex/sks-codex-lb.env` with mode `0600`, syncs Codex CLI API-key auth through `codex login --with-api-key`, and sources that env file before launching Codex in tmux. When codex-lb is configured from this prompt, SKS opens a fresh tmux session for that launch so the new key is loaded by the Codex process immediately. The generated provider config follows the codex-lb README's Codex CLI API-key setup:
186
+ Answering `y` asks for the hosted domain and API key, writes `~/.codex/config.toml`, stores the key in `~/.codex/sks-codex-lb.env` with mode `0600`, syncs Codex CLI API-key auth through `codex login --with-api-key`, and sources that env file before launching Codex in tmux. When codex-lb is configured from this prompt, SKS opens a fresh tmux session for that launch so the new key is loaded by the Codex process immediately. SKS keeps Codex App Fast mode selection visible by avoiding legacy top-level `model`, `model_reasoning_effort`, and `service_tier` locks in `~/.codex/config.toml`; route-specific reasoning stays in named profiles or explicit tmux launch args. The generated provider config follows the codex-lb README's Codex CLI API-key setup:
187
187
 
188
188
  ```toml
189
189
  model_provider = "codex-lb"
@@ -299,9 +299,18 @@ After installing, run:
299
299
  ```sh
300
300
  sks bootstrap
301
301
  sks codex-app check
302
+ sks codex-app remote-control --status
302
303
  sks dollar-commands
303
304
  ```
304
305
 
306
+ For headless remotely controllable Codex App/server sessions on Codex CLI 0.130.0 or newer, run:
307
+
308
+ ```sh
309
+ sks codex-app remote-control -- --help
310
+ ```
311
+
312
+ `sks codex-app check` reports whether the installed Codex CLI is new enough. Codex CLI 0.130.0+ app-server/remote-control threads can pick up config changes live; older CLI/TUI sessions should still be restarted after `.codex/config.toml` or MCP/plugin changes.
313
+
305
314
  Then open Codex App and use prompt commands directly in the chat. Examples:
306
315
 
307
316
  ```text
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.7.43",
4
+ "version": "0.7.45",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { codexRemoteControlStatus, formatCodexRemoteControlStatus } from '../core/codex-app.mjs';
3
+
4
+ export async function codexAppRemoteControlCommand(args = [], opts = {}) {
5
+ const controlArgs = argsBeforeSeparator(args);
6
+ if (controlArgs.includes('--help') || controlArgs.includes('-h')) {
7
+ console.log(remoteControlHelp());
8
+ return;
9
+ }
10
+
11
+ const status = await codexRemoteControlStatus();
12
+ if (controlArgs.includes('--json')) {
13
+ console.log(JSON.stringify(status, null, 2));
14
+ if (!status.ok) process.exitCode = 1;
15
+ return;
16
+ }
17
+
18
+ if (controlArgs.includes('--status') || controlArgs.includes('--check') || controlArgs.includes('--dry-run')) {
19
+ console.log(formatCodexRemoteControlStatus(status));
20
+ if (!status.ok) process.exitCode = 1;
21
+ return;
22
+ }
23
+
24
+ if (!status.ok) {
25
+ console.error(formatCodexRemoteControlStatus(status));
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ const passthrough = stripSeparator(args);
31
+ const spawnFn = opts.spawn || spawn;
32
+ const code = await spawnInherited(spawnFn, status.codex_cli.bin, ['remote-control', ...passthrough], {
33
+ cwd: process.cwd(),
34
+ env: process.env
35
+ });
36
+ if (code) process.exitCode = code;
37
+ }
38
+
39
+ function remoteControlHelp() {
40
+ return [
41
+ 'Usage: sks codex-app remote-control [--status|--check|--dry-run|--json] [-- <codex remote-control args>]',
42
+ '',
43
+ 'Starts Codex CLI 0.130.0+ remote-control, the headless remotely controllable app-server entrypoint.',
44
+ 'SKS only wraps the first-party command and refuses older Codex CLI versions instead of falling back to app-server internals.'
45
+ ].join('\n');
46
+ }
47
+
48
+ function stripSeparator(args = []) {
49
+ const index = args.indexOf('--');
50
+ return index >= 0 ? args.slice(index + 1) : args;
51
+ }
52
+
53
+ function argsBeforeSeparator(args = []) {
54
+ const index = args.indexOf('--');
55
+ return index >= 0 ? args.slice(0, index) : args;
56
+ }
57
+
58
+ function spawnInherited(spawnFn, command, args, opts) {
59
+ return new Promise((resolve) => {
60
+ const child = spawnFn(command, args, { ...opts, stdio: 'inherit' });
61
+ child.on('error', (err) => {
62
+ console.error(`codex remote-control failed to start: ${err.message}`);
63
+ resolve(1);
64
+ });
65
+ child.on('close', (code) => resolve(code || 0));
66
+ });
67
+ }
@@ -30,6 +30,11 @@ export async function postinstall({ bootstrap }) {
30
30
  else if (context7Install.status === 'codex_missing') console.log('Context7 MCP: Codex CLI missing. Install @openai/codex or set SKS_CODEX_BIN, then run `sks context7 setup --scope global` or `sks setup` in a project.');
31
31
  else if (context7Install.status === 'skipped') console.log(`Context7 MCP: skipped (${context7Install.reason}).`);
32
32
  else if (context7Install.status === 'failed') console.log(`Context7 MCP: auto setup failed. Run \`sks context7 setup --scope global\` or \`sks setup\`. ${context7Install.error || ''}`.trim());
33
+ const fastModeRepair = await ensureGlobalCodexFastModeDuringInstall();
34
+ if (fastModeRepair.status === 'updated') console.log(`Codex App Fast mode: restored in ${fastModeRepair.config_path}.`);
35
+ else if (fastModeRepair.status === 'present') console.log('Codex App Fast mode: config already compatible.');
36
+ else if (fastModeRepair.status === 'skipped') console.log(`Codex App Fast mode: skipped (${fastModeRepair.reason}).`);
37
+ else if (fastModeRepair.status === 'failed') console.log(`Codex App Fast mode: auto repair failed. Run \`sks setup\`. ${fastModeRepair.error || ''}`.trim());
33
38
  const globalSkills = await ensureGlobalCodexSkillsDuringInstall();
34
39
  if (globalSkills.status === 'installed') console.log(`Codex App global $ skills: installed in ${globalSkills.root} (${globalSkills.installed_count} skills).`);
35
40
  else if (globalSkills.status === 'partial') console.log(`Codex App global $ skills: partial in ${globalSkills.root}; missing ${globalSkills.missing_skills.join(', ')}. Run \`sks doctor --fix\`.`);
@@ -138,7 +143,7 @@ export async function configureCodexLb(opts = {}) {
138
143
  if (!apiKey) return { ok: false, status: 'missing_api_key', config_path: configPath, env_path: envPath };
139
144
  await ensureDir(path.dirname(configPath));
140
145
  const current = await readText(configPath, '');
141
- const next = upsertCodexLbConfig(current, baseUrl);
146
+ const next = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, baseUrl));
142
147
  await writeTextAtomic(configPath, next);
143
148
  await writeTextAtomic(envPath, `export CODEX_LB_API_KEY=${shellSingleQuote(apiKey)}\n`);
144
149
  await fsp.chmod(envPath, 0o600).catch(() => {});
@@ -225,6 +230,77 @@ function upsertCodexLbConfig(text = '', baseUrl) {
225
230
  return `${next.trim()}\n`;
226
231
  }
227
232
 
233
+ export async function ensureGlobalCodexFastModeDuringInstall(opts = {}) {
234
+ if (process.env.SKS_SKIP_CODEX_FAST_MODE_REPAIR === '1') return { status: 'skipped', reason: 'SKS_SKIP_CODEX_FAST_MODE_REPAIR=1' };
235
+ const home = opts.home || process.env.HOME || os.homedir();
236
+ const configPath = opts.configPath || codexLbConfigPath(home);
237
+ try {
238
+ await ensureDir(path.dirname(configPath));
239
+ const current = await readText(configPath, '');
240
+ const next = normalizeCodexFastModeUiConfig(current);
241
+ if (next === ensureTrailingNewline(current)) return { status: 'present', config_path: configPath };
242
+ await writeTextAtomic(configPath, next);
243
+ return { status: 'updated', config_path: configPath };
244
+ } catch (err) {
245
+ return { status: 'failed', config_path: configPath, error: err.message };
246
+ }
247
+ }
248
+
249
+ export function normalizeCodexFastModeUiConfig(text = '') {
250
+ let next = removeLegacyTopLevelCodexModeLocks(text);
251
+ next = upsertTomlTableKey(next, 'features', 'fast_mode_ui = true');
252
+ next = upsertTomlTableKey(next, 'user.fast_mode', 'visible = true');
253
+ next = upsertTomlTableKey(next, 'user.fast_mode', 'enabled = true');
254
+ return ensureTrailingNewline(next);
255
+ }
256
+
257
+ function removeLegacyTopLevelCodexModeLocks(text = '') {
258
+ const legacy = {
259
+ model: new Set(['gpt-5.5']),
260
+ model_reasoning_effort: new Set(['high']),
261
+ service_tier: new Set(['fast'])
262
+ };
263
+ const lines = String(text || '').split('\n');
264
+ const firstTable = lines.findIndex((x) => /^\s*\[.+\]\s*$/.test(x));
265
+ const end = firstTable === -1 ? lines.length : firstTable;
266
+ return lines.filter((line, index) => {
267
+ if (index >= end) return true;
268
+ const match = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$/);
269
+ if (!match) return true;
270
+ return !legacy[match[1]]?.has(match[2]);
271
+ }).join('\n').replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n');
272
+ }
273
+
274
+ function upsertTomlTableKey(text, table, line) {
275
+ const key = String(line).split('=')[0].trim();
276
+ const lines = String(text || '').trimEnd().split('\n');
277
+ if (lines.length === 1 && lines[0] === '') lines.length = 0;
278
+ const header = `[${table}]`;
279
+ const start = lines.findIndex((x) => x.trim() === header);
280
+ if (start === -1) return [...lines, ...(lines.length ? [''] : []), header, line].join('\n').replace(/\n{3,}/g, '\n\n');
281
+ let end = lines.length;
282
+ for (let i = start + 1; i < lines.length; i++) {
283
+ if (/^\s*\[.+\]\s*$/.test(lines[i])) {
284
+ end = i;
285
+ break;
286
+ }
287
+ }
288
+ const keyRe = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
289
+ for (let i = start + 1; i < end; i++) {
290
+ if (keyRe.test(lines[i])) {
291
+ lines[i] = line;
292
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
293
+ }
294
+ }
295
+ lines.splice(end, 0, line);
296
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
297
+ }
298
+
299
+ function ensureTrailingNewline(text = '') {
300
+ const value = String(text || '').trimEnd();
301
+ return value ? `${value}\n` : '';
302
+ }
303
+
228
304
  function upsertTopLevelTomlString(text, key, value) {
229
305
  const line = `${key} = "${value}"`;
230
306
  const lines = String(text || '').split('\n');
package/src/cli/main.mjs CHANGED
@@ -26,10 +26,15 @@ import { buildResearchPrompt, evaluateResearchGate, writeMockResearchResult, wri
26
26
  import {
27
27
  PPT_AUDIENCE_STRATEGY_ARTIFACT,
28
28
  PPT_CLEANUP_REPORT_ARTIFACT,
29
+ PPT_FACT_LEDGER_ARTIFACT,
29
30
  PPT_GATE_ARTIFACT,
30
31
  PPT_HTML_ARTIFACT,
32
+ PPT_IMAGE_ASSET_LEDGER_ARTIFACT,
33
+ PPT_ITERATION_REPORT_ARTIFACT,
31
34
  PPT_PARALLEL_REPORT_ARTIFACT,
32
35
  PPT_PDF_ARTIFACT,
36
+ PPT_REVIEW_LEDGER_ARTIFACT,
37
+ PPT_REVIEW_POLICY_ARTIFACT,
33
38
  PPT_RENDER_REPORT_ARTIFACT,
34
39
  PPT_SOURCE_HTML_DIR,
35
40
  PPT_TEMP_DIR,
@@ -57,6 +62,7 @@ import { buildPromptContext } from '../core/prompt-context-builder.mjs';
57
62
  import { renderTeamDashboardState, writeTeamDashboardState } from '../core/team-dashboard-renderer.mjs';
58
63
  import { GOAL_WORKFLOW_ARTIFACT } from '../core/goal-workflow.mjs';
59
64
  import { CODEX_APP_DOCS_URL, codexAppIntegrationStatus, formatCodexAppStatus } from '../core/codex-app.mjs';
65
+ import { codexAppRemoteControlCommand } from './codex-app-command.mjs';
60
66
  import { OPENCLAW_SKILL_NAME, installOpenClawSkill } from '../core/openclaw.mjs';
61
67
  import { buildTmuxLaunchPlan, buildTmuxOpenArgs, codexLaunchCommand, createTmuxSession, isTmuxShellSession, runTmuxLaunchPlanSyntaxCheck, shouldAutoAttachTmux, tmuxReadiness, tmuxStatusKind, defaultTmuxSessionName, formatTmuxBanner, launchTmuxTeamView, launchTmuxUi, platformTmuxInstallHint, runTmuxStatus, sanitizeTmuxSessionName, teamLaneStyle } from '../core/tmux-ui.mjs';
62
68
  import { autoReviewProfileName, autoReviewStatus, autoReviewSummary, enableAutoReview, disableAutoReview, enableMadHighProfile, madHighProfileName } from '../core/auto-review.mjs';
@@ -417,6 +423,10 @@ async function pptCommand(sub = 'status', args = []) {
417
423
  console.log(`Mission: ${id}`);
418
424
  console.log(`HTML: ${path.relative(root, result.files.html)}`);
419
425
  console.log(`PDF: ${path.relative(root, result.files.pdf)}`);
426
+ console.log(`Facts: ${path.relative(root, result.files.fact_ledger)}`);
427
+ console.log(`Images: ${path.relative(root, result.files.image_asset_ledger)}`);
428
+ console.log(`Review: ${path.relative(root, result.files.review_ledger)}`);
429
+ console.log(`Loop: ${path.relative(root, result.files.iteration_report)}`);
420
430
  console.log(`Report: ${path.relative(root, result.files.render_report)}`);
421
431
  console.log(`Cleanup: ${path.relative(root, result.files.cleanup_report)}`);
422
432
  console.log(`Parallel:${' '.repeat(1)}${path.relative(root, result.files.parallel_report)}`);
@@ -435,6 +445,11 @@ async function pptCommand(sub = 'status', args = []) {
435
445
  html: path.join(dir, PPT_HTML_ARTIFACT),
436
446
  source_html: path.join(dir, PPT_HTML_ARTIFACT),
437
447
  pdf: path.join(dir, PPT_PDF_ARTIFACT),
448
+ fact_ledger: path.join(dir, PPT_FACT_LEDGER_ARTIFACT),
449
+ image_asset_ledger: path.join(dir, PPT_IMAGE_ASSET_LEDGER_ARTIFACT),
450
+ review_policy: path.join(dir, PPT_REVIEW_POLICY_ARTIFACT),
451
+ review_ledger: path.join(dir, PPT_REVIEW_LEDGER_ARTIFACT),
452
+ iteration_report: path.join(dir, PPT_ITERATION_REPORT_ARTIFACT),
438
453
  render_report: path.join(dir, PPT_RENDER_REPORT_ARTIFACT),
439
454
  cleanup_report: path.join(dir, PPT_CLEANUP_REPORT_ARTIFACT),
440
455
  parallel_report: path.join(dir, PPT_PARALLEL_REPORT_ARTIFACT),
@@ -447,6 +462,10 @@ async function pptCommand(sub = 'status', args = []) {
447
462
  console.log(`Gate: ${status.ok ? 'passed' : 'not passed'}`);
448
463
  console.log(`HTML: ${path.relative(root, status.files.html)}`);
449
464
  console.log(`PDF: ${path.relative(root, status.files.pdf)}`);
465
+ console.log(`Facts: ${path.relative(root, status.files.fact_ledger)}`);
466
+ console.log(`Images: ${path.relative(root, status.files.image_asset_ledger)}`);
467
+ console.log(`Review: ${path.relative(root, status.files.review_ledger)}`);
468
+ console.log(`Loop: ${path.relative(root, status.files.iteration_report)}`);
450
469
  console.log(`Report: ${path.relative(root, status.files.render_report)}`);
451
470
  console.log(`Cleanup: ${path.relative(root, status.files.cleanup_report)}`);
452
471
  console.log(`Parallel:${' '.repeat(1)}${path.relative(root, status.files.parallel_report)}`);
@@ -1233,6 +1252,7 @@ async function autoReviewCommand(sub = 'status', args = []) {
1233
1252
 
1234
1253
  async function codexAppHelp(args = []) {
1235
1254
  const action = args[0] || 'help';
1255
+ if (action === 'remote-control' || action === 'remote') return codexAppRemoteControlCommand(args.slice(1));
1236
1256
  if (action === 'check' || action === 'status') {
1237
1257
  const status = await codexAppIntegrationStatus();
1238
1258
  const skills = await codexAppSkillReadiness();
@@ -1259,7 +1279,7 @@ async function codexAppHelp(args = []) {
1259
1279
  'ㅅㅋㅅ Codex App', '',
1260
1280
  formatCodexAppStatus(status), '',
1261
1281
  `Skills: project=${skills.project.ok ? 'ok' : `missing ${skills.project.missing.length}`} global=${skills.global.ok ? 'ok' : `missing ${skills.global.missing.length}`}`, '',
1262
- 'Setup:', ' sks bootstrap', ' sks deps check', ' sks codex-app check', ' sks tmux check', '',
1282
+ 'Setup:', ' sks bootstrap', ' sks deps check', ' sks codex-app check', ' sks codex-app remote-control --status', ' sks tmux check', '',
1263
1283
  'Generated files:', ' .codex/config.toml', ' .codex/hooks.json', ' .agents/skills/', ' .codex/agents/', ' .codex/SNEAKOSCOPE.md', ' AGENTS.md', '',
1264
1284
  'Git ignore:', ' default setup writes .gitignore entries for .sneakoscope/, .codex/, .agents/, AGENTS.md', ' --local-only writes those patterns to .git/info/exclude instead', '',
1265
1285
  'Prompt routes:', formatDollarCommandsCompact(' ')
@@ -1302,9 +1322,9 @@ function usage(args = []) {
1302
1322
  openclaw: ['OpenClaw', '', ' sks openclaw install', ' sks openclaw path', ' sks openclaw print SKILL.md', '', 'Installs an OpenClaw skill package under ~/.openclaw/skills/sneakoscope-codex so OpenClaw agents can attach skills: [sneakoscope-codex] with the shell tool and call local SKS commands from a project root.'],
1303
1323
  team: ['Team', '', ' sks team "task" executor:5 reviewer:2 user:1', ' sks team watch latest', ' sks team lane latest --agent analysis_scout_1 --follow', ' sks team message latest --from analysis_scout_1 --to executor_1 --message "handoff note"', ' sks team cleanup-tmux latest', '', '$Team runs questions -> contract -> scouts -> TriWiki attention -> debate -> runtime graph/inbox -> fresh executors -> review -> cleanup -> reflection -> Honest.'],
1304
1324
  'qa-loop': ['QA-LOOP', '', ' sks qa-loop prepare "QA this app"', ' sks qa-loop answer <MISSION_ID> answers.json', ' sks qa-loop run <MISSION_ID> --max-cycles 8', '', 'Report: YYYY-MM-DD-v<version>-qa-report.md'],
1305
- ppt: ['PPT', '', ' $PPT 투자자용 피치덱을 HTML 기반 PDF로 만들어줘', ' $PPT 우리 SaaS 소개자료 만들어줘', ' sks ppt build latest --json', ' sks ppt status latest --json', '', '$PPT asks delivery context, audience profile, STP strategy, decision context, and 3+ pain-point/solution/aha mappings before source research, design-system work, HTML/PDF export, and render QA. Independent strategy/render/file-write phases run in parallel where inputs allow and are recorded in ppt-parallel-report.json. The visual system must stay simple, restrained, and information-first; editable source HTML is kept under source-html/, PPT-only temporary build files are cleaned, and installed skills/MCPs outside the $PPT allowlist are ignored. Design uses getdesign-reference plus the built-in PPT design pipeline; imagegen and Context7 are conditional only when the sealed PPT contract needs raster assets or current external docs.'],
1325
+ ppt: ['PPT', '', ' $PPT 투자자용 피치덱을 HTML 기반 PDF로 만들어줘', ' $PPT 우리 SaaS 소개자료 만들어줘', ' sks ppt build latest --json', ' sks ppt status latest --json', '', '$PPT asks delivery context, audience profile, STP strategy, decision context, and 3+ pain-point/solution/aha mappings before source research, design-system work, HTML/PDF export, render QA, fact-ledger validation, and bounded review-loop validation. Independent strategy/render/file-write phases run in parallel where inputs allow and are recorded in ppt-parallel-report.json. The visual system must stay simple, restrained, and information-first; editable source HTML is kept under source-html/, PPT-only temporary build files are cleaned, and installed skills/MCPs outside the $PPT allowlist are ignored. Design uses getdesign-reference plus the built-in PPT design pipeline; imagegen/gpt-image-2 and Context7 are conditional only when the sealed PPT contract needs raster assets, slide visual critique, or current external docs. Missing required image-review evidence blocks instead of being simulated.'],
1306
1326
  goal: ['Goal', '', ' sks goal create "task"', ' sks goal status latest', ' sks goal pause latest', ' sks goal resume latest', ' sks goal clear latest'],
1307
- 'codex-app': ['Codex App', '', ' sks bootstrap', ' sks codex-app check', ' sks dollar-commands', ' cat .codex/SNEAKOSCOPE.md'],
1327
+ 'codex-app': ['Codex App', '', ' sks bootstrap', ' sks codex-app check', ' sks codex-app remote-control --status', ' sks dollar-commands', ' cat .codex/SNEAKOSCOPE.md'],
1308
1328
  dollar: ['Dollar Commands', '', formatDollarCommandsCompact(' '), '', 'Terminal: sks dollar-commands [--json]'],
1309
1329
  wiki: ['TriWiki', '', ' sks wiki pack', ' sks wiki refresh [--prune]', ' sks wiki sweep latest --json', ' sks wiki validate .sneakoscope/wiki/context-pack.json', ' sks wiki prune --dry-run --json', '', 'Packs include attention.use_first and attention.hydrate_first for compact recall plus source hydration. Sweep records intentional forgetting and promotion candidates.'],
1310
1330
  harness: ['Harness Growth', '', ' sks harness fixture --json', ' sks harness review --json', '', 'Runs deterministic fixtures for deliberate forgetting, skill cards, harness experiments, tool error taxonomy, permission profiles, MultiAgentV2, and tmux cockpit views.'],
@@ -1757,6 +1777,13 @@ async function safeReadText(file, fallback = '') {
1757
1777
  try { return await fsp.readFile(file, 'utf8'); } catch { return fallback; }
1758
1778
  }
1759
1779
 
1780
+ function hasTopLevelCodexModeLock(text = '') {
1781
+ const lines = String(text || '').split('\n');
1782
+ const firstTable = lines.findIndex((x) => /^\s*\[.+\]\s*$/.test(x));
1783
+ const top = (firstTable === -1 ? lines : lines.slice(0, firstTable)).join('\n');
1784
+ return /^model\s*=|^model_reasoning_effort\s*=|^service_tier\s*=/m.test(top);
1785
+ }
1786
+
1760
1787
  async function resolveMissionId(root, arg) { return (!arg || arg === 'latest') ? findLatestMission(root) : arg; }
1761
1788
  function readMaxCycles(args, fallback) {
1762
1789
  const i = args.indexOf('--max-cycles');
@@ -1999,6 +2026,8 @@ async function selftest() {
1999
2026
  const defaultFastHighPlan = await buildTmuxLaunchPlan({ root: tmp, tmux: { ok: true, bin: 'tmux', version: '3.4' }, codex: { bin: 'codex', version: 'codex-cli 99.0.0' }, app: { ok: true } });
2000
2027
  if (defaultFastHighPlan.codexArgs.join(' ') !== '--model gpt-5.5 -c model_reasoning_effort="high"') throw new Error('selftest failed: default sks tmux launch is not fast-high');
2001
2028
  const codexLbHome = path.join(tmp, 'codex-lb-home');
2029
+ await ensureDir(path.join(codexLbHome, '.codex'));
2030
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), 'model = "gpt-5.5"\nmodel_reasoning_effort = "high"\nservice_tier = "fast"\n');
2002
2031
  const codexLbSetup = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'setup', '--host', 'lb.example.test', '--api-key', 'sk-test', '--json'], {
2003
2032
  cwd: tmp,
2004
2033
  env: { HOME: codexLbHome, SKS_GLOBAL_ROOT: path.join(tmp, 'codex-lb-global') },
@@ -2011,6 +2040,7 @@ async function selftest() {
2011
2040
  const codexLbEnv = await safeReadText(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'));
2012
2041
  const codexLbAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
2013
2042
  if (!codexLbSetupJson.ok || codexLbSetupJson.base_url !== 'https://lb.example.test/backend-api/codex' || !codexLbConfig.includes('model_provider = "codex-lb"') || !codexLbConfig.includes('[model_providers.codex-lb]') || !codexLbEnv.includes("CODEX_LB_API_KEY='sk-test'") || !codexLbAuth.includes('"auth_mode": "apikey"')) throw new Error('selftest failed: codex-lb setup did not write provider config, env key, and Codex API-key auth');
2043
+ if (!codexLbConfig.includes('fast_mode_ui = true') || !codexLbConfig.includes('[user.fast_mode]') || hasTopLevelCodexModeLock(codexLbConfig)) throw new Error('selftest failed: codex-lb setup did not preserve Codex App Fast mode UI');
2014
2044
  const codexLbLaunch = codexLaunchCommand(tmp, 'codex', []);
2015
2045
  if (!codexLbLaunch.includes('sks-codex-lb.env')) throw new Error('selftest failed: tmux launch command does not source codex-lb env file');
2016
2046
  if (!codexLbLaunch.includes('SKS_TMUX_LOGO_ANIMATION') || !codexLbLaunch.includes('SNEAKOSCOPE CODEX')) throw new Error('selftest failed: tmux launch command does not include the animated SKS logo intro');
@@ -2053,6 +2083,31 @@ async function selftest() {
2053
2083
  maxOutputBytes: 64 * 1024
2054
2084
  });
2055
2085
  if (!String(openClawAutoUpdate.stdout || '').includes('Codex CLI ready: 0.1.0 -> codex-cli 99.0.0')) throw new Error('selftest failed: OpenClaw mode did not auto-approve Codex CLI update before tmux launch');
2086
+ const remoteControlBin = path.join(tmp, 'remote-control-bin');
2087
+ await ensureDir(remoteControlBin);
2088
+ await writeTextAtomic(path.join(remoteControlBin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.130.0"; exit 0; fi\nif [ "$1" = "remote-control" ]; then echo "remote-control $*"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
2089
+ await fsp.chmod(path.join(remoteControlBin, 'codex'), 0o755);
2090
+ const remoteControlStatus = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-app', 'remote-control', '--dry-run', '--json'], {
2091
+ cwd: globalCwd,
2092
+ env: { SKS_GLOBAL_ROOT: globalRuntimeRoot, PATH: remoteControlBin },
2093
+ timeoutMs: 15000,
2094
+ maxOutputBytes: 64 * 1024
2095
+ });
2096
+ if (remoteControlStatus.code !== 0) throw new Error(`selftest failed: Codex remote-control status exited ${remoteControlStatus.code}: ${remoteControlStatus.stderr}`);
2097
+ const remoteControlJson = JSON.parse(remoteControlStatus.stdout);
2098
+ if (!remoteControlJson.ok || remoteControlJson.min_version !== '0.130.0' || !String(remoteControlJson.command || '').includes('remote-control')) throw new Error('selftest failed: Codex remote-control status did not report 0.130.0 readiness');
2099
+ const remoteControlOldBin = path.join(tmp, 'remote-control-old-bin');
2100
+ await ensureDir(remoteControlOldBin);
2101
+ await writeTextAtomic(path.join(remoteControlOldBin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.129.0"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
2102
+ await fsp.chmod(path.join(remoteControlOldBin, 'codex'), 0o755);
2103
+ const remoteControlOldStatus = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-app', 'remote-control', '--dry-run'], {
2104
+ cwd: globalCwd,
2105
+ env: { SKS_GLOBAL_ROOT: globalRuntimeRoot, PATH: remoteControlOldBin },
2106
+ timeoutMs: 15000,
2107
+ maxOutputBytes: 64 * 1024
2108
+ });
2109
+ if (remoteControlOldStatus.code !== 1 || !String(`${remoteControlOldStatus.stdout}\n${remoteControlOldStatus.stderr}`).includes('Codex CLI 0.130.0+')) throw new Error('selftest failed: Codex remote-control did not block older Codex CLI versions');
2110
+ if (!COMMAND_CATALOG.find((entry) => entry.name === 'codex-app')?.usage.includes('remote-control')) throw new Error('selftest failed: codex-app command catalog does not advertise remote-control');
2056
2111
  const guardBlocked = await checkHarnessModification(tmp, { tool_name: 'apply_patch', command: '*** Update File: .agents/skills/team/SKILL.md\n+tamper\n' });
2057
2112
  if (guardBlocked.action !== 'block') throw new Error('selftest failed: harness guard allowed skill tampering');
2058
2113
  const setupBlocked = await checkHarnessModification(tmp, { command: 'sks setup --force' });
@@ -2407,7 +2462,8 @@ async function selftest() {
2407
2462
  if (!hookKoreanSksContext.includes('Route: $Team')) throw new Error('selftest failed: Korean implementation prompt did not promote to Team route');
2408
2463
  if (hookKoreanSksContext.includes('SKS answer-only pipeline active')) throw new Error('selftest failed: Korean implementation prompt still used answer-only pipeline');
2409
2464
  const hookKoreanSksState = await readJson(stateFile(hookKoreanSksTmp), {});
2410
- if (hookKoreanSksState.phase !== 'TEAM_CLARIFICATION_CONTRACT_SEALED' || hookKoreanSksState.implementation_allowed !== true || !hookKoreanSksState.ambiguity_gate_passed) throw new Error('selftest failed: Korean Team auto-seal');
2465
+ if (hookKoreanSksState.phase !== 'TEAM_PARALLEL_ANALYSIS_SCOUTING' || hookKoreanSksState.implementation_allowed !== true || !hookKoreanSksState.ambiguity_gate_passed || !hookKoreanSksState.team_plan_ready) throw new Error('selftest failed: Korean Team auto-seal did not materialize Team');
2466
+ if (!(await exists(path.join(missionDir(hookKoreanSksTmp, hookKoreanSksState.mission_id), 'team-plan.json')))) throw new Error('selftest failed: Korean Team auto-seal did not write team-plan.json');
2411
2467
  const hookPaymentTeamTmp = tmpdir();
2412
2468
  await initProject(hookPaymentTeamTmp, {});
2413
2469
  const hookPaymentTeamPayload = JSON.stringify({ cwd: hookPaymentTeamTmp, prompt: '$Team 결제 재시도 정책과 로그인 세션 만료 버그 수정 executor:2 reviewer:1 user:1' });
@@ -2418,9 +2474,10 @@ async function selftest() {
2418
2474
  if (!hookPaymentTeamContext.includes('Ambiguity gate auto-sealed')) throw new Error('selftest failed: predictable payment/auth Team prompt did not auto-seal');
2419
2475
  if (hookPaymentTeamContext.includes('PAYMENT_RETRY_POLICY') || hookPaymentTeamContext.includes('AUTH_PROTOCOL_CHANGE_ALLOWED')) throw new Error('selftest failed: predictable payment/auth policy defaults were asked instead of inferred');
2420
2476
  const hookPaymentTeamState = await readJson(stateFile(hookPaymentTeamTmp), {});
2421
- if (hookPaymentTeamState.phase !== 'TEAM_CLARIFICATION_CONTRACT_SEALED' || hookPaymentTeamState.implementation_allowed !== true || !hookPaymentTeamState.ambiguity_gate_passed) throw new Error('selftest failed: predictable payment/auth Team state was not executable after auto-seal');
2477
+ if (hookPaymentTeamState.phase !== 'TEAM_PARALLEL_ANALYSIS_SCOUTING' || hookPaymentTeamState.implementation_allowed !== true || !hookPaymentTeamState.ambiguity_gate_passed || !hookPaymentTeamState.team_plan_ready) throw new Error('selftest failed: predictable payment/auth Team did not materialize after auto-seal');
2422
2478
  const hookPaymentTeamSchema = await readJson(path.join(missionDir(hookPaymentTeamTmp, hookPaymentTeamState.mission_id), 'required-answers.schema.json'));
2423
2479
  if (hookPaymentTeamSchema.slots.length !== 0 || hookPaymentTeamSchema.inferred_answers?.PAYMENT_RETRY_POLICY === undefined || hookPaymentTeamSchema.inferred_answers?.AUTH_SESSION_EXPIRED_BEHAVIOR === undefined) throw new Error('selftest failed: predictable payment/auth defaults were not recorded as inferred answers');
2480
+ if (!(await exists(path.join(missionDir(hookPaymentTeamTmp, hookPaymentTeamState.mission_id), 'team-plan.json')))) throw new Error('selftest failed: predictable payment/auth Team auto-seal did not write team-plan.json');
2424
2481
  const hookTeamTmp = tmpdir();
2425
2482
  await initProject(hookTeamTmp, {});
2426
2483
  const hookTeamPayload = JSON.stringify({ cwd: hookTeamTmp, prompt: '$Team 발표자료 만들어줘 executor:2 reviewer:1 user:1' });
@@ -2633,11 +2690,12 @@ async function selftest() {
2633
2690
  if (!codexConfigText.includes('[agents.team_consensus]')) throw new Error('selftest failed: team_consensus agent not configured');
2634
2691
  const preservedConfigTmp = tmpdir();
2635
2692
  await ensureDir(path.join(preservedConfigTmp, '.codex'));
2636
- await writeTextAtomic(path.join(preservedConfigTmp, '.codex', 'config.toml'), '[features]\nfast_mode_ui = true\n\n[user.fast_mode]\nvisible = true\n');
2693
+ await writeTextAtomic(path.join(preservedConfigTmp, '.codex', 'config.toml'), 'model = "gpt-5.5"\nmodel_reasoning_effort = "high"\nservice_tier = "fast"\n\n[features]\nfast_mode_ui = true\n\n[user.fast_mode]\nvisible = true\n');
2637
2694
  await initProject(preservedConfigTmp, {});
2638
2695
  const preservedConfig = await safeReadText(path.join(preservedConfigTmp, '.codex', 'config.toml'));
2639
2696
  if (!preservedConfig.includes('fast_mode_ui = true') || !preservedConfig.includes('[user.fast_mode]') || !preservedConfig.includes('visible = true') || !preservedConfig.includes('enabled = true') || !preservedConfig.includes('default_profile = "sks-fast-high"')) throw new Error('selftest failed: Codex config merge dropped or failed to enable Fast mode settings');
2640
2697
  if (!preservedConfig.includes('codex_hooks = true') || !preservedConfig.includes('[profiles.sks-fast-high]')) throw new Error('selftest failed: Codex config merge did not add SKS managed settings');
2698
+ if (hasTopLevelCodexModeLock(preservedConfig)) throw new Error('selftest failed: Codex config merge left top-level legacy mode locks that hide Fast mode UI');
2641
2699
  const autoReviewHome = path.join(tmp, 'auto-review-home');
2642
2700
  const autoReviewEnv = { HOME: autoReviewHome };
2643
2701
  const autoReviewEnabled = await enableAutoReview({ env: autoReviewEnv, high: true });
@@ -2991,6 +3049,10 @@ async function selftest() {
2991
3049
  if (buttonUxSlotIds.length) throw new Error(`selftest failed: clear small UI work should auto-seal, got ${buttonUxSlotIds.join(',')}`);
2992
3050
  if (buttonUxSchema.inferred_answers.UI_STATE_BEHAVIOR !== 'infer_from_task_context_and_existing_design_system; preserve existing loading/error/empty/retry behavior unless explicitly requested; add only standard states required by the touched surface') throw new Error('selftest failed: UI state default inference missing');
2993
3051
  if (buttonUxSchema.inferred_answers.VISUAL_REGRESSION_REQUIRED !== 'yes_if_available') throw new Error('selftest failed: visual regression default inference missing');
3052
+ const predictableAuthCliSchema = buildQuestionSchema('회전 아스키 아트는 제일 처음 인증 안됐을때만 codex cli처럼 애니메이션으로 보이게 하고 tmux에서는 정적 3d 아스키 아트로 보여줘');
3053
+ const predictableAuthCliSlotIds = predictableAuthCliSchema.slots.map((s) => s.id);
3054
+ if (predictableAuthCliSlotIds.length) throw new Error(`selftest failed: clear auth-worded CLI rendering work should auto-seal, got ${predictableAuthCliSlotIds.join(',')}`);
3055
+ if (!predictableAuthCliSchema.inferred_answers.RISK_BOUNDARY?.includes('no destructive commands or live data writes')) throw new Error('selftest failed: predictable auth-worded CLI work did not infer conservative risk boundary');
2994
3056
  const vagueSchema = buildQuestionSchema('뭔가 개선해줘');
2995
3057
  const vagueSlotIds = vagueSchema.slots.map((s) => s.id);
2996
3058
  if (!vagueSlotIds.includes('INTENT_TARGET') || vagueSlotIds.includes('GOAL_PRECISE') || vagueSlotIds.includes('ACCEPTANCE_CRITERIA')) throw new Error(`selftest failed: vague work should ask dynamic intent questions only, got ${vagueSlotIds.join(',')}`);
@@ -3009,6 +3071,7 @@ async function selftest() {
3009
3071
  if (!pptSkillText.includes('simple, restrained, and information-first') || !pptSkillText.includes('over-designed decoration') || !pptSkillText.includes(CODEX_APP_IMAGE_GENERATION_DOC_URL) || !pptSkillText.includes(AWESOME_DESIGN_MD_REFERENCE.url) || !pptSkillText.includes('only design decision SSOT') || !pptSkillText.includes('instead of treating references as parallel authorities')) throw new Error('selftest failed: generated PPT skill missing restrained design/imagegen/fused-SSOT guidance');
3010
3072
  if (!pptSkillText.includes('PPT pipeline allowlist') || !pptSkillText.includes('ignore installed skills and MCPs') || !pptSkillText.includes('prevent AI-like generic presentation design') || !pptSkillText.includes('Do not use generic design skills such as design-artifact-expert')) throw new Error('selftest failed: generated PPT skill missing pipeline allowlist enforcement');
3011
3073
  if (!pptSkillText.includes('source-html/') || !pptSkillText.includes('temporary build files') || !pptSkillText.includes('ppt-parallel-report.json')) throw new Error('selftest failed: generated PPT skill missing source preservation/temp cleanup/parallel guidance');
3074
+ if (!pptSkillText.includes('ppt-fact-ledger.json') || !pptSkillText.includes('ppt-image-asset-ledger.json') || !pptSkillText.includes('OpenAI Image API') || !pptSkillText.includes('ppt-review-ledger.json') || !pptSkillText.includes('ppt-iteration-report.json') || !pptSkillText.includes('never simulate missing gpt-image-2 output')) throw new Error('selftest failed: generated PPT skill missing fact/image/review loop anti-fake guidance');
3012
3075
  if (routeRequiresSubagents(pptRoute, '$PPT 투자자용 피치덱 만들어줘')) throw new Error('selftest failed: PPT route should not require subagents by default');
3013
3076
  if (!reflectionRequiredForRoute(pptRoute)) throw new Error('selftest failed: PPT route should require reflection');
3014
3077
  const pptMission = await createMission(tmp, { mode: 'ppt', prompt: '$PPT 투자자용 피치덱 만들어줘' });
@@ -3028,10 +3091,20 @@ async function selftest() {
3028
3091
  if (!pptAudienceStrategy?.source_answers?.PRESENTATION_STP_STRATEGY || pptAudienceStrategy.painpoint_solution_map.length !== 3) throw new Error('selftest failed: PPT audience strategy was not materialized from sealed answers');
3029
3092
  const pptGate = await readJson(path.join(pptMission.dir, PPT_GATE_ARTIFACT));
3030
3093
  if (pptGate.passed !== false || pptGate.audience_strategy_sealed !== true || pptGate.painpoint_count !== 3) throw new Error('selftest failed: PPT gate did not initialize with sealed audience strategy');
3094
+ await writeJsonAtomic(path.join(pptMission.dir, PPT_FACT_LEDGER_ARTIFACT), {
3095
+ schema_version: 1,
3096
+ web_research_performed: true,
3097
+ external_research_required: true,
3098
+ sources: [{ id: 'web-source-selftest', type: 'verified_web_source', url: 'https://example.com/ppt-source', support_status: 'verified' }],
3099
+ claims: [{ id: 'claim-selftest-market-risk', text: '시장 차별성과 실행 리스크는 외부 근거가 필요한 주장으로 분리된다.', source_ids: ['web-source-selftest'], support_status: 'supported', criticality: 'high', slide_refs: [2] }],
3100
+ unsupported_critical_claims: [],
3101
+ unsupported_critical_claims_count: 0,
3102
+ passed: true
3103
+ });
3031
3104
  const pptBuildResult = await runProcess(process.execPath, [hookBin, 'ppt', 'build', pptMission.id, '--json'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
3032
3105
  if (pptBuildResult.code !== 0) throw new Error(`selftest failed: sks ppt build failed: ${pptBuildResult.stderr || pptBuildResult.stdout}`);
3033
3106
  const pptBuild = JSON.parse(pptBuildResult.stdout);
3034
- if (!pptBuild.ok || !pptBuild.gate?.passed || !pptBuild.gate?.parallel_build_recorded || !pptBuild.gate?.html_artifact_created || !pptBuild.gate?.source_html_preserved || !pptBuild.gate?.pdf_exported_or_explicitly_deferred || !pptBuild.gate?.render_qa_recorded || !pptBuild.gate?.temp_cleanup_recorded) throw new Error('selftest failed: PPT build did not pass artifact gate');
3107
+ if (!pptBuild.ok || !pptBuild.gate?.passed || !pptBuild.gate?.fact_ledger_created || !pptBuild.gate?.unsupported_critical_claims_zero || !pptBuild.gate?.image_asset_ledger_created || !pptBuild.gate?.image_asset_policy_satisfied || !pptBuild.gate?.review_policy_created || !pptBuild.gate?.review_ledger_created || !pptBuild.gate?.bounded_iteration_complete || !pptBuild.gate?.critical_review_issues_zero || !pptBuild.gate?.parallel_build_recorded || !pptBuild.gate?.html_artifact_created || !pptBuild.gate?.source_html_preserved || !pptBuild.gate?.pdf_exported_or_explicitly_deferred || !pptBuild.gate?.render_qa_recorded || !pptBuild.gate?.temp_cleanup_recorded) throw new Error('selftest failed: PPT build did not pass artifact gate');
3035
3108
  if (!PPT_HTML_ARTIFACT.startsWith(`${PPT_SOURCE_HTML_DIR}/`)) throw new Error('selftest failed: PPT HTML source must be stored in source-html folder');
3036
3109
  const pptHtml = await safeReadText(path.join(pptMission.dir, PPT_HTML_ARTIFACT));
3037
3110
  if (!pptHtml.includes('<html') || pptHtml.includes('gradient')) throw new Error('selftest failed: PPT HTML artifact missing or over-designed');
@@ -3042,8 +3115,19 @@ async function selftest() {
3042
3115
  const audienceScript = pptHtml.match(/id="ppt-audience-strategy">([^<]+)<\/script>/);
3043
3116
  if (!audienceScript) throw new Error('selftest failed: PPT HTML missing audience strategy script data');
3044
3117
  JSON.parse(audienceScript[1]);
3118
+ if (!pptHtml.includes('id="ppt-fact-ledger"') || !pptHtml.includes('id="ppt-image-asset-ledger"') || !pptHtml.includes('id="ppt-review-policy"')) throw new Error('selftest failed: PPT HTML missing fact/image/review embedded ledgers');
3045
3119
  const pptPdfBytes = await fsp.readFile(path.join(pptMission.dir, PPT_PDF_ARTIFACT));
3046
3120
  if (pptPdfBytes.subarray(0, 5).toString('utf8') !== '%PDF-') throw new Error('selftest failed: PPT PDF artifact does not have a PDF header');
3121
+ const pptFactLedger = await readJson(path.join(pptMission.dir, PPT_FACT_LEDGER_ARTIFACT));
3122
+ if (!pptFactLedger.passed || pptFactLedger.unsupported_critical_claims_count !== 0 || !Array.isArray(pptFactLedger.claims)) throw new Error('selftest failed: PPT fact ledger did not pass unsupported-claim gate');
3123
+ const pptImageAssetLedger = await readJson(path.join(pptMission.dir, PPT_IMAGE_ASSET_LEDGER_ARTIFACT));
3124
+ if (!pptImageAssetLedger.passed || pptImageAssetLedger.required !== false || pptImageAssetLedger.planned_count !== 0 || pptImageAssetLedger.provider?.model !== 'gpt-image-2') throw new Error('selftest failed: PPT image asset ledger did not pass optional no-cost state');
3125
+ const pptReviewPolicy = await readJson(path.join(pptMission.dir, PPT_REVIEW_POLICY_ARTIFACT));
3126
+ if (pptReviewPolicy.visual_review?.model !== 'gpt-image-2' || pptReviewPolicy.max_full_deck_passes !== 2 || pptReviewPolicy.max_slide_retries !== 2 || pptReviewPolicy.score_threshold < 0.88) throw new Error('selftest failed: PPT review policy missing bounded gpt-image-2 loop settings');
3127
+ const pptReviewLedger = await readJson(path.join(pptMission.dir, PPT_REVIEW_LEDGER_ARTIFACT));
3128
+ if (!pptReviewLedger.passed || !pptReviewLedger.p0_p1_zero || pptReviewLedger.image_review_status !== 'not_required_or_not_available') throw new Error('selftest failed: PPT review ledger did not pass deterministic no-blocker state');
3129
+ const pptIterationReport = await readJson(path.join(pptMission.dir, PPT_ITERATION_REPORT_ARTIFACT));
3130
+ if (!pptIterationReport.passed || pptIterationReport.loop_policy?.max_full_deck_passes !== 2 || pptIterationReport.stop_reason !== 'score_threshold_met_and_no_p0_p1_issues') throw new Error('selftest failed: PPT iteration report did not record bounded pass termination');
3047
3131
  const pptRenderReport = await readJson(path.join(pptMission.dir, PPT_RENDER_REPORT_ARTIFACT));
3048
3132
  if (!pptRenderReport.passed || !pptRenderReport.design_policy_checks.every((check) => check.passed)) throw new Error('selftest failed: PPT render report did not pass design policy checks');
3049
3133
  const pptParallelReport = await readJson(path.join(pptMission.dir, PPT_PARALLEL_REPORT_ARTIFACT));
@@ -3054,6 +3138,31 @@ async function selftest() {
3054
3138
  if (await exists(path.join(pptMission.dir, 'artifact.html'))) throw new Error('selftest failed: legacy root PPT HTML should not remain after source-html preservation');
3055
3139
  const pptStatusResult = await runProcess(process.execPath, [hookBin, 'ppt', 'status', pptMission.id, '--json'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
3056
3140
  if (pptStatusResult.code !== 0 || !JSON.parse(pptStatusResult.stdout).ok) throw new Error('selftest failed: sks ppt status did not report the built gate');
3141
+ const requiredImagePptMission = await createMission(tmp, { mode: 'ppt', prompt: '$PPT 이미지 리소스 포함 투자자용 피치덱 만들어줘' });
3142
+ await writeQuestions(requiredImagePptMission.dir, pptSchema);
3143
+ await writeJsonAtomic(path.join(requiredImagePptMission.dir, 'answers.json'), {
3144
+ ...pptAnswers,
3145
+ PRESENTATION_IMAGE_ASSETS_REQUIRED: 'yes',
3146
+ PRESENTATION_IMAGE_ASSET_REQUESTS: ['한국 B2B SaaS 운영 효율을 상징하는 첫 장용 히어로 이미지']
3147
+ });
3148
+ const requiredImageSeal = await sealContract(requiredImagePptMission.dir, requiredImagePptMission.mission);
3149
+ if (!requiredImageSeal.ok) throw new Error('selftest failed: PPT required-image answers rejected');
3150
+ await materializeAfterPipelineAnswer(tmp, requiredImagePptMission.id, requiredImagePptMission.dir, requiredImagePptMission.mission, pptRoute, { route: 'PPT', command: '$PPT', mode: 'PPT', task: requiredImagePptMission.mission.prompt, context7_required: false }, requiredImageSeal.contract);
3151
+ await writeJsonAtomic(path.join(requiredImagePptMission.dir, PPT_FACT_LEDGER_ARTIFACT), {
3152
+ schema_version: 1,
3153
+ web_research_performed: true,
3154
+ external_research_required: true,
3155
+ sources: [{ id: 'web-source-required-image-selftest', type: 'verified_web_source', url: 'https://example.com/ppt-source-image', support_status: 'verified' }],
3156
+ claims: [{ id: 'claim-required-image-selftest', text: '이미지 리소스 요구사항은 사실 검증과 별도 게이트로 차단되어야 한다.', source_ids: ['web-source-required-image-selftest'], support_status: 'supported', criticality: 'high', slide_refs: [1] }],
3157
+ unsupported_critical_claims: [],
3158
+ unsupported_critical_claims_count: 0,
3159
+ passed: true
3160
+ });
3161
+ const requiredImageBuildResult = await runProcess(process.execPath, [hookBin, 'ppt', 'build', requiredImagePptMission.id, '--json'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1', OPENAI_API_KEY: '' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
3162
+ if (requiredImageBuildResult.code !== 0) throw new Error(`selftest failed: required-image PPT build command failed: ${requiredImageBuildResult.stderr || requiredImageBuildResult.stdout}`);
3163
+ const requiredImageBuild = JSON.parse(requiredImageBuildResult.stdout);
3164
+ const requiredImageLedger = await readJson(path.join(requiredImagePptMission.dir, PPT_IMAGE_ASSET_LEDGER_ARTIFACT));
3165
+ if (requiredImageBuild.ok || requiredImageBuild.gate?.passed || !requiredImageBuild.gate?.image_asset_ledger_created || requiredImageBuild.gate?.image_asset_policy_satisfied !== false || !requiredImageLedger.required || requiredImageLedger.passed || !requiredImageLedger.blockers?.includes('missing_OPENAI_API_KEY_for_required_gpt_image_2_assets') || requiredImageLedger.generated_count !== 0) throw new Error('selftest failed: required PPT image assets were not blocked without real gpt-image-2 credentials');
3057
3166
  const installUxSchema = buildQuestionSchema('SKS first install/bootstrap UX and Context7 MCP setup improvement');
3058
3167
  const installUxSlotIds = installUxSchema.slots.map((s) => s.id);
3059
3168
  if (installUxSchema.domain_hints.includes('uiux') || installUxSlotIds.includes('VISUAL_REGRESSION_REQUIRED')) throw new Error('selftest failed: CLI UX install prompt should not ask visual UI questions');