supipowers 0.2.7 → 0.4.0
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/package.json +21 -6
- package/skills/debugging/SKILL.md +54 -15
- package/skills/fix-pr/SKILL.md +99 -0
- package/skills/planning/SKILL.md +70 -10
- package/skills/receiving-code-review/SKILL.md +87 -0
- package/skills/tdd/SKILL.md +83 -0
- package/skills/verification/SKILL.md +54 -0
- package/src/commands/fix-pr.ts +324 -0
- package/src/commands/plan.ts +96 -31
- package/src/commands/qa.ts +150 -29
- package/src/commands/release.ts +1 -1
- package/src/commands/review.ts +2 -2
- package/src/commands/run.ts +52 -2
- package/src/commands/supi.ts +1 -0
- package/src/commands/update.ts +2 -2
- package/src/discipline/debugging.ts +57 -0
- package/src/discipline/receiving-review.ts +65 -0
- package/src/discipline/tdd.ts +77 -0
- package/src/discipline/verification.ts +68 -0
- package/src/fix-pr/config.ts +36 -0
- package/src/fix-pr/prompt-builder.ts +201 -0
- package/src/fix-pr/scripts/diff-comments.sh +33 -0
- package/src/fix-pr/scripts/fetch-pr-comments.sh +25 -0
- package/src/fix-pr/scripts/trigger-review.sh +36 -0
- package/src/fix-pr/scripts/wait-and-check.sh +37 -0
- package/src/fix-pr/types.ts +71 -0
- package/src/git/branch-finish.ts +101 -0
- package/src/git/worktree.ts +119 -0
- package/src/index.ts +13 -2
- package/src/lsp/detector.ts +2 -2
- package/src/orchestrator/agent-prompts.ts +282 -0
- package/src/orchestrator/dispatcher.ts +150 -1
- package/src/orchestrator/prompts.ts +17 -31
- package/src/planning/plan-reviewer.ts +49 -0
- package/src/planning/plan-writer-prompt.ts +173 -0
- package/src/planning/prompt-builder.ts +178 -0
- package/src/planning/spec-reviewer.ts +43 -0
- package/src/qa/phases/discovery.ts +34 -0
- package/src/qa/phases/execution.ts +65 -0
- package/src/qa/phases/matrix.ts +41 -0
- package/src/qa/phases/reporting.ts +71 -0
- package/src/qa/session.ts +104 -0
- package/src/storage/fix-pr-sessions.ts +59 -0
- package/src/storage/qa-sessions.ts +83 -0
- package/src/storage/specs.ts +36 -0
- package/src/types.ts +70 -0
- package/src/visual/companion.ts +115 -0
- package/src/visual/prompt-instructions.ts +102 -0
- package/src/visual/scripts/frame-template.html +201 -0
- package/src/visual/scripts/helper.js +88 -0
- package/src/visual/scripts/index.js +148 -0
- package/src/visual/scripts/package.json +10 -0
- package/src/visual/scripts/start-server.sh +98 -0
- package/src/visual/scripts/stop-server.sh +21 -0
- package/src/visual/types.ts +16 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { QaPhase, QaPhaseStatus, QaSessionLedger, QaTestCase, QaTestResult } from "../types.js";
|
|
2
|
+
import { generateSessionId, createSession, updateSession } from "../storage/qa-sessions.js";
|
|
3
|
+
|
|
4
|
+
const PHASE_ORDER: QaPhase[] = ["discovery", "matrix", "execution", "reporting"];
|
|
5
|
+
|
|
6
|
+
/** Create a new QA session with all phases pending */
|
|
7
|
+
export function createNewSession(cwd: string, framework: string): QaSessionLedger {
|
|
8
|
+
const now = new Date().toISOString();
|
|
9
|
+
const ledger: QaSessionLedger = {
|
|
10
|
+
id: generateSessionId(),
|
|
11
|
+
createdAt: now,
|
|
12
|
+
updatedAt: now,
|
|
13
|
+
framework,
|
|
14
|
+
phases: {
|
|
15
|
+
discovery: { status: "pending" },
|
|
16
|
+
matrix: { status: "pending" },
|
|
17
|
+
execution: { status: "pending" },
|
|
18
|
+
reporting: { status: "pending" },
|
|
19
|
+
},
|
|
20
|
+
tests: [],
|
|
21
|
+
matrix: [],
|
|
22
|
+
results: [],
|
|
23
|
+
};
|
|
24
|
+
createSession(cwd, ledger);
|
|
25
|
+
return ledger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Update a phase's status and timestamps, persist to disk */
|
|
29
|
+
export function advancePhase(
|
|
30
|
+
cwd: string,
|
|
31
|
+
ledger: QaSessionLedger,
|
|
32
|
+
phase: QaPhase,
|
|
33
|
+
status: QaPhaseStatus
|
|
34
|
+
): QaSessionLedger {
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const record = { ...ledger.phases[phase] };
|
|
37
|
+
|
|
38
|
+
record.status = status;
|
|
39
|
+
if (status === "running" && !record.startedAt) {
|
|
40
|
+
record.startedAt = now;
|
|
41
|
+
}
|
|
42
|
+
if (status === "completed" || status === "failed") {
|
|
43
|
+
record.completedAt = now;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const updated: QaSessionLedger = {
|
|
47
|
+
...ledger,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
phases: { ...ledger.phases, [phase]: record },
|
|
50
|
+
};
|
|
51
|
+
updateSession(cwd, updated);
|
|
52
|
+
return updated;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge new test results into the ledger, upserting by testId */
|
|
56
|
+
export function mergeTestResults(
|
|
57
|
+
ledger: QaSessionLedger,
|
|
58
|
+
newResults: QaTestResult[]
|
|
59
|
+
): QaSessionLedger {
|
|
60
|
+
const resultMap = new Map(ledger.results.map((r) => [r.testId, r]));
|
|
61
|
+
|
|
62
|
+
for (const incoming of newResults) {
|
|
63
|
+
const existing = resultMap.get(incoming.testId);
|
|
64
|
+
if (existing) {
|
|
65
|
+
resultMap.set(incoming.testId, {
|
|
66
|
+
...incoming,
|
|
67
|
+
retryCount: existing.retryCount + 1,
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
resultMap.set(incoming.testId, incoming);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...ledger,
|
|
76
|
+
updatedAt: new Date().toISOString(),
|
|
77
|
+
results: Array.from(resultMap.values()),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get test cases whose latest result is "fail" */
|
|
82
|
+
export function getFailedTests(ledger: QaSessionLedger): QaTestCase[] {
|
|
83
|
+
const failedIds = new Set(
|
|
84
|
+
ledger.results.filter((r) => r.status === "fail").map((r) => r.testId)
|
|
85
|
+
);
|
|
86
|
+
return ledger.tests.filter((t) => failedIds.has(t.id));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get the next phase that is not completed */
|
|
90
|
+
export function getNextPhase(ledger: QaSessionLedger): QaPhase | null {
|
|
91
|
+
for (const phase of PHASE_ORDER) {
|
|
92
|
+
if (ledger.phases[phase].status !== "completed") return phase;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Format phase status for TUI display */
|
|
98
|
+
export function getPhaseStatusLine(ledger: QaSessionLedger): string {
|
|
99
|
+
return PHASE_ORDER.map((phase) => {
|
|
100
|
+
const label = phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
101
|
+
const done = ledger.phases[phase].status === "completed";
|
|
102
|
+
return done ? `[done] ${label}` : `[ ] ${label}`;
|
|
103
|
+
}).join(" · ");
|
|
104
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { FixPrSessionLedger } from "../fix-pr/types.js";
|
|
4
|
+
|
|
5
|
+
const SESSIONS_DIR = "fix-pr-sessions";
|
|
6
|
+
|
|
7
|
+
function getBaseDir(cwd: string): string {
|
|
8
|
+
return path.join(cwd, ".omp", "supipowers", SESSIONS_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSessionDir(cwd: string, sessionId: string): string {
|
|
12
|
+
return path.join(getBaseDir(cwd), sessionId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateFixPrSessionId(): string {
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
18
|
+
const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
|
|
19
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
20
|
+
return `fpr-${date}-${time}-${rand}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFixPrSession(cwd: string, ledger: FixPrSessionLedger): void {
|
|
24
|
+
const sessionDir = getSessionDir(cwd, ledger.id);
|
|
25
|
+
fs.mkdirSync(path.join(sessionDir, "snapshots"), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(sessionDir, "ledger.json"), JSON.stringify(ledger, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadFixPrSession(cwd: string, sessionId: string): FixPrSessionLedger | null {
|
|
30
|
+
const ledgerPath = path.join(getSessionDir(cwd, sessionId), "ledger.json");
|
|
31
|
+
if (!fs.existsSync(ledgerPath)) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(ledgerPath, "utf-8")) as FixPrSessionLedger;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updateFixPrSession(cwd: string, ledger: FixPrSessionLedger): void {
|
|
40
|
+
const ledgerPath = path.join(getSessionDir(cwd, ledger.id), "ledger.json");
|
|
41
|
+
ledger.updatedAt = new Date().toISOString();
|
|
42
|
+
fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findActiveFixPrSession(cwd: string): FixPrSessionLedger | null {
|
|
46
|
+
const baseDir = getBaseDir(cwd);
|
|
47
|
+
if (!fs.existsSync(baseDir)) return null;
|
|
48
|
+
|
|
49
|
+
const dirs = fs.readdirSync(baseDir)
|
|
50
|
+
.filter((d) => d.startsWith("fpr-"))
|
|
51
|
+
.sort()
|
|
52
|
+
.reverse();
|
|
53
|
+
|
|
54
|
+
for (const dir of dirs) {
|
|
55
|
+
const ledger = loadFixPrSession(cwd, dir);
|
|
56
|
+
if (ledger && ledger.status === "running") return ledger;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { QaSessionLedger } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const SESSIONS_DIR = [".omp", "supipowers", "qa-sessions"];
|
|
6
|
+
|
|
7
|
+
function getSessionsDir(cwd: string): string {
|
|
8
|
+
return path.join(cwd, ...SESSIONS_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getSessionDir(cwd: string, sessionId: string): string {
|
|
12
|
+
return path.join(getSessionsDir(cwd), sessionId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Generate a unique QA session ID */
|
|
16
|
+
export function generateSessionId(): string {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
19
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
20
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
21
|
+
return `qa-${date}-${time}-${suffix}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create a new QA session */
|
|
25
|
+
export function createSession(cwd: string, ledger: QaSessionLedger): void {
|
|
26
|
+
const sessionDir = getSessionDir(cwd, ledger.id);
|
|
27
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
path.join(sessionDir, "ledger.json"),
|
|
30
|
+
JSON.stringify(ledger, null, 2) + "\n"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Load a QA session ledger */
|
|
35
|
+
export function loadSession(cwd: string, sessionId: string): QaSessionLedger | null {
|
|
36
|
+
const filePath = path.join(getSessionDir(cwd, sessionId), "ledger.json");
|
|
37
|
+
if (!fs.existsSync(filePath)) return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Update a QA session ledger */
|
|
46
|
+
export function updateSession(cwd: string, ledger: QaSessionLedger): void {
|
|
47
|
+
const filePath = path.join(getSessionDir(cwd, ledger.id), "ledger.json");
|
|
48
|
+
fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2) + "\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** List all QA sessions, newest first */
|
|
52
|
+
export function listSessions(cwd: string): string[] {
|
|
53
|
+
const dir = getSessionsDir(cwd);
|
|
54
|
+
if (!fs.existsSync(dir)) return [];
|
|
55
|
+
return fs
|
|
56
|
+
.readdirSync(dir)
|
|
57
|
+
.filter((f) => f.startsWith("qa-"))
|
|
58
|
+
.sort()
|
|
59
|
+
.reverse();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Find the latest session with incomplete phases */
|
|
63
|
+
export function findActiveSession(cwd: string): QaSessionLedger | null {
|
|
64
|
+
for (const sessionId of listSessions(cwd)) {
|
|
65
|
+
const ledger = loadSession(cwd, sessionId);
|
|
66
|
+
if (!ledger) continue;
|
|
67
|
+
const allCompleted = Object.values(ledger.phases).every(
|
|
68
|
+
(p) => p.status === "completed"
|
|
69
|
+
);
|
|
70
|
+
if (!allCompleted) return ledger;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Find the latest session with failed test results */
|
|
76
|
+
export function findSessionWithFailures(cwd: string): QaSessionLedger | null {
|
|
77
|
+
for (const sessionId of listSessions(cwd)) {
|
|
78
|
+
const ledger = loadSession(cwd, sessionId);
|
|
79
|
+
if (!ledger) continue;
|
|
80
|
+
if (ledger.results.some((r) => r.status === "fail")) return ledger;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SPECS_DIR = ["docs", "supipowers", "specs"];
|
|
5
|
+
|
|
6
|
+
/** Get the specs directory path */
|
|
7
|
+
export function getSpecsDir(cwd: string): string {
|
|
8
|
+
return path.join(cwd, ...SPECS_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Save a spec markdown file */
|
|
12
|
+
export function saveSpec(cwd: string, filename: string, content: string): string {
|
|
13
|
+
const dir = getSpecsDir(cwd);
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
const filePath = path.join(dir, filename);
|
|
16
|
+
fs.writeFileSync(filePath, content);
|
|
17
|
+
return filePath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read a spec file by name */
|
|
21
|
+
export function readSpec(cwd: string, name: string): string | null {
|
|
22
|
+
const filePath = path.join(getSpecsDir(cwd), name);
|
|
23
|
+
if (!fs.existsSync(filePath)) return null;
|
|
24
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** List all spec files, newest first */
|
|
28
|
+
export function listSpecs(cwd: string): string[] {
|
|
29
|
+
const dir = getSpecsDir(cwd);
|
|
30
|
+
if (!fs.existsSync(dir)) return [];
|
|
31
|
+
return fs
|
|
32
|
+
.readdirSync(dir)
|
|
33
|
+
.filter((f) => f.endsWith(".md"))
|
|
34
|
+
.sort()
|
|
35
|
+
.reverse();
|
|
36
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -124,6 +124,76 @@ export interface SupipowersConfig {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// ── QA Session Management ──────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** QA pipeline phase */
|
|
130
|
+
export type QaPhase = "discovery" | "matrix" | "execution" | "reporting";
|
|
131
|
+
|
|
132
|
+
/** Phase completion status */
|
|
133
|
+
export type QaPhaseStatus = "pending" | "running" | "completed" | "failed";
|
|
134
|
+
|
|
135
|
+
/** Per-test result status */
|
|
136
|
+
export type TestResultStatus = "pass" | "fail" | "skip";
|
|
137
|
+
|
|
138
|
+
/** A discovered test case */
|
|
139
|
+
export interface QaTestCase {
|
|
140
|
+
id: string;
|
|
141
|
+
filePath: string;
|
|
142
|
+
testName: string;
|
|
143
|
+
suiteName?: string;
|
|
144
|
+
tags?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Traceability matrix entry — one requirement mapped to its tests */
|
|
148
|
+
export interface QaMatrixEntry {
|
|
149
|
+
requirement: string;
|
|
150
|
+
testIds: string[];
|
|
151
|
+
platforms?: string[];
|
|
152
|
+
coverage: "full" | "partial" | "none";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Per-test execution result */
|
|
156
|
+
export interface QaTestResult {
|
|
157
|
+
testId: string;
|
|
158
|
+
status: TestResultStatus;
|
|
159
|
+
duration?: number;
|
|
160
|
+
error?: string;
|
|
161
|
+
retryCount: number;
|
|
162
|
+
lastRunAt: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Report summary generated in the Reporting phase */
|
|
166
|
+
export interface QaReportSummary {
|
|
167
|
+
generatedAt: string;
|
|
168
|
+
total: number;
|
|
169
|
+
passed: number;
|
|
170
|
+
failed: number;
|
|
171
|
+
skipped: number;
|
|
172
|
+
passRate: number;
|
|
173
|
+
failedTests: { testId: string; testName: string; error?: string }[];
|
|
174
|
+
coverageSummary?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Phase record within a session */
|
|
178
|
+
export interface QaPhaseRecord {
|
|
179
|
+
status: QaPhaseStatus;
|
|
180
|
+
startedAt?: string;
|
|
181
|
+
completedAt?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The full QA session ledger */
|
|
185
|
+
export interface QaSessionLedger {
|
|
186
|
+
id: string;
|
|
187
|
+
createdAt: string;
|
|
188
|
+
updatedAt: string;
|
|
189
|
+
framework: string;
|
|
190
|
+
phases: Record<QaPhase, QaPhaseRecord>;
|
|
191
|
+
tests: QaTestCase[];
|
|
192
|
+
matrix: QaMatrixEntry[];
|
|
193
|
+
results: QaTestResult[];
|
|
194
|
+
report?: QaReportSummary;
|
|
195
|
+
}
|
|
196
|
+
|
|
127
197
|
/** Profile shape */
|
|
128
198
|
export interface Profile {
|
|
129
199
|
name: string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { VisualServerInfo, VisualEvent } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
const VISUAL_DIR = [".omp", "supipowers", "visual"];
|
|
9
|
+
|
|
10
|
+
/** Generate a unique visual session ID */
|
|
11
|
+
export function generateVisualSessionId(): string {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
14
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
15
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
16
|
+
return `visual-${date}-${time}-${suffix}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create the session directory and return its path */
|
|
20
|
+
export function createSessionDir(cwd: string, sessionId: string): string {
|
|
21
|
+
const sessionDir = path.join(cwd, ...VISUAL_DIR, sessionId);
|
|
22
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
23
|
+
return sessionDir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Write an HTML screen to the session directory */
|
|
27
|
+
export function writeScreen(sessionDir: string, filename: string, html: string): void {
|
|
28
|
+
fs.writeFileSync(path.join(sessionDir, filename), html);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read user events from the .events file */
|
|
32
|
+
export function readEvents(sessionDir: string): VisualEvent[] {
|
|
33
|
+
const eventsFile = path.join(sessionDir, ".events");
|
|
34
|
+
if (!fs.existsSync(eventsFile)) return [];
|
|
35
|
+
|
|
36
|
+
const content = fs.readFileSync(eventsFile, "utf-8");
|
|
37
|
+
const events: VisualEvent[] = [];
|
|
38
|
+
|
|
39
|
+
for (const line of content.split("\n")) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
try {
|
|
42
|
+
events.push(JSON.parse(line));
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip invalid lines
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return events;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Clear the events file */
|
|
52
|
+
export function clearEvents(sessionDir: string): void {
|
|
53
|
+
const eventsFile = path.join(sessionDir, ".events");
|
|
54
|
+
if (fs.existsSync(eventsFile)) {
|
|
55
|
+
fs.unlinkSync(eventsFile);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get the path to the scripts directory */
|
|
60
|
+
export function getScriptsDir(): string {
|
|
61
|
+
return path.join(__dirname, "scripts");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse server info from start-server.sh JSON output */
|
|
65
|
+
export function parseServerInfo(stdout: string): VisualServerInfo | null {
|
|
66
|
+
for (const line of stdout.split("\n")) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(line);
|
|
70
|
+
if (data.type === "server-started") {
|
|
71
|
+
return {
|
|
72
|
+
port: data.port,
|
|
73
|
+
host: data.host,
|
|
74
|
+
url: data.url,
|
|
75
|
+
screenDir: data.screen_dir,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip non-JSON lines
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Check if a server is running for the given session dir */
|
|
86
|
+
export function isServerRunning(sessionDir: string): boolean {
|
|
87
|
+
const pidFile = path.join(sessionDir, ".server.pid");
|
|
88
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
92
|
+
process.kill(pid, 0); // signal 0 = check existence
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read server info from .server-info file */
|
|
100
|
+
export function readServerInfo(sessionDir: string): VisualServerInfo | null {
|
|
101
|
+
const infoFile = path.join(sessionDir, ".server-info");
|
|
102
|
+
if (!fs.existsSync(infoFile)) return null;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(fs.readFileSync(infoFile, "utf-8").trim());
|
|
106
|
+
return {
|
|
107
|
+
port: data.port,
|
|
108
|
+
host: data.host,
|
|
109
|
+
url: data.url,
|
|
110
|
+
screenDir: data.screen_dir,
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the visual companion instruction block to append to sub-agent prompts.
|
|
3
|
+
* Tells the agent how to write HTML screens, what CSS classes are available,
|
|
4
|
+
* and how to read user interaction events.
|
|
5
|
+
*/
|
|
6
|
+
export function buildVisualInstructions(url: string, sessionDir: string): string {
|
|
7
|
+
const fence = "```";
|
|
8
|
+
|
|
9
|
+
const sections: string[] = [
|
|
10
|
+
"## Visual Companion Active",
|
|
11
|
+
"",
|
|
12
|
+
`A browser companion is running at ${url}. The user can see visual content there.`,
|
|
13
|
+
"",
|
|
14
|
+
"### When to Use Browser vs Terminal",
|
|
15
|
+
"",
|
|
16
|
+
"- **Use the browser** for content that IS visual: mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs, A/B/C option cards, pros/cons tables",
|
|
17
|
+
"- **Use the terminal** for content that is text: requirements questions, conceptual choices, discussion, final plan output",
|
|
18
|
+
"",
|
|
19
|
+
"A question about a UI topic is not automatically a visual question. Conceptual questions go to the terminal. Visual comparisons go to the browser.",
|
|
20
|
+
"",
|
|
21
|
+
"### How to Write HTML Screens",
|
|
22
|
+
"",
|
|
23
|
+
`Write HTML fragment files to \`${sessionDir}/\` with descriptive filenames:`,
|
|
24
|
+
"- `screen-001-approaches.html`",
|
|
25
|
+
"- `screen-002-architecture.html`",
|
|
26
|
+
"",
|
|
27
|
+
"The server auto-wraps fragments in a styled frame with dark/light theme support. You do NOT need to write full HTML documents — just the content inside `<body>`.",
|
|
28
|
+
"",
|
|
29
|
+
"### Available CSS Classes",
|
|
30
|
+
"",
|
|
31
|
+
"Your HTML fragments can use these classes (provided by the frame template):",
|
|
32
|
+
"",
|
|
33
|
+
"**Choices (A/B/C options):**",
|
|
34
|
+
"- `.options` > `.option[data-choice=\"x\"]` with `.letter` + `.content` children",
|
|
35
|
+
"",
|
|
36
|
+
`${fence}html`,
|
|
37
|
+
'<div class="options">',
|
|
38
|
+
' <div class="option" data-choice="a" onclick="toggleSelect(this)">',
|
|
39
|
+
' <div class="letter">A</div>',
|
|
40
|
+
' <div class="content">',
|
|
41
|
+
" <h3>Option Title</h3>",
|
|
42
|
+
" <p>Description text</p>",
|
|
43
|
+
" </div>",
|
|
44
|
+
" </div>",
|
|
45
|
+
"</div>",
|
|
46
|
+
fence,
|
|
47
|
+
"",
|
|
48
|
+
"**Cards (grid layout, multi-select with `data-multiselect`):**",
|
|
49
|
+
'- `.cards` > `.card[data-choice="x"]` with `.card-image` + `.card-body`',
|
|
50
|
+
"",
|
|
51
|
+
"**Mockup containers:**",
|
|
52
|
+
"- `.mockup` > `.mockup-header` + `.mockup-body`",
|
|
53
|
+
"",
|
|
54
|
+
"**Side-by-side comparison:**",
|
|
55
|
+
"- `.split` — two-column grid (responsive)",
|
|
56
|
+
"",
|
|
57
|
+
"**Pros/Cons:**",
|
|
58
|
+
"- `.pros-cons` > `.pros` + `.cons` — color-coded green/red headers",
|
|
59
|
+
"",
|
|
60
|
+
"**Placeholders:**",
|
|
61
|
+
"- `.placeholder` — dashed border boxes for areas to be filled",
|
|
62
|
+
"",
|
|
63
|
+
"**Mock UI elements:**",
|
|
64
|
+
"- `.mock-nav`, `.mock-sidebar`, `.mock-content`, `.mock-button`, `.mock-input`",
|
|
65
|
+
"",
|
|
66
|
+
"**Typography:**",
|
|
67
|
+
"- `h2` (page title), `h3` (section heading), `.subtitle`, `.label`, `.section`",
|
|
68
|
+
"",
|
|
69
|
+
"### Example Screen",
|
|
70
|
+
"",
|
|
71
|
+
`${fence}html`,
|
|
72
|
+
'<h2>Architecture Approach</h2>',
|
|
73
|
+
'<p class="subtitle">Choose the approach that best fits your needs</p>',
|
|
74
|
+
'<div class="options">',
|
|
75
|
+
' <div class="option" data-choice="monolith" onclick="toggleSelect(this)">',
|
|
76
|
+
' <div class="letter">A</div>',
|
|
77
|
+
' <div class="content">',
|
|
78
|
+
" <h3>Monolith</h3>",
|
|
79
|
+
" <p>Single service, simpler deployment</p>",
|
|
80
|
+
" </div>",
|
|
81
|
+
" </div>",
|
|
82
|
+
' <div class="option" data-choice="microservices" onclick="toggleSelect(this)">',
|
|
83
|
+
' <div class="letter">B</div>',
|
|
84
|
+
' <div class="content">',
|
|
85
|
+
" <h3>Microservices</h3>",
|
|
86
|
+
" <p>Distributed, independently scalable</p>",
|
|
87
|
+
" </div>",
|
|
88
|
+
" </div>",
|
|
89
|
+
"</div>",
|
|
90
|
+
fence,
|
|
91
|
+
"",
|
|
92
|
+
"### Reading User Choices",
|
|
93
|
+
"",
|
|
94
|
+
"When you present choices in the browser, the user clicks `[data-choice]` elements.",
|
|
95
|
+
`Read \`${sessionDir}/.events\` to see their selections (newline-delimited JSON).`,
|
|
96
|
+
'Each event: `{ "type": "click", "choice": "a", "text": "Option Title", "timestamp": 1234 }`',
|
|
97
|
+
"",
|
|
98
|
+
"After presenting a visual screen, tell the user to check their browser and respond in the terminal. Then read the .events file to see what they clicked.",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
return sections.join("\n");
|
|
102
|
+
}
|