@geravant/sinain 1.10.1 → 1.11.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 +1 -1
- package/sinain-agent/CLAUDE.md +1 -1
- package/sinain-agent/run.sh +66 -7
- package/sinain-core/src/agent/analyzer.ts +4 -27
- package/sinain-core/src/agent/loop.ts +10 -40
- package/sinain-core/src/agent/situation-writer.ts +0 -16
- package/sinain-core/src/config.ts +1 -9
- package/sinain-core/src/escalation/escalator.ts +43 -16
- package/sinain-core/src/index.ts +20 -36
- package/sinain-core/src/learning/local-curation.ts +4 -4
- package/sinain-core/src/overlay/commands.ts +31 -11
- package/sinain-core/src/overlay/ws-handler.ts +10 -1
- package/sinain-core/src/server.ts +121 -0
- package/sinain-core/src/types.ts +22 -28
- package/sinain-mcp-server/index.ts +28 -0
- package/sinain-memory/eval/assertions.py +0 -21
- package/sinain-core/src/agent/traits.ts +0 -520
- package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
|
@@ -21,8 +21,8 @@ export interface CommandDeps {
|
|
|
21
21
|
onSpawnCommand?: (text: string) => void;
|
|
22
22
|
/** Toggle screen capture — returns new state */
|
|
23
23
|
onToggleScreen: () => boolean;
|
|
24
|
-
/** Toggle
|
|
25
|
-
|
|
24
|
+
/** Toggle escalation pause/resume — returns true if now active */
|
|
25
|
+
onToggleEscalation: () => boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -79,6 +79,27 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
79
79
|
}
|
|
80
80
|
break;
|
|
81
81
|
}
|
|
82
|
+
case "spawn_reply": {
|
|
83
|
+
const { taskId, text } = msg as any;
|
|
84
|
+
log(TAG, `spawn reply for ${taskId}: "${(text || "").slice(0, 60)}"`);
|
|
85
|
+
// Forward to the /spawn/reply HTTP endpoint internally
|
|
86
|
+
fetch(`http://localhost:${deps.config.port}/spawn/reply`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ taskId, text }),
|
|
90
|
+
}).catch(() => {});
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "spawn_permission_reply": {
|
|
94
|
+
const { taskId, decision } = msg as any;
|
|
95
|
+
log(TAG, `spawn permission reply for ${taskId}: ${decision}`);
|
|
96
|
+
fetch(`http://localhost:${deps.config.port}/spawn/permission-reply`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ taskId, decision }),
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
82
103
|
case "command": {
|
|
83
104
|
handleCommand(msg.action, deps);
|
|
84
105
|
log(TAG, `command processed: ${msg.action}`);
|
|
@@ -142,15 +163,14 @@ function handleCommand(action: string, deps: CommandDeps): void {
|
|
|
142
163
|
log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
|
|
143
164
|
break;
|
|
144
165
|
}
|
|
145
|
-
case "
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
|
|
166
|
+
case "toggle_escalation": {
|
|
167
|
+
const nowActive = deps.onToggleEscalation();
|
|
168
|
+
wsHandler.updateState({ escalation: nowActive ? "active" : "paused" });
|
|
169
|
+
wsHandler.broadcast(
|
|
170
|
+
nowActive ? "Escalations resumed" : "Escalations paused — context still accumulating",
|
|
171
|
+
"normal"
|
|
172
|
+
);
|
|
173
|
+
log(TAG, `escalation toggled ${nowActive ? "ON" : "OFF"}`);
|
|
154
174
|
break;
|
|
155
175
|
}
|
|
156
176
|
case "open_settings": {
|
|
@@ -37,6 +37,7 @@ export class WsHandler {
|
|
|
37
37
|
audio: "muted",
|
|
38
38
|
mic: "muted",
|
|
39
39
|
screen: "off",
|
|
40
|
+
escalation: "active",
|
|
40
41
|
connection: "disconnected",
|
|
41
42
|
};
|
|
42
43
|
private replayBuffer: FeedMessage[] = [];
|
|
@@ -72,6 +73,7 @@ export class WsHandler {
|
|
|
72
73
|
audio: this.state.audio,
|
|
73
74
|
mic: this.state.mic,
|
|
74
75
|
screen: this.state.screen,
|
|
76
|
+
escalation: this.state.escalation,
|
|
75
77
|
connection: this.state.connection,
|
|
76
78
|
});
|
|
77
79
|
|
|
@@ -149,11 +151,12 @@ export class WsHandler {
|
|
|
149
151
|
|
|
150
152
|
/** Send a status update to all connected overlays. */
|
|
151
153
|
broadcastStatus(): void {
|
|
152
|
-
const msg: StatusMessage & { envPath?: string } = {
|
|
154
|
+
const msg: StatusMessage & { envPath?: string; escalation?: string } = {
|
|
153
155
|
type: "status",
|
|
154
156
|
audio: this.state.audio,
|
|
155
157
|
mic: this.state.mic,
|
|
156
158
|
screen: this.state.screen,
|
|
159
|
+
escalation: this.state.escalation,
|
|
157
160
|
connection: this.state.connection,
|
|
158
161
|
};
|
|
159
162
|
if (loadedEnvPath) msg.envPath = loadedEnvPath;
|
|
@@ -229,6 +232,12 @@ export class WsHandler {
|
|
|
229
232
|
case "spawn_command":
|
|
230
233
|
log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
|
|
231
234
|
break;
|
|
235
|
+
case "spawn_reply":
|
|
236
|
+
log(TAG, `\u2190 spawn reply: taskId=${(msg as any).taskId}`);
|
|
237
|
+
break;
|
|
238
|
+
case "spawn_permission_reply":
|
|
239
|
+
log(TAG, `\u2190 spawn permission reply: taskId=${(msg as any).taskId} decision=${(msg as any).decision}`);
|
|
240
|
+
break;
|
|
232
241
|
case "profiling":
|
|
233
242
|
if (this.onProfilingCb) this.onProfilingCb(msg);
|
|
234
243
|
return;
|
|
@@ -174,6 +174,7 @@ export interface ServerDeps {
|
|
|
174
174
|
feedbackStore?: FeedbackStore;
|
|
175
175
|
setUserCommand?: (text: string) => void;
|
|
176
176
|
getEscalationPending?: () => any;
|
|
177
|
+
isEscalationPaused?: () => boolean;
|
|
177
178
|
respondEscalation?: (id: string, response: string) => any;
|
|
178
179
|
getKnowledgeDocPath?: () => string | null;
|
|
179
180
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
@@ -203,6 +204,9 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
|
|
208
|
+
const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
|
|
209
|
+
|
|
206
210
|
export function createAppServer(deps: ServerDeps) {
|
|
207
211
|
const { config, feedBuffer, senseBuffer, wsHandler } = deps;
|
|
208
212
|
let senseInBytes = 0;
|
|
@@ -562,6 +566,11 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
562
566
|
|
|
563
567
|
// ── /escalation/pending ──
|
|
564
568
|
if (req.method === "GET" && url.pathname === "/escalation/pending") {
|
|
569
|
+
const paused = deps.isEscalationPaused?.() ?? false;
|
|
570
|
+
if (paused) {
|
|
571
|
+
res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
565
574
|
const pending = deps.getEscalationPending?.();
|
|
566
575
|
res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
|
|
567
576
|
return;
|
|
@@ -620,6 +629,118 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
620
629
|
return;
|
|
621
630
|
}
|
|
622
631
|
|
|
632
|
+
// ── /spawn/ask (MCP tool posts question, blocks until user replies) ──
|
|
633
|
+
if (req.method === "POST" && url.pathname === "/spawn/ask") {
|
|
634
|
+
const body = await readBody(req, 8192);
|
|
635
|
+
const { taskId, question } = JSON.parse(body);
|
|
636
|
+
if (!taskId || !question) {
|
|
637
|
+
res.writeHead(400);
|
|
638
|
+
res.end(JSON.stringify({ ok: false, error: "missing taskId or question" }));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Broadcast question to overlay
|
|
642
|
+
deps.wsHandler?.broadcastRaw({
|
|
643
|
+
type: "spawn_task",
|
|
644
|
+
taskId,
|
|
645
|
+
label: "user-command",
|
|
646
|
+
status: "awaiting_input",
|
|
647
|
+
startedAt: Date.now(),
|
|
648
|
+
question,
|
|
649
|
+
});
|
|
650
|
+
// Hold response open until user replies (or timeout after 5 min)
|
|
651
|
+
const answer = await new Promise<string>((resolve) => {
|
|
652
|
+
const key = `ask:${taskId}`;
|
|
653
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
654
|
+
setTimeout(() => {
|
|
655
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
656
|
+
pendingSpawnQuestions.delete(key);
|
|
657
|
+
resolve("(no reply — user did not respond within 5 minutes)");
|
|
658
|
+
}
|
|
659
|
+
}, 5 * 60_000);
|
|
660
|
+
});
|
|
661
|
+
res.end(JSON.stringify({ ok: true, answer }));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── /spawn/reply (overlay sends answer to a spawn question) ──
|
|
666
|
+
if (req.method === "POST" && url.pathname === "/spawn/reply") {
|
|
667
|
+
const body = await readBody(req, 8192);
|
|
668
|
+
const { taskId, text } = JSON.parse(body);
|
|
669
|
+
const key = `ask:${taskId}`;
|
|
670
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
671
|
+
if (resolve) {
|
|
672
|
+
pendingSpawnQuestions.delete(key);
|
|
673
|
+
resolve(text || "(empty reply)");
|
|
674
|
+
res.end(JSON.stringify({ ok: true }));
|
|
675
|
+
} else {
|
|
676
|
+
res.end(JSON.stringify({ ok: false, error: "no pending question for this task" }));
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── /spawn/approve (Claude hook posts tool permission, blocks until user decides) ──
|
|
682
|
+
if (req.method === "POST" && url.pathname === "/spawn/approve") {
|
|
683
|
+
const body = await readBody(req, 16384);
|
|
684
|
+
const hookInput = JSON.parse(body);
|
|
685
|
+
const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
|
|
686
|
+
const input = hookInput?.tool_input || hookInput?.input || {};
|
|
687
|
+
|
|
688
|
+
// Auto-approve safe read-only tools
|
|
689
|
+
const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
|
|
690
|
+
if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
|
|
691
|
+
res.end(JSON.stringify({
|
|
692
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
693
|
+
}));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const taskId = `perm-${Date.now()}`;
|
|
698
|
+
// Broadcast permission request to overlay
|
|
699
|
+
deps.wsHandler?.broadcastRaw({
|
|
700
|
+
type: "spawn_task",
|
|
701
|
+
taskId,
|
|
702
|
+
label: "permission",
|
|
703
|
+
status: "awaiting_permission",
|
|
704
|
+
startedAt: Date.now(),
|
|
705
|
+
permission: { tool, input },
|
|
706
|
+
});
|
|
707
|
+
// Hold response open until user decides
|
|
708
|
+
const decision = await new Promise<string>((resolve) => {
|
|
709
|
+
const key = `perm:${taskId}`;
|
|
710
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
711
|
+
setTimeout(() => {
|
|
712
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
713
|
+
pendingSpawnQuestions.delete(key);
|
|
714
|
+
resolve("deny"); // default deny on timeout
|
|
715
|
+
}
|
|
716
|
+
}, 2 * 60_000);
|
|
717
|
+
});
|
|
718
|
+
res.end(JSON.stringify({
|
|
719
|
+
hookSpecificOutput: {
|
|
720
|
+
hookEventName: "PreToolUse",
|
|
721
|
+
permissionDecision: decision === "allow" ? "allow" : "deny",
|
|
722
|
+
permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
|
|
723
|
+
},
|
|
724
|
+
}));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── /spawn/permission-reply (overlay sends allow/deny) ──
|
|
729
|
+
if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
|
|
730
|
+
const body = await readBody(req, 1024);
|
|
731
|
+
const { taskId, decision } = JSON.parse(body);
|
|
732
|
+
const key = `perm:${taskId}`;
|
|
733
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
734
|
+
if (resolve) {
|
|
735
|
+
pendingSpawnQuestions.delete(key);
|
|
736
|
+
resolve(decision || "deny");
|
|
737
|
+
res.end(JSON.stringify({ ok: true }));
|
|
738
|
+
} else {
|
|
739
|
+
res.end(JSON.stringify({ ok: false, error: "no pending permission for this task" }));
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
623
744
|
res.writeHead(404);
|
|
624
745
|
res.end(JSON.stringify({ error: "not found" }));
|
|
625
746
|
} catch (err: any) {
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface StatusMessage {
|
|
|
18
18
|
audio: string;
|
|
19
19
|
mic: string;
|
|
20
20
|
screen: string;
|
|
21
|
+
escalation?: string;
|
|
21
22
|
connection: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -28,7 +29,7 @@ export interface PingMessage {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** sinain-core → Overlay: spawn task lifecycle update */
|
|
31
|
-
export type SpawnTaskStatus = "spawned" | "polling" | "completed" | "failed" | "timeout";
|
|
32
|
+
export type SpawnTaskStatus = "spawned" | "polling" | "completed" | "failed" | "timeout" | "awaiting_input" | "awaiting_permission";
|
|
32
33
|
|
|
33
34
|
export interface SpawnTaskMessage {
|
|
34
35
|
type: "spawn_task";
|
|
@@ -38,6 +39,10 @@ export interface SpawnTaskMessage {
|
|
|
38
39
|
startedAt: number;
|
|
39
40
|
completedAt?: number;
|
|
40
41
|
resultPreview?: string;
|
|
42
|
+
/** Question the spawn is asking the user (status=awaiting_input) */
|
|
43
|
+
question?: string;
|
|
44
|
+
/** Tool permission request (status=awaiting_permission) */
|
|
45
|
+
permission?: { tool: string; input: Record<string, unknown> };
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/** Overlay → sinain-core: user typed a message */
|
|
@@ -78,6 +83,20 @@ export interface SpawnCommandMessage {
|
|
|
78
83
|
text: string;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/** Overlay → sinain-core: reply to a spawn question */
|
|
87
|
+
export interface SpawnReplyMessage {
|
|
88
|
+
type: "spawn_reply";
|
|
89
|
+
taskId: string;
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Overlay → sinain-core: reply to a spawn permission request */
|
|
94
|
+
export interface SpawnPermissionReplyMessage {
|
|
95
|
+
type: "spawn_permission_reply";
|
|
96
|
+
taskId: string;
|
|
97
|
+
decision: "allow" | "deny";
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
/** Cost update broadcast to overlay. */
|
|
82
101
|
export interface CostMessage {
|
|
83
102
|
type: "cost";
|
|
@@ -108,7 +127,7 @@ export interface CostSnapshot {
|
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage | CostMessage;
|
|
111
|
-
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
|
|
130
|
+
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage | SpawnReplyMessage | SpawnPermissionReplyMessage;
|
|
112
131
|
|
|
113
132
|
/** Abstraction for user commands (text now, voice later). */
|
|
114
133
|
export interface UserCommand {
|
|
@@ -163,27 +182,6 @@ export interface AudioPipelineConfig {
|
|
|
163
182
|
gainDb: number;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
export interface TraitConfig {
|
|
167
|
-
enabled: boolean;
|
|
168
|
-
configPath: string; // path to ~/.sinain/traits.json
|
|
169
|
-
entropyHigh: boolean; // Phase 2: boosts entropy roll to 15%
|
|
170
|
-
logDir: string; // path to ~/.sinain-core/traits/
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export interface TraitLogEntry {
|
|
174
|
-
ts: string;
|
|
175
|
-
tickId: number;
|
|
176
|
-
enabled: boolean;
|
|
177
|
-
voice: string;
|
|
178
|
-
voice_stat: number;
|
|
179
|
-
voice_confidence: number;
|
|
180
|
-
activation_scores: Record<string, number>;
|
|
181
|
-
context_app: string;
|
|
182
|
-
hud_length: number;
|
|
183
|
-
synthesis: boolean;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
185
|
export interface AudioChunk {
|
|
188
186
|
buffer: Buffer;
|
|
189
187
|
source: string;
|
|
@@ -282,9 +280,6 @@ export interface AgentResult {
|
|
|
282
280
|
tokensOut: number;
|
|
283
281
|
model: string;
|
|
284
282
|
parsedOk: boolean;
|
|
285
|
-
voice?: string;
|
|
286
|
-
voice_stat?: number;
|
|
287
|
-
voice_confidence?: number;
|
|
288
283
|
/** Actual USD cost returned by OpenRouter (undefined if not available). */
|
|
289
284
|
cost?: number;
|
|
290
285
|
}
|
|
@@ -396,7 +391,7 @@ export interface BridgeState {
|
|
|
396
391
|
audio: "active" | "muted";
|
|
397
392
|
mic: "active" | "muted";
|
|
398
393
|
screen: "active" | "off";
|
|
399
|
-
|
|
394
|
+
escalation: "active" | "paused";
|
|
400
395
|
connection: "connected" | "disconnected" | "connecting";
|
|
401
396
|
}
|
|
402
397
|
|
|
@@ -479,6 +474,5 @@ export interface CoreConfig {
|
|
|
479
474
|
costDisplayEnabled: boolean;
|
|
480
475
|
traceDir: string;
|
|
481
476
|
learningConfig: LearningConfig;
|
|
482
|
-
traitConfig: TraitConfig;
|
|
483
477
|
privacyConfig: PrivacyConfig;
|
|
484
478
|
}
|
|
@@ -451,6 +451,34 @@ server.tool(
|
|
|
451
451
|
},
|
|
452
452
|
);
|
|
453
453
|
|
|
454
|
+
// 15. sinain_ask_user — blocking question to the user via overlay
|
|
455
|
+
server.tool(
|
|
456
|
+
"sinain_ask_user",
|
|
457
|
+
"Ask the user a question and wait for their reply. Use when you need clarification, confirmation, or a decision. The question appears on the user's HUD overlay and blocks until they respond.",
|
|
458
|
+
{
|
|
459
|
+
question: z.string().describe("The question to ask the user"),
|
|
460
|
+
},
|
|
461
|
+
async ({ question }) => {
|
|
462
|
+
// Use the spawn task ID from the environment if available
|
|
463
|
+
const taskId = process.env.SINAIN_SPAWN_TASK_ID || `ask-${Date.now()}`;
|
|
464
|
+
try {
|
|
465
|
+
const resp = await fetch(`${SINAIN_CORE_URL}/spawn/ask`, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { "Content-Type": "application/json" },
|
|
468
|
+
body: JSON.stringify({ taskId, question }),
|
|
469
|
+
signal: AbortSignal.timeout(6 * 60_000), // 6 min (server times out at 5)
|
|
470
|
+
});
|
|
471
|
+
const data = await resp.json() as { ok: boolean; answer?: string };
|
|
472
|
+
if (data.ok && data.answer) {
|
|
473
|
+
return textResult(`User replied: ${data.answer}`);
|
|
474
|
+
}
|
|
475
|
+
return textResult("User did not reply.");
|
|
476
|
+
} catch (err: any) {
|
|
477
|
+
return textResult(`Failed to ask user: ${err.message}`);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
|
|
454
482
|
// ---------------------------------------------------------------------------
|
|
455
483
|
// Startup
|
|
456
484
|
// ---------------------------------------------------------------------------
|
|
@@ -206,27 +206,6 @@ def assert_playbook_header_footer_intact(playbook_text: str) -> dict:
|
|
|
206
206
|
f"missing playbook comments: {', '.join(missing)}")
|
|
207
207
|
|
|
208
208
|
|
|
209
|
-
# ---------------------------------------------------------------------------
|
|
210
|
-
# Trait voice assertions (sinain-core wiring verification)
|
|
211
|
-
# ---------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
def assert_situation_has_active_voice(
|
|
214
|
-
situation_content: str, expected_trait: str | None = None
|
|
215
|
-
) -> dict:
|
|
216
|
-
"""Check SITUATION.md contains an Active Voice section (after trait wiring).
|
|
217
|
-
|
|
218
|
-
Called by tick_evaluator.py when processing live ticks that have SITUATION.md
|
|
219
|
-
content and a trait was selected for that tick.
|
|
220
|
-
"""
|
|
221
|
-
has_section = "## Active Voice" in situation_content
|
|
222
|
-
if not has_section:
|
|
223
|
-
return _result("situation_has_active_voice", False, "no '## Active Voice' section")
|
|
224
|
-
if expected_trait and expected_trait not in situation_content:
|
|
225
|
-
return _result("situation_has_active_voice", False,
|
|
226
|
-
f"section present but '{expected_trait}' not found")
|
|
227
|
-
return _result("situation_has_active_voice", True, "Active Voice section present")
|
|
228
|
-
|
|
229
|
-
|
|
230
209
|
# ---------------------------------------------------------------------------
|
|
231
210
|
# Runner: execute all applicable assertions for a tick
|
|
232
211
|
# ---------------------------------------------------------------------------
|