sneakoscope 0.6.30 → 0.6.33

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
@@ -2,9 +2,11 @@
2
2
 
3
3
  Zero-runtime-dependency Node.js harness for OpenAI Codex CLI and Codex App. `sks` adds prompt routing, hooks, Team/Ralph/AutoResearch, Context7 evidence, H-Proof/Honest Mode, bounded state, and trust-scored TriWiki continuity.
4
4
 
5
+ Its core selling point is repetition resistance: when Codex hits a release trap, stale command surface, missing generated skill, blocked stop gate, or any other recurring mistake, SKS records the fix as ranked TriWiki context. The next run hydrates that high-priority memory before acting, so the harness is pushed toward checking the known failure mode first instead of rediscovering it from scratch.
6
+
5
7
  ## AI Answer Snapshot
6
8
 
7
- Package: `sneakoscope`. CLI: `sks` with `sneakoscope` alias. Install Codex CLI separately or set `SKS_CODEX_BIN`. Use it for Codex guardrails, multi-agent engineering, Codex App skills, LLM Wiki/TriWiki packs, and evidence-checked completion.
9
+ Package: `sneakoscope`. CLI: `sks` with `sneakoscope` alias. Install Codex CLI separately or set `SKS_CODEX_BIN`. Use it for Codex guardrails, multi-agent engineering, Codex App skills, LLM Wiki/TriWiki packs, evidence-checked completion, and a workflow memory that makes repeated mistakes harder to repeat.
8
10
 
9
11
  ```bash
10
12
  npm i -g sneakoscope
@@ -21,6 +23,7 @@ sks quickstart|codex-app|dollar-commands
21
23
  sks selftest --mock
22
24
  sks pipeline status|resume|answer
23
25
  sks team "task" executor:5 reviewer:2 user:1
26
+ sks qa-loop prepare|answer|run|status
24
27
  sks team log|tail|watch|status|event latest
25
28
  sks ralph prepare|answer|run
26
29
  sks context7 check|tools|resolve|docs|evidence
@@ -28,7 +31,7 @@ sks wiki refresh|pack|prune|validate
28
31
  sks guard check; sks eval run|compare; sks gx init|render|validate|drift|snapshot; sks gc --dry-run
29
32
  ```
30
33
 
31
- Prompt routes: `$DFix`, `$Answer`, `$SKS`, `$Team`, `$Ralph`, `$Research`, `$AutoResearch`, `$DB`, `$GX`, `$Wiki`, `$Help`.
34
+ Prompt routes: `$DFix`, `$Answer`, `$SKS`, `$Team`, `$QALoop`, `$Ralph`, `$Research`, `$AutoResearch`, `$DB`, `$GX`, `$Wiki`, `$Help`.
32
35
 
33
36
  ## Codex App
34
37
 
@@ -37,3 +40,5 @@ Run `sks setup` once. SKS creates hooks/skills plus `.sneakoscope/` mission/wiki
37
40
  ## TriWiki
38
41
 
39
42
  TriWiki is the LLM Wiki SSOT. It scores claims by trust, relevance, freshness, risk, and token cost. Read `.sneakoscope/wiki/context-pack.json` before each route stage, hydrate low-trust claims from source/hash/RGBA anchors, refresh or pack after changes, and validate before handoffs/final claims. `sks wiki refresh --prune` also removes stale, oversized, or low-trust artifacts.
43
+
44
+ Repeated failures are promoted, not buried. If an issue recurs, SKS can store it under `.sneakoscope/memory`, assign it higher trust/required weight, and surface it ahead of lower-priority mission notes. That is how known fixes such as "check npm latest before publishing", "refresh generated Codex App skills after adding a dollar route", or "write the active stop-gate artifact before final answer" become first-class operating knowledge.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "Sneakoscope Codex",
4
- "version": "0.6.30",
4
+ "version": "0.6.33",
5
5
  "description": "Sneakoscope Codex: update-aware, database-safe Codex CLI harness with multi-agent Team orchestration, Ralph no-question execution, autoresearch-style loops, and H-Proof gates.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
package/src/cli/main.mjs CHANGED
@@ -8,6 +8,7 @@ import { getCodexInfo, runCodexExec } from '../core/codex-adapter.mjs';
8
8
  import { createMission, loadMission, findLatestMission, missionDir, setCurrent, stateFile } from '../core/mission.mjs';
9
9
  import { buildQuestionSchema, writeQuestions } from '../core/questions.mjs';
10
10
  import { sealContract, validateAnswers } from '../core/decision-contract.mjs';
11
+ import { buildQaLoopQuestionSchema, buildQaLoopPrompt, evaluateQaGate, qaStatus, writeMockQaResult, writeQaLoopArtifacts } from '../core/qa-loop.mjs';
11
12
  import { containsUserQuestion, noQuestionContinuationReason } from '../core/no-question-guard.mjs';
12
13
  import { evaluateDoneGate, defaultDoneGate } from '../core/hproof.mjs';
13
14
  import { emitHook } from '../core/hooks-runtime.mjs';
@@ -54,6 +55,7 @@ export async function main(args) {
54
55
  if (cmd === 'codex-app') return codexAppHelp();
55
56
  if (cmd === 'dollar-commands' || cmd === 'dollars' || cmd === '$') return dollarCommands(tail);
56
57
  if (String(cmd).toLowerCase() === 'dfix') return dfixHelp();
58
+ if (cmd === 'qa-loop' || cmd === 'qaloop') return qaLoop(sub, rest);
57
59
  if (cmd === 'context7') return context7(sub, rest);
58
60
  if (cmd === 'pipeline') return pipeline(sub, rest);
59
61
  if (cmd === 'guard') return guard(sub, rest);
@@ -99,6 +101,10 @@ Usage:
99
101
  sks codex-app
100
102
  sks dollar-commands [--json]
101
103
  sks dfix
104
+ sks qa-loop prepare "target"
105
+ sks qa-loop answer <mission-id|latest> <answers.json>
106
+ sks qa-loop run <mission-id|latest> [--mock] [--max-cycles N]
107
+ sks qa-loop status <mission-id|latest>
102
108
  sks context7 check|setup|tools|resolve|docs|evidence ...
103
109
  sks pipeline status|resume [--json]
104
110
  sks pipeline answer <mission-id|latest> <answers.json>
@@ -639,6 +645,7 @@ async function pipelineAnswer(root, args = []) {
639
645
  const route = ROUTES.find((candidate) => candidate.id === routeContext.route || candidate.command === routeContext.command)
640
646
  || routePrompt(routeContext.command || routeContext.route || '$SKS');
641
647
  await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'pipeline.clarification.contract_sealed', route: route?.id || routeContext.route, hash: result.contract.sealed_hash });
648
+ if (route?.id === 'QALoop') await writeQaLoopArtifacts(dir, mission, result.contract);
642
649
  await setCurrent(root, {
643
650
  mission_id: id,
644
651
  route: route?.id || routeContext.route || 'SKS',
@@ -869,6 +876,7 @@ Useful prompts inside Codex App:
869
876
  $Answer 이 훅은 왜 이렇게 동작해?
870
877
  $SKS show me available workflows
871
878
  $Team agree on the plan, then implement with specialists
879
+ $QALoop run UI and API E2E against local dev
872
880
  $Ralph implement this with mandatory clarification
873
881
  $Research investigate this idea
874
882
  $AutoResearch improve this workflow with experiments.
@@ -934,6 +942,7 @@ Discovery:
934
942
  Common workflows:
935
943
  sks usage install
936
944
  sks usage team
945
+ sks usage qa-loop
937
946
  sks usage ralph
938
947
  sks usage research
939
948
  sks usage db
@@ -1017,6 +1026,33 @@ Generated Codex App support:
1017
1026
  .codex/config.toml enables multi_agent and [agents] limits.
1018
1027
  .codex/agents/*.toml defines analysis_scout, team_consensus, implementation_worker, db_safety_reviewer, and qa_reviewer.
1019
1028
  .agents/skills/team/SKILL.md explains the orchestration protocol.
1029
+ `,
1030
+ 'qa-loop': `QA-Loop Workflow
1031
+
1032
+ Prepare:
1033
+ sks qa-loop prepare "QA this app"
1034
+
1035
+ Answer generated slots:
1036
+ cat .sneakoscope/missions/<MISSION_ID>/questions.md
1037
+ cp .sneakoscope/missions/<MISSION_ID>/required-answers.schema.json answers.json
1038
+ sks qa-loop answer <MISSION_ID> answers.json
1039
+
1040
+ Run:
1041
+ sks qa-loop run <MISSION_ID> --max-cycles 8
1042
+ sks qa-loop status latest
1043
+
1044
+ Inside Codex App:
1045
+ $QALoop run UI and API E2E against local dev
1046
+
1047
+ Safety:
1048
+ UI E2E requires @Computer Use evidence or it must be reported as not verified.
1049
+ Login credentials are test-only, runtime-only, and must not be saved to artifacts or TriWiki.
1050
+ Non-local/deployed targets are read-only smoke by default; destructive removal scenarios are never allowed there.
1051
+
1052
+ Artifacts:
1053
+ qa-ledger.json
1054
+ qa-report.md
1055
+ qa-gate.json
1020
1056
  `,
1021
1057
  setup: `Setup Repair
1022
1058
 
@@ -1090,6 +1126,7 @@ Use inside Codex App:
1090
1126
  $DFix 내용을 영어로 바꿔줘
1091
1127
  $SKS show me available workflows
1092
1128
  $Team agree on the plan, then implement with specialists
1129
+ $QALoop run UI and API E2E against local dev
1093
1130
  $Ralph implement this with mandatory clarification
1094
1131
  $Research investigate this idea
1095
1132
  $AutoResearch improve this workflow with experiments
@@ -1596,6 +1633,156 @@ async function research(sub, args) {
1596
1633
  process.exitCode = 1;
1597
1634
  }
1598
1635
 
1636
+ async function qaLoop(sub, args) {
1637
+ const known = new Set(['prepare', 'answer', 'run', 'status', 'help', '--help', '-h']);
1638
+ const action = known.has(sub) ? sub : 'prepare';
1639
+ const actionArgs = action === 'prepare' && sub && !known.has(sub) ? [sub, ...args] : args;
1640
+ if (action === 'prepare') return qaLoopPrepare(actionArgs);
1641
+ if (action === 'answer') return qaLoopAnswer(actionArgs);
1642
+ if (action === 'run') return qaLoopRun(actionArgs);
1643
+ if (action === 'status') return qaLoopStatus(actionArgs);
1644
+ console.log(`SKS QA-Loop
1645
+
1646
+ Usage:
1647
+ sks qa-loop prepare "target"
1648
+ sks qa-loop answer <mission-id|latest> <answers.json>
1649
+ sks qa-loop run <mission-id|latest> [--mock] [--max-cycles N]
1650
+ sks qa-loop status <mission-id|latest>
1651
+
1652
+ Prompt route:
1653
+ $QALoop run UI and API E2E against local dev
1654
+ `);
1655
+ }
1656
+
1657
+ function qaRoute() {
1658
+ return ROUTES.find((route) => route.id === 'QALoop') || routePrompt('$QALoop');
1659
+ }
1660
+
1661
+ async function qaLoopPrepare(args) {
1662
+ const root = await projectRoot();
1663
+ if (!(await exists(path.join(root, '.sneakoscope')))) await initProject(root, {});
1664
+ const prompt = promptOf(args);
1665
+ if (!prompt) throw new Error('Missing QA target prompt.');
1666
+ const { id, dir } = await createMission(root, { mode: 'qaloop', prompt });
1667
+ const schema = buildQaLoopQuestionSchema(prompt);
1668
+ const route = qaRoute();
1669
+ await writeQuestions(dir, schema);
1670
+ await writeJsonAtomic(path.join(dir, 'route-context.json'), { route: 'QALoop', command: '$QALoop', mode: 'QALOOP', task: prompt, required_skills: route?.requiredSkills || [], context7_required: false, original_stop_gate: 'qa-gate.json', clarification_gate: true });
1671
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.prepare.questions_created', slots: schema.slots.length });
1672
+ await setCurrent(root, { mission_id: id, route: 'QALoop', route_command: '$QALoop', mode: 'QALOOP', phase: 'QALOOP_CLARIFICATION_AWAITING_ANSWERS', questions_allowed: true, implementation_allowed: false, clarification_required: true, ambiguity_gate_required: true, stop_gate: 'clarification-gate', reasoning_effort: 'high', reasoning_profile: 'sks-logic-high', reasoning_temporary: true });
1673
+ console.log(`QA-Loop mission created: ${id}`);
1674
+ console.log('QA-Loop is locked until all required answers are supplied.');
1675
+ console.log(`Questions: ${path.relative(root, path.join(dir, 'questions.md'))}`);
1676
+ console.log(`Answer schema: ${path.relative(root, path.join(dir, 'required-answers.schema.json'))}`);
1677
+ console.log('\nRequired questions:');
1678
+ console.log(formatRalphQuestionsForCli(schema));
1679
+ }
1680
+
1681
+ async function qaLoopAnswer(args) {
1682
+ const root = await projectRoot();
1683
+ const [missionArg, answerFile] = args;
1684
+ const id = await resolveMissionId(root, missionArg);
1685
+ if (!id || !answerFile) throw new Error('Usage: sks qa-loop answer <mission-id|latest> <answers.json>');
1686
+ const { dir, mission } = await loadMission(root, id);
1687
+ const answers = await readJson(path.resolve(answerFile));
1688
+ await writeJsonAtomic(path.join(dir, 'answers.json'), answers);
1689
+ const result = await sealContract(dir, mission);
1690
+ if (!result.ok) {
1691
+ console.error('Answer validation failed. QA-Loop remains locked.');
1692
+ console.error(JSON.stringify(result.validation, null, 2));
1693
+ process.exitCode = 2;
1694
+ return;
1695
+ }
1696
+ const artifactResult = await writeQaLoopArtifacts(dir, mission, result.contract);
1697
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.contract.sealed', hash: result.contract.sealed_hash, checklist_count: artifactResult.checklist_count });
1698
+ await setCurrent(root, { mission_id: id, route: 'QALoop', route_command: '$QALoop', mode: 'QALOOP', phase: 'QALOOP_CLARIFICATION_CONTRACT_SEALED', questions_allowed: false, implementation_allowed: true, clarification_required: false, clarification_passed: true, ambiguity_gate_passed: true, stop_gate: 'qa-gate.json', reasoning_effort: 'high', reasoning_profile: 'sks-logic-high', reasoning_temporary: true });
1699
+ console.log(`QA-Loop contract sealed for ${id}`);
1700
+ console.log(`Hash: ${result.contract.sealed_hash}`);
1701
+ console.log(`Checklist: ${artifactResult.checklist_count} cases`);
1702
+ console.log(`Report: ${path.relative(root, path.join(dir, 'qa-report.md'))}`);
1703
+ console.log(`Run: sks qa-loop run ${id} --max-cycles ${answers.MAX_QA_CYCLES || 8}`);
1704
+ }
1705
+
1706
+ async function qaLoopRun(args) {
1707
+ const root = await projectRoot();
1708
+ const id = await resolveMissionId(root, args[0]);
1709
+ if (!id) throw new Error('Usage: sks qa-loop run <mission-id|latest> [--mock] [--max-cycles N]');
1710
+ const { dir, mission } = await loadMission(root, id);
1711
+ const contractPath = path.join(dir, 'decision-contract.json');
1712
+ if (!(await exists(contractPath))) throw new Error('QA-Loop cannot run: decision-contract.json is missing.');
1713
+ const contract = await readJson(contractPath);
1714
+ if (!(await exists(path.join(dir, 'qa-ledger.json')))) await writeQaLoopArtifacts(dir, mission, contract);
1715
+ const safetyScan = await scanDbSafety(root);
1716
+ if (!safetyScan.ok) {
1717
+ console.error('QA-Loop cannot run: SKS safety scan found unsafe project data-tool configuration.');
1718
+ console.error(JSON.stringify(safetyScan.findings, null, 2));
1719
+ process.exitCode = 2;
1720
+ return;
1721
+ }
1722
+ const fallbackCycles = Number.parseInt(contract.answers?.MAX_QA_CYCLES, 10) || 8;
1723
+ const maxCycles = readMaxCycles(args, fallbackCycles);
1724
+ const mock = flag(args, '--mock');
1725
+ await setCurrent(root, { mission_id: id, route: 'QALoop', route_command: '$QALoop', mode: 'QALOOP', phase: 'QALOOP_RUNNING_NO_QUESTIONS', questions_allowed: false, stop_gate: 'qa-gate.json', reasoning_effort: 'high', reasoning_profile: 'sks-logic-high', reasoning_temporary: true });
1726
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.run.started', maxCycles, mock });
1727
+ if (mock) {
1728
+ const gate = await writeMockQaResult(dir, mission, contract);
1729
+ await setCurrent(root, { mission_id: id, mode: 'QALOOP', phase: gate.passed ? 'QALOOP_DONE' : 'QALOOP_PAUSED', questions_allowed: true });
1730
+ console.log(`Mock QA-Loop done: ${id}`);
1731
+ console.log(`Gate: ${gate.passed ? 'passed' : 'blocked'}`);
1732
+ return;
1733
+ }
1734
+ const codex = await getCodexInfo();
1735
+ if (!codex.bin) {
1736
+ console.error('Codex CLI not found. Running mock QA-Loop instead.');
1737
+ const gate = await writeMockQaResult(dir, mission, contract);
1738
+ await setCurrent(root, { mission_id: id, mode: 'QALOOP', phase: gate.passed ? 'QALOOP_DONE' : 'QALOOP_PAUSED', questions_allowed: true });
1739
+ console.log(`Mock QA-Loop done: ${id}`);
1740
+ return;
1741
+ }
1742
+ let last = '';
1743
+ for (let cycle = 1; cycle <= maxCycles; cycle++) {
1744
+ const cycleDir = path.join(dir, 'qa-loop', `cycle-${cycle}`);
1745
+ const outputFile = path.join(cycleDir, 'final.md');
1746
+ const prompt = buildQaLoopPrompt({ id, mission, contract, cycle, previous: last });
1747
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.cycle.start', cycle });
1748
+ const result = await runCodexExec({ root, prompt, outputFile, json: true, profile: 'sks-logic-high', logDir: cycleDir });
1749
+ await writeJsonAtomic(path.join(cycleDir, 'process.json'), { code: result.code, stdout_tail: result.stdout, stderr_tail: result.stderr, stdout_bytes: result.stdoutBytes, stderr_bytes: result.stderrBytes, truncated: result.truncated, timed_out: result.timedOut });
1750
+ last = await safeReadText(outputFile, result.stdout || result.stderr || '');
1751
+ if (containsUserQuestion(last)) {
1752
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.guard.question_blocked', cycle });
1753
+ last = `${last}\n\n${noQuestionContinuationReason()}`;
1754
+ continue;
1755
+ }
1756
+ const gate = await evaluateQaGate(dir);
1757
+ if (gate.passed) {
1758
+ await setCurrent(root, { mission_id: id, mode: 'QALOOP', phase: 'QALOOP_DONE', questions_allowed: true });
1759
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.done', cycle });
1760
+ console.log(`QA-Loop done: ${id}`);
1761
+ return;
1762
+ }
1763
+ await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'qaloop.cycle.continue', cycle, reasons: gate.reasons });
1764
+ }
1765
+ await setCurrent(root, { mission_id: id, mode: 'QALOOP', phase: 'QALOOP_PAUSED_MAX_CYCLES', questions_allowed: true });
1766
+ console.log(`QA-Loop paused after max cycles: ${id}`);
1767
+ }
1768
+
1769
+ async function qaLoopStatus(args) {
1770
+ const root = await projectRoot();
1771
+ const id = await resolveMissionId(root, args[0]);
1772
+ if (!id) throw new Error('Usage: sks qa-loop status <mission-id|latest>');
1773
+ const { dir, mission } = await loadMission(root, id);
1774
+ const state = await readJson(stateFile(root), {});
1775
+ const status = await qaStatus(dir);
1776
+ if (flag(args, '--json')) return console.log(JSON.stringify({ mission, state, qa: status }, null, 2));
1777
+ console.log('SKS QA-Loop Status\n');
1778
+ console.log(`Mission: ${id}`);
1779
+ console.log(`Phase: ${state.phase || mission.phase}`);
1780
+ console.log(`Checklist: ${status.checklist_count ?? 'none'}`);
1781
+ console.log(`Report: ${status.report_written ? 'present' : 'missing'}`);
1782
+ console.log(`Gate: ${status.gate?.passed ? 'passed' : 'not passed'}`);
1783
+ if (status.gate?.reasons?.length) console.log(`Reasons: ${status.gate.reasons.join(', ')}`);
1784
+ }
1785
+
1599
1786
  async function researchPrepare(args) {
1600
1787
  const root = await projectRoot();
1601
1788
  if (!(await exists(path.join(root, '.sneakoscope')))) await initProject(root, {});
@@ -1997,10 +2184,12 @@ async function selftest() {
1997
2184
  if (new Set(DOLLAR_COMMANDS.map((c) => c.command)).size !== DOLLAR_COMMANDS.length) throw new Error('selftest failed: duplicate dollar commands');
1998
2185
  if (!DOLLAR_COMMAND_ALIASES.some((alias) => alias.canonical === '$Team' && alias.app_skill === '$agent-team')) throw new Error('selftest failed: $Team fallback picker alias missing');
1999
2186
  if (!DOLLAR_COMMAND_ALIASES.some((alias) => alias.canonical === '$Wiki' && alias.app_skill === '$wiki-refresh')) throw new Error('selftest failed: $WikiRefresh picker alias missing');
2187
+ if (!DOLLAR_COMMAND_ALIASES.some((alias) => alias.canonical === '$QALoop' && alias.app_skill === '$qa-loop')) throw new Error('selftest failed: $QALoop picker alias missing');
2000
2188
  if (routePrompt('$agent-team run specialists')?.id !== 'Team') throw new Error('selftest failed: $agent-team did not route to Team');
2189
+ if (routePrompt('$QALoop run UI E2E')?.id !== 'QALoop' || routePrompt('$QA-Loop deployed smoke')?.id !== 'QALoop') throw new Error('selftest failed: $QALoop did not route to QA-Loop');
2001
2190
  if (routePrompt('$WikiRefresh 갱신')?.id !== 'Wiki') throw new Error('selftest failed: $WikiRefresh did not route to Wiki');
2002
2191
  if (routePrompt('위키 갱신해줘')?.id !== 'Wiki') throw new Error('selftest failed: wiki refresh text did not route to Wiki');
2003
- if (!COMMAND_CATALOG.some((c) => c.name === 'context7') || !COMMAND_CATALOG.some((c) => c.name === 'pipeline')) throw new Error('selftest failed: context7/pipeline commands missing from catalog');
2192
+ if (!COMMAND_CATALOG.some((c) => c.name === 'context7') || !COMMAND_CATALOG.some((c) => c.name === 'pipeline') || !COMMAND_CATALOG.some((c) => c.name === 'qa-loop')) throw new Error('selftest failed: context7/pipeline/qa-loop commands missing from catalog');
2004
2193
  const registryDollarCommands = DOLLAR_COMMANDS.map((c) => c.command);
2005
2194
  const manifest = await readJson(path.join(tmp, '.sneakoscope', 'manifest.json'));
2006
2195
  const policy = await readJson(path.join(tmp, '.sneakoscope', 'policy.json'));
@@ -2068,6 +2257,29 @@ async function selftest() {
2068
2257
  const answeredTeamState = await readJson(stateFile(hookTeamTmp), {});
2069
2258
  if (answeredTeamState.phase !== 'TEAM_CLARIFICATION_CONTRACT_SEALED' || !answeredTeamState.ambiguity_gate_passed || answeredTeamState.implementation_allowed !== true) throw new Error('selftest failed: pipeline answer did not pass Team ambiguity gate');
2070
2259
  if (!(await exists(path.join(missionDir(hookTeamTmp, hookTeamState.mission_id), 'decision-contract.json')))) throw new Error('selftest failed: pipeline answer did not seal decision contract');
2260
+ const hookQaTmp = tmpdir();
2261
+ await initProject(hookQaTmp, {});
2262
+ const hookQaPayload = JSON.stringify({ cwd: hookQaTmp, prompt: '$QALoop run UI and API E2E against local dev' });
2263
+ const hookQaResult = await runProcess(process.execPath, [hookBin, 'hook', 'user-prompt-submit'], { cwd: hookQaTmp, input: hookQaPayload, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 256 * 1024 });
2264
+ if (hookQaResult.code !== 0) throw new Error(`selftest failed: $QALoop hook exited ${hookQaResult.code}: ${hookQaResult.stderr}`);
2265
+ const hookQaJson = JSON.parse(hookQaResult.stdout);
2266
+ const hookQaContext = hookQaJson.hookSpecificOutput?.additionalContext || '';
2267
+ if (!hookQaContext.includes('MANDATORY ambiguity-removal gate activated') || !hookQaContext.includes('QA_SCOPE') || !hookQaContext.includes('UI_COMPUTER_USE_ACK')) throw new Error('selftest failed: $QALoop hook did not provide QA-specific questions');
2268
+ const hookQaState = await readJson(stateFile(hookQaTmp), {});
2269
+ if (hookQaState.phase !== 'QALOOP_CLARIFICATION_AWAITING_ANSWERS' || hookQaState.implementation_allowed !== false) throw new Error('selftest failed: $QALoop hook did not lock execution behind ambiguity gate');
2270
+ const hookQaSchema = await readJson(path.join(missionDir(hookQaTmp, hookQaState.mission_id), 'required-answers.schema.json'));
2271
+ const hookQaAnswers = {};
2272
+ for (const s of hookQaSchema.slots) hookQaAnswers[s.id] = s.options ? (s.type === 'array' ? [s.options[0]] : s.options[0]) : (s.type.includes('array') ? ['selftest'] : 'selftest');
2273
+ const hookQaAnswersPath = path.join(hookQaTmp, 'qa-answers.json');
2274
+ await writeJsonAtomic(hookQaAnswersPath, hookQaAnswers);
2275
+ const qaAnswerResult = await runProcess(process.execPath, [hookBin, 'pipeline', 'answer', 'latest', hookQaAnswersPath], { cwd: hookQaTmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2276
+ if (qaAnswerResult.code !== 0) throw new Error(`selftest failed: QA pipeline answer exited ${qaAnswerResult.code}: ${qaAnswerResult.stderr}`);
2277
+ const qaMissionDir = missionDir(hookQaTmp, hookQaState.mission_id);
2278
+ if (!(await exists(path.join(qaMissionDir, 'qa-report.md'))) || !(await exists(path.join(qaMissionDir, 'qa-ledger.json'))) || !(await exists(path.join(qaMissionDir, 'qa-gate.json')))) throw new Error('selftest failed: QA artifacts missing after answer');
2279
+ const qaRunResult = await runProcess(process.execPath, [hookBin, 'qa-loop', 'run', 'latest', '--mock'], { cwd: hookQaTmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2280
+ if (qaRunResult.code !== 0) throw new Error(`selftest failed: qa-loop mock run exited ${qaRunResult.code}: ${qaRunResult.stderr}`);
2281
+ const qaGate = await readJson(path.join(qaMissionDir, 'qa-gate.evaluated.json'));
2282
+ if (!qaGate.passed) throw new Error('selftest failed: qa-loop mock gate did not pass');
2071
2283
  const hookDfixTmp = tmpdir();
2072
2284
  await initProject(hookDfixTmp, {});
2073
2285
  const hookDfixPayload = JSON.stringify({ cwd: hookDfixTmp, prompt: '$DFix 버튼 라벨 바꿔줘' });
@@ -2241,6 +2453,7 @@ async function selftest() {
2241
2453
  if (!evalReport.candidate.wiki?.valid) throw new Error('selftest failed: wiki coordinate index invalid in eval');
2242
2454
  const coord = rgbaToWikiCoord({ r: 12, g: 34, b: 56, a: 255 });
2243
2455
  if (coord.schema !== 'sks.wiki-coordinate.v1' || coord.xyzw.length !== 4) throw new Error('selftest failed: RGBA wiki coordinate conversion');
2456
+ await writeTextAtomic(path.join(tmp, '.sneakoscope', 'memory', 'q2_facts', 'selftest.md'), '- claim: Selftest memory claim must be selected before lower-weight mission notes. | id: selftest-memory-priority | source: src/cli/main.mjs | risk: high | status: supported | evidence_count: 3 | required_weight: 1.0 | trust_score: 0.9\n');
2244
2457
  const wikiPack = contextCapsule({
2245
2458
  mission: { id: 'selftest-wiki', coord: { rgba: { r: 48, g: 132, b: 212, a: 240 } } },
2246
2459
  role: 'verifier',
@@ -2255,6 +2468,7 @@ async function selftest() {
2255
2468
  if (!(wikiPack.wiki.anchors || wikiPack.wiki.a || []).some((anchor) => Array.isArray(anchor) ? Number.isFinite(Number(anchor[9])) : Number.isFinite(Number(anchor.trust_score)))) throw new Error('selftest failed: wiki anchor trust score missing');
2256
2469
  if (!(wikiPack.wiki.anchors || wikiPack.wiki.a || []).some((anchor) => (Array.isArray(anchor) ? anchor[0] : anchor.id) === 'wiki-trig')) throw new Error('selftest failed: wiki trig anchor missing');
2257
2470
  if (!(wikiPack.wiki.anchors || wikiPack.wiki.a || []).some((anchor) => String(Array.isArray(anchor) ? anchor[0] : anchor.id).startsWith('team-analysis-'))) throw new Error('selftest failed: team analysis claim missing from TriWiki pack');
2471
+ if (wikiPack.claims?.[0]?.id !== 'selftest-memory-priority') throw new Error('selftest failed: memory required_weight did not take priority in TriWiki pack');
2258
2472
  const dryRunPack = await writeWikiContextPack(tmp, ['--max-anchors', '4'], { dryRun: true });
2259
2473
  if (await exists(dryRunPack.file)) throw new Error('selftest failed: wiki refresh dry-run wrote context pack');
2260
2474
  await ensureDir(path.dirname(dryRunPack.file));
@@ -2498,10 +2712,136 @@ async function projectWikiClaims(root) {
2498
2712
  evidence_count: await exists(path.join(root, file)) ? 1 : 0
2499
2713
  });
2500
2714
  }
2715
+ out.push(...(await memoryWikiClaims(root)));
2501
2716
  out.push(...(await teamAnalysisWikiClaims(root)));
2502
2717
  return out;
2503
2718
  }
2504
2719
 
2720
+ async function memoryWikiClaims(root) {
2721
+ const base = path.join(root, '.sneakoscope', 'memory');
2722
+ const files = await listMemoryClaimFiles(base);
2723
+ const claims = [];
2724
+ for (const file of files.slice(0, 80)) {
2725
+ const relFile = path.relative(root, file);
2726
+ let text = '';
2727
+ try {
2728
+ text = await fsp.readFile(file, 'utf8');
2729
+ } catch {
2730
+ continue;
2731
+ }
2732
+ if (!text.trim()) continue;
2733
+ const rows = parseMemoryClaimRows(text, relFile).slice(0, 24);
2734
+ let index = 0;
2735
+ for (const row of rows) {
2736
+ const source = row.source || relFile;
2737
+ const sourceExists = source && (await exists(path.join(root, source)));
2738
+ index += 1;
2739
+ claims.push({
2740
+ id: row.id || `memory-${slugifyClaimId(relFile)}-${index}`,
2741
+ text: row.text,
2742
+ authority: row.authority || 'wiki',
2743
+ risk: row.risk || 'high',
2744
+ status: row.status || (sourceExists || source === relFile ? 'supported' : 'unknown'),
2745
+ freshness: row.freshness || 'fresh',
2746
+ source,
2747
+ file: source,
2748
+ evidence_count: row.evidence_count ?? (sourceExists ? 2 : 1),
2749
+ required_weight: row.required_weight ?? 0.85,
2750
+ trust_score: row.trust_score
2751
+ });
2752
+ }
2753
+ }
2754
+ return claims;
2755
+ }
2756
+
2757
+ async function listMemoryClaimFiles(base) {
2758
+ const out = [];
2759
+ async function walk(dir, depth = 0) {
2760
+ if (depth > 3) return;
2761
+ let entries = [];
2762
+ try {
2763
+ entries = await fsp.readdir(dir, { withFileTypes: true });
2764
+ } catch {
2765
+ return;
2766
+ }
2767
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
2768
+ const p = path.join(dir, entry.name);
2769
+ if (entry.isDirectory()) await walk(p, depth + 1);
2770
+ else if (/\.(md|txt|json)$/i.test(entry.name)) out.push(p);
2771
+ }
2772
+ }
2773
+ await walk(base);
2774
+ return out;
2775
+ }
2776
+
2777
+ function parseMemoryClaimRows(text, relFile) {
2778
+ if (/\.json$/i.test(relFile)) {
2779
+ try {
2780
+ const parsed = JSON.parse(text);
2781
+ const rows = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.claims) ? parsed.claims : []);
2782
+ return rows.map((row) => normalizeMemoryClaimRow(row, relFile)).filter(Boolean);
2783
+ } catch {
2784
+ return [];
2785
+ }
2786
+ }
2787
+ return text.split(/\r?\n/)
2788
+ .map((line) => line.trim())
2789
+ .filter((line) => line && !line.startsWith('#'))
2790
+ .map((line) => normalizeMemoryClaimRow(line.replace(/^[-*]\s*/, ''), relFile))
2791
+ .filter(Boolean);
2792
+ }
2793
+
2794
+ function normalizeMemoryClaimRow(row, relFile) {
2795
+ if (!row) return null;
2796
+ if (typeof row === 'object') {
2797
+ const text = String(row.text || row.claim || '').trim();
2798
+ if (!text) return null;
2799
+ return {
2800
+ id: row.id ? String(row.id) : null,
2801
+ text: text.slice(0, 320),
2802
+ source: row.source || row.file || relFile,
2803
+ authority: row.authority,
2804
+ risk: row.risk,
2805
+ status: row.status || row.confidence,
2806
+ freshness: row.freshness,
2807
+ evidence_count: Number.isFinite(Number(row.evidence_count)) ? Number(row.evidence_count) : undefined,
2808
+ required_weight: Number.isFinite(Number(row.required_weight)) ? Number(row.required_weight) : undefined,
2809
+ trust_score: Number.isFinite(Number(row.trust_score)) ? Number(row.trust_score) : undefined
2810
+ };
2811
+ }
2812
+ const clean = String(row || '').trim();
2813
+ if (!/\bclaim\s*:/i.test(clean)) return null;
2814
+ const source = extractClaimField(clean, 'source') || extractClaimField(clean, 'file') || extractClaimField(clean, 'path') || relFile;
2815
+ const status = extractClaimField(clean, 'status') || extractClaimField(clean, 'confidence');
2816
+ return {
2817
+ id: extractClaimField(clean, 'id'),
2818
+ text: clean.slice(0, 320),
2819
+ source,
2820
+ authority: extractClaimField(clean, 'authority') || 'wiki',
2821
+ risk: extractClaimField(clean, 'risk') || 'high',
2822
+ status,
2823
+ freshness: extractClaimField(clean, 'freshness') || 'fresh',
2824
+ evidence_count: parseOptionalNumber(extractClaimField(clean, 'evidence_count')),
2825
+ required_weight: parseOptionalNumber(extractClaimField(clean, 'required_weight')),
2826
+ trust_score: parseOptionalNumber(extractClaimField(clean, 'trust_score'))
2827
+ };
2828
+ }
2829
+
2830
+ function extractClaimField(text, key) {
2831
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2832
+ const match = String(text || '').match(new RegExp(`\\b${escaped}\\s*[:=]\\s*\\\`?([^\\\`|,;]+)`, 'i'));
2833
+ return match ? match[1].trim().replace(/[.;)]$/, '') : null;
2834
+ }
2835
+
2836
+ function parseOptionalNumber(value) {
2837
+ const n = Number(value);
2838
+ return Number.isFinite(n) ? n : undefined;
2839
+ }
2840
+
2841
+ function slugifyClaimId(value) {
2842
+ return String(value || 'claim').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'claim';
2843
+ }
2844
+
2505
2845
  async function teamAnalysisWikiClaims(root) {
2506
2846
  const base = path.join(root, '.sneakoscope', 'missions');
2507
2847
  let entries = [];
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { readJson, writeJsonAtomic, nowIso, sha256 } from './fsx.mjs';
3
+ import { validateQaLoopAnswers } from './qa-loop.mjs';
3
4
 
4
5
  function isEmptyAnswer(v) {
5
6
  if (v === undefined || v === null) return true;
@@ -31,6 +32,7 @@ export function validateAnswers(schema, answers) {
31
32
  if (answers.DATABASE_TARGET_ENVIRONMENT === 'production_write') {
32
33
  errors.push({ slot: 'DATABASE_TARGET_ENVIRONMENT', error: 'production_write_target_forbidden' });
33
34
  }
35
+ errors.push(...validateQaLoopAnswers(schema, answers));
34
36
  return { ok: errors.length === 0, errors, resolved, totalRequired: schema.slots.filter((s) => s.required).length };
35
37
  }
36
38
 
@@ -72,6 +74,11 @@ export function buildDecisionContract({ mission, schema, answers }) {
72
74
  supabase_mcp_policy: answers.SUPABASE_MCP_POLICY || 'not_used',
73
75
  db_backup_or_branch_required: answers.DB_BACKUP_OR_BRANCH_REQUIRED || 'yes_for_any_write',
74
76
  db_max_blast_radius: answers.DB_MAX_BLAST_RADIUS || 'no_live_dml',
77
+ qa_loop_scope: answers.QA_SCOPE || null,
78
+ qa_loop_target_environment: answers.TARGET_ENVIRONMENT || null,
79
+ qa_loop_mutation_policy: answers.QA_MUTATION_POLICY || null,
80
+ qa_loop_credentials_saved: false,
81
+ qa_loop_ui_requires_computer_use: Boolean(answers.QA_SCOPE && answers.QA_SCOPE !== 'api_e2e_only'),
75
82
  production_database_writes_allowed: false,
76
83
  mcp_direct_execute_sql_writes_allowed: false,
77
84
  db_reset_allowed: false,
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.6.30';
8
+ export const PACKAGE_VERSION = '0.6.33';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -22,7 +22,8 @@ async function loadState(root) {
22
22
 
23
23
  function isNoQuestionRunning(state) {
24
24
  return (state.mode === 'RALPH' && state.phase === 'RALPH_RUNNING_NO_QUESTIONS')
25
- || (state.mode === 'RESEARCH' && state.phase === 'RESEARCH_RUNNING_NO_QUESTIONS');
25
+ || (state.mode === 'RESEARCH' && state.phase === 'RESEARCH_RUNNING_NO_QUESTIONS')
26
+ || (state.mode === 'QALOOP' && state.phase === 'QALOOP_RUNNING_NO_QUESTIONS');
26
27
  }
27
28
 
28
29
  function extractLastMessage(payload) {
@@ -427,6 +428,7 @@ function visibleHookMessage(name, text = '') {
427
428
  if (body.includes('SKS wiki pipeline active')) return 'SKS: wiki refresh context injected.';
428
429
  if (body.includes('MANDATORY $Ralph')) return 'SKS: Ralph clarification gate prepared in Codex App.';
429
430
  if (body.includes('$Team route prepared') || body.includes('Team route')) return 'SKS: Team route, live transcript, and subagent plan injected.';
431
+ if (body.includes('$QALoop route prepared') || body.includes('QA-Loop')) return 'SKS: QA-Loop route and safety checklist injected.';
430
432
  if (body.includes('Subagent policy: REQUIRED')) return 'SKS: route context injected; subagent execution gate is active.';
431
433
  return 'SKS: skill-first route context injected.';
432
434
  }
package/src/core/init.mjs CHANGED
@@ -650,6 +650,7 @@ The prompt optimization pipeline also runs without a dollar command and infers t
650
650
  - "$Team agree on the best plan, then implement it with a fresh specialist team."
651
651
  - "$DFix change the button text to English."
652
652
  - "$Answer why does this hook behave this way?"
653
+ - "$QALoop run UI and API E2E against local dev."
653
654
  - "$Ralph implement this with mandatory clarification."
654
655
  - "$Research investigate this idea."
655
656
  - "$AutoResearch improve this workflow with experiments."
@@ -700,6 +701,8 @@ async function installSkills(root) {
700
701
  'wikirefresh': `---\nname: wikirefresh\ndescription: Compact Codex App picker alias for $WikiRefresh.\n---\n\nUse exactly like $Wiki.\n`,
701
702
  'team': `---\nname: team\ndescription: Dollar-command route for $Team or $team SKS Team multi-agent orchestration: mandatory ambiguity gate, parallel analysis scouts, TriWiki refresh, role-counted debate, fresh executor development team, live transcript, and final integration.\n---\n\nUse when the user invokes $Team/$team, asks for a team of agents, or asks for parallel specialist implementation.\n\nWorkflow:\n1. Mandatory ambiguity-removal gate: before any scout/debate/implementation work, ask the generated questions, write answers.json, and run sks pipeline answer latest answers.json. Do not spawn analysis scouts until this gate passes.\n2. Create or inspect the Team mission with sks team \"task\" when useful. Role counts use executor:5 reviewer:2 user:1 planner:1. executor:N means exactly N analysis_scout_N agents first, exactly N debate participants next, and then a separate N-person executor development team. --agents N, --sessions N, and --team-size N remain aliases for executor/session budget; --max-agents uses the configured default maximum of 6 sessions/agents; default is executor:3 reviewer:1 user:1 planner:1.\n3. Parallel analysis scouts: spawn the concrete analysis_scout_N roster read-only. Split repo, docs, tests, API, DB-risk, UX-friction, and implementation-surface investigation into independent slices. Each scout returns source-backed findings for team-analysis.md.\n4. TriWiki refresh: parent turns scout findings into TriWiki-ready claims, runs sks wiki pack, then runs sks wiki validate .sneakoscope/wiki/context-pack.json. Do not move to debate or implementation until the pack is refreshed and validated.\n5. Debate bundle: spawn the concrete debate_team roster using the refreshed TriWiki context. Users are intentionally low-context, self-interested, stubborn, and inconvenience-averse. Executor voices are capable developers. Reviewers are strict. Planners force one coherent objective.\n6. Live visibility phase: after every useful scout finding, subagent status/result/handoff, record it with sks team event <mission-id|latest> --agent <name> --phase <phase> --message \"...\" so the user can see the team conversation without tmux.\n7. Consensus phase: synthesize debate into one objective, explicit constraints, acceptance criteria, and disjoint implementation slices.\n8. Close or stop the debate team after their results are captured.\n9. Development bundle: form a fresh development_team where exactly executor_N developers implement slices in parallel with non-overlapping ownership. Tell workers they are not alone in the codebase and must not revert others' edits.\n10. Review phase: validation_team reviewers check correctness, DB safety, missing tests, and evidence; user personas reject outcomes that create practical friction.\n11. Verification phase: run focused tests or justify gaps, update mission artifacts when present, and produce final evidence.\n\nLive files:\n- .sneakoscope/missions/<id>/team-analysis.md stores source-backed scout findings and TriWiki-ready claims.\n- .sneakoscope/missions/<id>/team-live.md is the user-readable live transcript inside Codex App.\n- .sneakoscope/missions/<id>/team-transcript.jsonl is the machine-readable event stream.\n- .sneakoscope/missions/<id>/team-dashboard.json is the current dashboard.\n\nRules:\n- The parent agent remains orchestrator and owns final integration.\n- Before spawning development workers, surface visible SKS route, guard, write-scope, TriWiki, and verification status.\n- Do not delegate the immediate blocking task when the parent can do it faster.\n- Use high reasoning only while the Team route is active, then return to the default/user-selected profile.\n- Never let subagents bypass SKS hooks, DB safety, no-question Ralph rules, or H-Proof completion gates.\n- Destructive database actions remain forbidden.\n`,
702
703
  'agent-team': `---\nname: agent-team\ndescription: Fallback Codex App picker alias for $Team/$team when the app hides or reserves the plain team skill name.\n---\n\nUse exactly like $Team. This skill exists so npm install, sks setup, and sks doctor --fix can repair Codex App discovery when the plain \`team\` skill file exists but does not appear in the picker.\n\nRoute:\n- Treat $agent-team as $Team.\n- Create or inspect the Team mission with sks team \"task\" when useful.\n- Follow the same scout-first Team orchestration protocol: parallel analysis scouts, TriWiki refresh and validation, read-only debate, one sealed objective, fresh executor_N implementation team, strict review, and final evidence.\n- Record live progress with sks team event <mission-id|latest> --agent <name> --phase <phase> --message \"...\".\n\nRules:\n- The parent agent remains orchestrator and owns final integration.\n- Never let subagents bypass SKS hooks, DB safety, no-question Ralph rules, Context7 gates, or H-Proof/Honest Mode.\n- Destructive database actions remain forbidden.\n`,
704
+ 'qaloop': `---\nname: qaloop\ndescription: Dollar-command route for $QALoop QA-Loop UI/API E2E verification with mandatory ambiguity questions, safety gates, Computer Use UI evidence, and a QA report.\n---\n\nUse when the user invokes $QALoop/$qaloop or asks for QA-Loop.\n\nWorkflow:\n1. Start with the mandatory QA ambiguity gate. Ask whether scope is UI E2E, API E2E, both, or all available; ask local dev server versus preview/staging/deployed target; ask allowed data mutation policy; ask whether login is required.\n2. If login is required, ask for test-only credentials as ephemeral runtime input only. Tell the user these credentials are for testing only. Never save usernames, passwords, tokens, cookies, auth state, or screenshots containing secrets in mission files, reports, logs, team events, or TriWiki.\n3. UI E2E must use @Computer Use for live browser interaction. If @Computer Use evidence is not available, mark UI verification as not performed.\n4. Non-local/deployed targets are read-only smoke by default. Destructive removal scenarios are never allowed on deployed targets.\n5. After answers are sealed, use sks qa-loop answer latest answers.json, then sks qa-loop run latest. Work through qa-ledger.json case by case until qa-gate.json passes or a hard blocker is recorded.\n6. End by writing qa-report.md and running Honest Mode with passed checks, gaps, risks, and unverified areas.\n\nArtifacts:\n- .sneakoscope/missions/<id>/qa-ledger.json\n- .sneakoscope/missions/<id>/qa-report.md\n- .sneakoscope/missions/<id>/qa-gate.json\n\nRules:\n- Do not skip the ambiguity gate.\n- Do not save credentials or auth state to artifacts or wiki.\n- Do not claim UI E2E without @Computer Use evidence.\n- Keep raw logs bounded under the mission cycle folder and summarize only evidence in the report.\n`,
705
+ 'qa-loop': `---\nname: qa-loop\ndescription: Codex App picker alias for $QALoop.\n---\n\nUse exactly like $QALoop.\n`,
703
706
  'ralph': `---\nname: ralph\ndescription: Dollar-command route for $Ralph or $ralph mandatory clarification and no-question mission workflows.\n---\n\nUse when the user invokes $Ralph/$ralph or requests a clarification-gated autonomous implementation mission. Prepare with sks ralph prepare, answer/seal required slots when answers are provided, then run only after decision-contract.json exists.\n`,
704
707
  'research': `---\nname: research\ndescription: Dollar-command route for $Research or $research frontier discovery workflows.\n---\n\nUse when the user invokes $Research/$research or asks for research, hypotheses, new mechanisms, falsification, or testable predictions. Prefer sks research prepare and sks research run. Do not use for ordinary code edits.\n`,
705
708
  'autoresearch': `---\nname: autoresearch\ndescription: Dollar-command route for $AutoResearch or $autoresearch iterative experiment loops.\n---\n\nUse when the user invokes $AutoResearch/$autoresearch or asks for iterative improvement, SEO/GEO, ranking, prompt/workflow improvement, benchmark gains, or open-ended experimentation. Follow the autoresearch-loop skill and load seo-geo-optimizer for README, npm, GitHub stars, schema, keyword, AI-search, or discoverability work. Define program, hypothesis, experiment, metric, keep/discard decision, falsification, next experiment, and Honest Mode conclusion.\n`,
@@ -737,7 +740,7 @@ async function installSkills(root) {
737
740
  }
738
741
 
739
742
  function enrichSkillContent(name, content) {
740
- if (!['sks', 'answer', 'wiki', 'wiki-refresh', 'wikirefresh', 'team', 'agent-team', 'ralph', 'research', 'autoresearch', 'db', 'gx', 'prompt-pipeline', 'pipeline-runner', 'turbo-context-pack', 'hproof-evidence-bind'].includes(name)) return content;
743
+ if (!['sks', 'answer', 'wiki', 'wiki-refresh', 'wikirefresh', 'team', 'agent-team', 'qaloop', 'qa-loop', 'ralph', 'research', 'autoresearch', 'db', 'gx', 'prompt-pipeline', 'pipeline-runner', 'turbo-context-pack', 'hproof-evidence-bind'].includes(name)) return content;
741
744
  const text = String(content || '').trimEnd();
742
745
  if (text.includes('TriWiki context-tracking SSOT')) return text;
743
746
  return `${text}
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { appendJsonl, exists, nowIso, readJson, readText, writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
3
3
  import { containsUserQuestion, noQuestionContinuationReason } from './no-question-guard.mjs';
4
4
  import { createMission, missionDir, setCurrent } from './mission.mjs';
5
- import { buildQuestionSchema, writeQuestions } from './questions.mjs';
5
+ import { buildQuestionSchemaForRoute, writeQuestions } from './questions.mjs';
6
6
  import { scanDbSafety } from './db-safety.mjs';
7
7
  import { writeResearchPlan } from './research.mjs';
8
8
  import { context7RequirementText, dollarCommand, reasoningInstruction, routeNeedsContext7, routePrompt, routeReasoning, routeRequiresSubagents, stripDollarCommand, subagentExecutionPolicyText, triwikiContextTracking, triwikiContextTrackingText, triwikiStagePolicyText } from './routes.mjs';
@@ -156,7 +156,7 @@ async function prepareRalph(root, route, task, required) {
156
156
 
157
157
  async function prepareClarificationGate(root, route, task, required, opts = {}) {
158
158
  const { id, dir } = await createMission(root, { mode: String(route.mode || route.id || 'route').toLowerCase(), prompt: task });
159
- const schema = buildQuestionSchema(task);
159
+ const schema = buildQuestionSchemaForRoute(route, task);
160
160
  await writeQuestions(dir, schema);
161
161
  await writeJsonAtomic(path.join(dir, 'route-context.json'), { route: route.id, command: route.command, mode: route.mode, task, required_skills: route.requiredSkills, context7_required: required, original_stop_gate: route.stopGate, clarification_gate: true });
162
162
  await appendJsonl(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: opts.ralph ? 'route.ralph.questions_created' : 'route.clarification.questions_created', route: route.id, slots: schema.slots.length });
@@ -513,6 +513,10 @@ function missingRequiredGateFields(file, state, gate = {}) {
513
513
  return ['analysis_artifact', 'triwiki_refreshed', 'triwiki_validated', 'consensus_artifact', 'implementation_team_fresh', 'review_artifact', 'integration_evidence']
514
514
  .filter((key) => gate[key] !== true);
515
515
  }
516
+ if (file === 'qa-gate.json' || mode === 'QALOOP') {
517
+ return ['clarification_contract_sealed', 'qa_report_written', 'qa_ledger_complete', 'checklist_completed', 'safety_reviewed', 'deployed_destructive_tests_blocked', 'credentials_not_persisted', 'ui_computer_use_evidence', 'honest_mode_complete']
518
+ .filter((key) => gate[key] !== true);
519
+ }
516
520
  return [];
517
521
  }
518
522
 
@@ -524,6 +528,7 @@ function gateFilesForState(state) {
524
528
  if (state.mode === 'AUTORESEARCH') return ['autoresearch-gate.json'];
525
529
  if (state.mode === 'DB') return ['db-review.json'];
526
530
  if (state.mode === 'GX') return ['gx-gate.json'];
531
+ if (state.mode === 'QALOOP') return ['qa-gate.json'];
527
532
  return ['done-gate.json'];
528
533
  }
529
534
 
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import { exists, nowIso, readJson, readText, writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
3
+
4
+ export const QA_LOOP_ROUTE = 'QALoop';
5
+
6
+ export function buildQaLoopQuestionSchema(prompt) {
7
+ return {
8
+ schema_version: 1,
9
+ route: QA_LOOP_ROUTE,
10
+ description: 'QA-Loop questions must be answered before execution. Login secrets and browser auth state are runtime-only and must not be saved to mission files or TriWiki.',
11
+ prompt,
12
+ slots: [
13
+ { id: 'GOAL_PRECISE', question: 'Define the QA objective in one sentence.', required: true, type: 'string' },
14
+ { id: 'QA_SCOPE', question: 'Which QA surface should run?', required: true, type: 'enum', options: ['ui_e2e_only', 'api_e2e_only', 'ui_and_api_e2e', 'all_available'] },
15
+ { id: 'TARGET_ENVIRONMENT', question: 'Where should QA run?', required: true, type: 'enum', options: ['local_dev_server', 'preview_or_staging_domain', 'deployed_production_domain'] },
16
+ { id: 'TARGET_BASE_URL', question: 'What base URL should QA target?', required: true, type: 'string' },
17
+ { id: 'DEV_SERVER_COMMAND', question: 'If local dev is selected, what command starts the app? Use none if already running.', required: true, type: 'string' },
18
+ { id: 'API_BASE_URL', question: 'If API E2E is selected, what API base URL should be used? Use same_as_target when identical.', required: true, type: 'string' },
19
+ { id: 'QA_MUTATION_POLICY', question: 'May QA create or change seeded data?', required: true, type: 'enum', options: ['read_only_smoke_only', 'seeded_create_change_only', 'seeded_create_change_remove_local_only'] },
20
+ { id: 'DESTRUCTIVE_DEPLOYED_TESTS_ALLOWED', question: 'Can non-local QA run destructive removal scenarios?', required: true, type: 'enum', options: ['never'] },
21
+ { id: 'EXTERNAL_SIDE_EFFECT_POLICY', question: 'How should email, SMS, webhook, payment, and admin side effects be handled?', required: true, type: 'enum', options: ['block_all_external_side_effects', 'mock_or_sandbox_only'] },
22
+ { id: 'LOGIN_REQUIRED', question: 'Does UI/API QA require login?', required: true, type: 'enum', options: ['no', 'yes'] },
23
+ { id: 'TEMP_TEST_CREDENTIALS_READY', question: 'If login is required, are test-only credentials ready to provide ephemerally during the run?', required: true, type: 'enum', options: ['not_required', 'yes_temp_only', 'no_block_authenticated_tests'] },
24
+ { id: 'TEST_CREDENTIALS_RUNTIME_SOURCE', question: 'If login is required, how will test-only credentials be provided without saving the values?', required: true, type: 'enum', options: ['not_required', 'ephemeral_chat_only', 'environment_variables', 'secret_manager'] },
25
+ { id: 'CREDENTIAL_STORAGE_ACK', question: 'Acknowledge credential handling policy.', required: true, type: 'enum', options: ['never_store_credentials_in_artifacts_or_wiki'] },
26
+ { id: 'UI_COMPUTER_USE_ACK', question: 'Acknowledge UI E2E evidence policy.', required: true, type: 'enum', options: ['use_computer_use_for_ui_e2e_or_mark_ui_not_verified'] },
27
+ { id: 'TEAM_MODE_ALLOWED', question: 'May QA-Loop use Team/subagents where useful?', required: true, type: 'enum', options: ['yes_parallel_where_safe', 'no_parent_only'] },
28
+ { id: 'MAX_QA_CYCLES', question: 'How many no-question QA cycles are allowed before pausing?', required: true, type: 'string' },
29
+ { id: 'ACCEPTANCE_CRITERIA', question: 'List the QA completion criteria.', required: true, type: 'array_or_string' },
30
+ { id: 'NON_GOALS', question: 'List anything QA-Loop must not test.', required: true, type: 'array_or_string' },
31
+ { id: 'RISK_BOUNDARY', question: 'List hard safety boundaries for data, auth, permissions, money, messages, and third-party systems.', required: true, type: 'array_or_string' },
32
+ { id: 'MID_RALPH_UNKNOWN_POLICY', question: 'If ambiguity appears during no-question QA, choose the fallback order.', required: true, type: 'array', options: ['preserve_existing_behavior', 'smallest_reversible_change', 'defer_optional_scope', 'block_only_if_no_safe_path'] }
33
+ ]
34
+ };
35
+ }
36
+
37
+ export function validateQaLoopAnswers(schema, answers = {}) {
38
+ if (schema?.route !== QA_LOOP_ROUTE) return [];
39
+ const errors = [];
40
+ const env = answers.TARGET_ENVIRONMENT;
41
+ const mutation = answers.QA_MUTATION_POLICY;
42
+ const extra = Object.keys(answers).filter((k) => /(password|passwd|token|secret|cookie|storage_state|login_username|login_password)/i.test(k));
43
+ if (extra.length) errors.push({ slot: extra.join(','), error: 'qa_loop_credentials_must_not_be_saved_in_answers_json' });
44
+ if (env !== 'local_dev_server' && mutation === 'seeded_create_change_remove_local_only') errors.push({ slot: 'QA_MUTATION_POLICY', error: 'destructive_removal_tests_are_local_dev_only' });
45
+ if (env === 'deployed_production_domain' && mutation !== 'read_only_smoke_only') errors.push({ slot: 'QA_MUTATION_POLICY', error: 'production_deployed_qa_is_read_only_smoke_only' });
46
+ if (answers.DESTRUCTIVE_DEPLOYED_TESTS_ALLOWED !== 'never') errors.push({ slot: 'DESTRUCTIVE_DEPLOYED_TESTS_ALLOWED', error: 'destructive_deployed_tests_never_allowed' });
47
+ if (isUiScope(answers.QA_SCOPE) && answers.UI_COMPUTER_USE_ACK !== 'use_computer_use_for_ui_e2e_or_mark_ui_not_verified') errors.push({ slot: 'UI_COMPUTER_USE_ACK', error: 'ui_e2e_requires_computer_use_ack' });
48
+ if (answers.LOGIN_REQUIRED === 'yes' && answers.TEMP_TEST_CREDENTIALS_READY !== 'yes_temp_only') errors.push({ slot: 'TEMP_TEST_CREDENTIALS_READY', error: 'authenticated_tests_require_ephemeral_test_credentials_or_must_be_blocked' });
49
+ if (answers.LOGIN_REQUIRED === 'yes' && answers.TEST_CREDENTIALS_RUNTIME_SOURCE === 'not_required') errors.push({ slot: 'TEST_CREDENTIALS_RUNTIME_SOURCE', error: 'credential_runtime_source_required' });
50
+ if (answers.CREDENTIAL_STORAGE_ACK !== 'never_store_credentials_in_artifacts_or_wiki') errors.push({ slot: 'CREDENTIAL_STORAGE_ACK', error: 'credential_temp_only_ack_required' });
51
+ return errors;
52
+ }
53
+
54
+ export function isUiScope(scope) {
55
+ return ['ui_e2e_only', 'ui_and_api_e2e', 'all_available'].includes(scope);
56
+ }
57
+
58
+ export function isApiScope(scope) {
59
+ return ['api_e2e_only', 'ui_and_api_e2e', 'all_available'].includes(scope);
60
+ }
61
+
62
+ export function defaultQaGate(contract = {}) {
63
+ const a = contract.answers || {};
64
+ return {
65
+ passed: false,
66
+ clarification_contract_sealed: Boolean(contract.sealed_hash),
67
+ qa_report_written: false,
68
+ qa_ledger_complete: false,
69
+ checklist_completed: false,
70
+ safety_reviewed: false,
71
+ deployed_destructive_tests_blocked: a.TARGET_ENVIRONMENT === 'local_dev_server' || a.DESTRUCTIVE_DEPLOYED_TESTS_ALLOWED === 'never',
72
+ credentials_not_persisted: false,
73
+ ui_e2e_required: isUiScope(a.QA_SCOPE),
74
+ ui_computer_use_evidence: !isUiScope(a.QA_SCOPE),
75
+ api_e2e_required: isApiScope(a.QA_SCOPE),
76
+ unsafe_external_side_effects: false,
77
+ honest_mode_complete: false,
78
+ evidence: [],
79
+ notes: []
80
+ };
81
+ }
82
+
83
+ export async function writeQaLoopArtifacts(dir, mission, contract) {
84
+ const a = contract.answers || {};
85
+ const checklist = qaChecklist(a);
86
+ await writeJsonAtomic(path.join(dir, 'qa-ledger.json'), {
87
+ schema_version: 1,
88
+ generated_at: nowIso(),
89
+ mission_id: mission.id,
90
+ target: { scope: a.QA_SCOPE, environment: a.TARGET_ENVIRONMENT, base_url: a.TARGET_BASE_URL, api_base_url: a.API_BASE_URL },
91
+ safety: { mutation_policy: a.QA_MUTATION_POLICY, deployed_destructive_tests_allowed: 'never', credentials: 'temp_only_never_saved', ui_evidence: 'computer_use_required_for_ui_e2e' },
92
+ checklist
93
+ });
94
+ await writeJsonAtomic(path.join(dir, 'qa-gate.json'), defaultQaGate(contract));
95
+ await writeTextAtomic(path.join(dir, 'qa-report.md'), qaReportTemplate(mission, contract, checklist));
96
+ return { checklist_count: checklist.length };
97
+ }
98
+
99
+ export async function evaluateQaGate(dir) {
100
+ const gate = await readJson(path.join(dir, 'qa-gate.json'), {});
101
+ const reasons = [];
102
+ for (const key of ['clarification_contract_sealed', 'qa_report_written', 'qa_ledger_complete', 'checklist_completed', 'safety_reviewed', 'deployed_destructive_tests_blocked', 'credentials_not_persisted', 'ui_computer_use_evidence', 'honest_mode_complete']) {
103
+ if (gate[key] !== true) reasons.push(`${key}_missing`);
104
+ }
105
+ if (gate.unsafe_external_side_effects === true) reasons.push('unsafe_external_side_effects');
106
+ if (!(await exists(path.join(dir, 'qa-report.md')))) reasons.push('qa_report_missing');
107
+ if (!(await exists(path.join(dir, 'qa-ledger.json')))) reasons.push('qa_ledger_missing');
108
+ const passed = gate.passed === true && reasons.length === 0;
109
+ const result = { checked_at: nowIso(), passed, reasons, gate };
110
+ await writeJsonAtomic(path.join(dir, 'qa-gate.evaluated.json'), result);
111
+ return result;
112
+ }
113
+
114
+ export async function writeMockQaResult(dir, mission, contract) {
115
+ await writeTextAtomic(path.join(dir, 'qa-report.md'), `# QA-Loop Report\n\nMission: ${mission.id}\nMode: mock verification\n\nMock QA-Loop completed. No live UI/API actions were executed.\n\n## Honest Mode\n\nThis is a mock smoke run for command verification, not production QA evidence.\n`);
116
+ await writeJsonAtomic(path.join(dir, 'qa-gate.json'), { ...defaultQaGate(contract), passed: true, qa_report_written: true, qa_ledger_complete: true, checklist_completed: true, safety_reviewed: true, credentials_not_persisted: true, ui_computer_use_evidence: true, honest_mode_complete: true, evidence: ['mock QA-Loop smoke completed'], notes: ['No live UI/API verification was claimed.'] });
117
+ return evaluateQaGate(dir);
118
+ }
119
+
120
+ export function buildQaLoopPrompt({ id, mission, contract, cycle, previous }) {
121
+ return `You are running SKS QA-Loop.\nMISSION: ${id}\nTASK: ${mission.prompt}\nCYCLE: ${cycle}\nNO QUESTIONS: use decision-contract.json and the decision ladder.\nUI E2E: if UI is in scope, use @Computer Use for live browser interaction or mark UI not verified. Do not claim UI E2E from text logs alone.\nCREDENTIALS: use only test credentials already provided ephemerally or through the approved runtime source. If they are unavailable, mark authenticated checks blocked; never save login secrets, cookies, auth state, or screenshots containing secrets to files, Team transcript, reports, logs, or TriWiki.\nDEPLOYED SAFETY: deployed domains are read-only smoke only; never run destructive removal scenarios on deployed domains.\nEXTERNAL SAFETY: payment/billing, email/SMS/webhook sends, admin permission changes, and bulk writes are forbidden unless safely mocked/sandboxed by the sealed contract.\nARTIFACTS: check qa-ledger.json case by case, save bounded raw output under qa-loop/cycle-${cycle}/, refresh qa-report.md and qa-gate.json. Continue until qa-gate.json passes or a hard blocker is documented.\nDECISION CONTRACT:\n${JSON.stringify(contract, null, 2)}\nPrevious cycle tail:\n${String(previous || '').slice(-2500)}\n`;
122
+ }
123
+
124
+ export async function qaStatus(dir) {
125
+ const gate = await readJson(path.join(dir, 'qa-gate.evaluated.json'), await readJson(path.join(dir, 'qa-gate.json'), null));
126
+ const ledger = await readJson(path.join(dir, 'qa-ledger.json'), null);
127
+ const report = await readText(path.join(dir, 'qa-report.md'), '');
128
+ return { gate, checklist_count: ledger?.checklist?.length ?? null, report_written: Boolean(report.trim()) };
129
+ }
130
+
131
+ function qaChecklist(a) {
132
+ const cases = [
133
+ ['preflight.target', 'Confirm target URL, environment, and allowed mutation policy.'],
134
+ ['preflight.safety', 'Block destructive, billing, email/SMS/webhook, admin, and bulk-write side effects outside local disposable data.'],
135
+ ['preflight.auth', 'Confirm login requirement and ephemeral test credential handling without saving secrets.'],
136
+ ['preflight.data', 'Identify seed data, cleanup limits, and rollback expectations.'],
137
+ ['preflight.roles', 'Map user roles, permissions, and protected areas in scope.']
138
+ ];
139
+ if (isUiScope(a.QA_SCOPE)) cases.push(
140
+ ['ui.computer_use', 'Use @Computer Use for live browser UI checks or mark UI not verified.'],
141
+ ['ui.navigation', 'Check primary navigation, deep links, back/forward, refresh, and protected routes.'],
142
+ ['ui.auth', 'Check login, logout, session expiry, unauthorized access, and role-specific visibility.'],
143
+ ['ui.forms', 'Check required fields, validation, disabled states, optimistic UI, submit success, and submit failure.'],
144
+ ['ui.states', 'Check loading, empty, error, retry, offline/timeout, and slow network states.'],
145
+ ['ui.crud', 'Check allowed create/change flows and block forbidden destructive flows by environment.'],
146
+ ['ui.responsive', 'Check desktop, tablet, mobile, overflow, long text, and keyboard focus order.'],
147
+ ['ui.a11y', 'Check labels, focus traps, modals, contrast-sensitive controls, and screen-reader names.'],
148
+ ['ui.visual', 'Capture evidence for meaningful UI regressions without storing secrets.']
149
+ );
150
+ if (isApiScope(a.QA_SCOPE)) cases.push(
151
+ ['api.health', 'Check health/version/readiness endpoints when available.'],
152
+ ['api.auth', 'Check anonymous, authenticated, expired, and wrong-role access.'],
153
+ ['api.contract', 'Check status codes, response shape, headers, content type, and error format.'],
154
+ ['api.validation', 'Check missing, malformed, boundary, duplicate, and over-limit payloads.'],
155
+ ['api.listing', 'Check pagination, sorting, filters, search, and empty results.'],
156
+ ['api.mutation', 'Check allowed seeded create/change and forbid deployed destructive flows.'],
157
+ ['api.idempotency', 'Check retry/idempotency behavior for safe operations.'],
158
+ ['api.concurrency', 'Check stale change, conflict, and double-submit behavior.'],
159
+ ['api.failure', 'Check timeout, upstream error, rate-limit, and rollback-visible failure paths.'],
160
+ ['api.security', 'Check CORS, auth headers, PII redaction, and permission boundaries.']
161
+ );
162
+ cases.push(['report.evidence', 'Record pass/fail/blocked/skipped with evidence path for every case.'], ['report.honest', 'Run Honest Mode: list passed checks, gaps, risks, and non-verified areas.']);
163
+ return cases.map(([id, title]) => ({ id, title, status: 'pending', evidence: [] }));
164
+ }
165
+
166
+ function qaReportTemplate(mission, contract, checklist) {
167
+ const a = contract.answers || {};
168
+ return `# QA-Loop Report\n\nMission: ${mission.id}\nTarget: ${a.TARGET_BASE_URL || 'unset'}\nScope: ${a.QA_SCOPE || 'unset'}\nEnvironment: ${a.TARGET_ENVIRONMENT || 'unset'}\n\n## Safety\n\n- Deployed destructive tests: never\n- Credentials: temp-only, never saved to artifacts or TriWiki\n- UI evidence: @Computer Use required when UI E2E is in scope\n\n## Checklist\n\n${checklist.map((item) => `- [ ] ${item.id}: ${item.title}`).join('\n')}\n\n## Findings\n\nTBD\n\n## Honest Mode\n\nTBD\n`;
169
+ }
@@ -1,5 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import { writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
3
+ import { buildQaLoopQuestionSchema } from './qa-loop.mjs';
4
+
5
+ export function buildQuestionSchemaForRoute(route, prompt) {
6
+ if (String(route?.id || '') === 'QALoop') return buildQaLoopQuestionSchema(prompt);
7
+ return buildQuestionSchema(prompt);
8
+ }
3
9
 
4
10
  export function buildQuestionSchema(prompt) {
5
11
  const lower = prompt.toLowerCase();
@@ -61,11 +67,20 @@ export function buildQuestionSchema(prompt) {
61
67
 
62
68
  export function questionsMarkdown(schema) {
63
69
  const lines = [];
64
- lines.push('# Sneakoscope Codex Ralph Prepare Questions');
70
+ const isQaLoop = schema?.route === 'QALoop';
71
+ lines.push(isQaLoop ? '# Sneakoscope Codex QA-Loop Prepare Questions' : '# Sneakoscope Codex Ralph Prepare Questions');
65
72
  lines.push('');
66
- lines.push('Ralph는 이 질문들에 모두 답변하고 Decision Contract가 봉인된 뒤에만 실행됩니다.');
67
- lines.push('Ralph 실행 중에는 사용자에게 절대 질문하지 않습니다.');
68
- lines.push('DB 작업은 특히 안전 게이트가 적용됩니다. 파괴적 DB 작업은 절대 허용되지 않습니다.');
73
+ if (isQaLoop) {
74
+ lines.push('QA-Loop는 질문들에 모두 답변하고 Decision Contract가 봉인된 뒤에만 실행됩니다.');
75
+ lines.push('로그인이 필요하면 테스트 전용 계정 정보만 임시 런타임 입력으로 제공해야 하며, answers.json/리포트/로그/wiki에는 절대 저장하지 않습니다.');
76
+ lines.push('UI E2E는 @Computer Use 증거가 없으면 검증 완료로 주장할 수 없습니다.');
77
+ lines.push('개발 서버가 아닌 배포/스테이징 도메인에서는 삭제성 테스트를 절대 실행하지 않습니다.');
78
+ } else {
79
+ lines.push('Ralph는 이 질문들에 모두 답변하고 Decision Contract가 봉인된 뒤에만 실행됩니다.');
80
+ lines.push('Ralph 실행 중에는 사용자에게 절대 질문하지 않습니다.');
81
+ lines.push('DB 작업은 특히 안전 게이트가 적용됩니다. 파괴적 DB 작업은 절대 허용되지 않습니다.');
82
+ }
83
+ if (schema.description) lines.push(schema.description);
69
84
  lines.push('');
70
85
  for (let i = 0; i < schema.slots.length; i++) {
71
86
  const s = schema.slots[i];
@@ -1,4 +1,4 @@
1
- export const USAGE_TOPICS = 'install|setup|team|ralph|research|db|codex-app|dfix|dollar|context7|pipeline|reasoning|guard|conflicts|versioning|eval|gx|wiki';
1
+ export const USAGE_TOPICS = 'install|setup|team|qa-loop|ralph|research|db|codex-app|dfix|dollar|context7|pipeline|reasoning|guard|conflicts|versioning|eval|gx|wiki';
2
2
 
3
3
  export const RECOMMENDED_MCP_SERVERS = [
4
4
  {
@@ -124,6 +124,21 @@ export const ROUTES = [
124
124
  cliEntrypoint: 'sks team "task" [executor:5 reviewer:2 user:1] | sks team log|tail|watch|status|event',
125
125
  examples: ['$Team executor:5 agree on the best plan and implement it']
126
126
  },
127
+ {
128
+ id: 'QALoop',
129
+ command: '$QALoop',
130
+ mode: 'QALOOP',
131
+ route: 'QA loop',
132
+ description: 'Clarification-gated UI/API E2E QA loop with local/deployed safety policy, Computer Use UI evidence, temp-only credentials, detailed checklist, QA report, and Honest Mode.',
133
+ appSkillAliases: ['qa-loop'],
134
+ requiredSkills: ['qaloop', 'qa-loop', 'pipeline-runner', 'honest-mode'],
135
+ lifecycle: ['qa_questions_answered', 'contract_sealed', 'qa_checklist', 'qa_loop_cycles', 'qa_report_md', 'qa_gate', 'honest_mode'],
136
+ context7Policy: 'optional',
137
+ reasoningPolicy: 'high',
138
+ stopGate: 'qa-gate.json',
139
+ cliEntrypoint: 'sks qa-loop prepare|answer|run|status',
140
+ examples: ['$QALoop run UI and API E2E against local dev', '$QA-Loop deployed smoke only']
141
+ },
127
142
  {
128
143
  id: 'Ralph',
129
144
  command: '$Ralph',
@@ -246,6 +261,7 @@ export const COMMAND_CATALOG = [
246
261
  { name: 'codex-app', usage: 'sks codex-app', description: 'Show Codex App setup files and example prompts.' },
247
262
  { name: 'dollar-commands', usage: 'sks dollar-commands [--json]', description: 'List Codex App $ commands such as $DFix and $Team.' },
248
263
  { name: 'dfix', usage: 'sks dfix', description: 'Explain $DFix ultralight design/content fix mode.' },
264
+ { name: 'qa-loop', usage: 'sks qa-loop prepare|answer|run|status ...', description: 'Run clarification-gated UI/API E2E QA with safety gates, Computer Use evidence, and a QA report.' },
249
265
  { name: 'context7', usage: 'sks context7 check|setup|tools|resolve|docs|evidence ...', description: 'Check, configure, and call the local Context7 MCP requirement.' },
250
266
  { name: 'pipeline', usage: 'sks pipeline status|resume|answer ...', description: 'Inspect the active skill-first route, pass mandatory ambiguity gates, and inspect completion gates.' },
251
267
  { name: 'guard', usage: 'sks guard check [--json]', description: 'Check SKS harness self-protection lock, fingerprints, and source-repo exception state.' },
@@ -309,6 +325,7 @@ export function routePrompt(prompt) {
309
325
  if (looksLikeAnswerOnlyRequest(text)) return routeById('Answer');
310
326
  if (/\b(SQL|Supabase|Postgres|migration|RLS|Prisma|Drizzle|Knex|database|DB|execute_sql|mcp)\b/i.test(text)) return routeById('DB');
311
327
  if (/\b(team|multi-agent|subagent|parallel agents|agent team|병렬|팀)\b/i.test(text)) return routeById('Team');
328
+ if (/\b(qa[-\s]?loop|qaloop|e2e\s+qa|qa\s+e2e)\b/i.test(text)) return routeById('QALoop');
312
329
  if (/\b(autoresearch|experiment|benchmark|SEO|GEO|ranking|optimi[sz]e|improve metric|discoverability|visibility|github stars?|npm downloads?|검색|노출|스타|다운로드)\b/i.test(text)) return routeById('AutoResearch');
313
330
  if (/\b(research|hypothesis|falsify|novelty|frontier|조사|연구)\b/i.test(text)) return routeById('Research');
314
331
  if (/(wiki\s+(refresh|pack|validate|prune)|triwiki\s+(refresh|pack|validate)|위키\s*(갱신|리프레시|정리|검증|패킹)|트라이위키|triwiki)/i.test(text)) return routeById('Wiki');
@@ -149,7 +149,7 @@ export function selectClaims(mission, claims, budget = {}) {
149
149
  const selectedIds = new Set();
150
150
  const required = scored
151
151
  .filter((x) => Number(x.claim.required_weight) > 0)
152
- .sort((a, b) => b.score - a.score);
152
+ .sort((a, b) => (Number(b.claim.required_weight) - Number(a.claim.required_weight)) || b.score - a.score);
153
153
  for (const item of required) {
154
154
  if (selected.length >= maxClaims) break;
155
155
  selected.push(item);
@@ -157,7 +157,7 @@ export function selectClaims(mission, claims, budget = {}) {
157
157
  }
158
158
  const fill = topKByScore(scored.filter((x) => !selectedIds.has(x.claim.id)), maxClaims - selected.length);
159
159
  return [...selected, ...fill]
160
- .sort((a, b) => b.score - a.score)
160
+ .sort((a, b) => (Number(b.claim.required_weight || 0) - Number(a.claim.required_weight || 0)) || b.score - a.score)
161
161
  .map((x) => withTrust({ ...x.claim, triwiki_score: Number(x.score.toFixed(4)) }, trustPolicy));
162
162
  }
163
163