principles-disciple 1.14.0 → 1.16.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/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/scripts/bootstrap-rules.mjs +66 -0
- package/scripts/validate-live-path.ts +356 -0
- package/src/core/bootstrap-rules.ts +177 -0
- package/src/core/principle-tree-migration.ts +196 -0
- package/src/service/evolution-worker.ts +81 -61
- package/src/service/monitoring-query-service.ts +277 -0
- package/src/service/nocturnal-service.ts +9 -1
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +10 -2
- package/tests/core/bootstrap-rules.test.ts +582 -0
- package/tests/core/principle-tree-migration.test.ts +77 -0
- package/tests/scripts/validate-live-path.test.ts +286 -0
- package/tests/service/evolution-worker.nocturnal.test.ts +208 -0
- package/tests/service/monitoring-query-service.test.ts +113 -0
- package/tests/service/nocturnal-runtime-hardening.test.ts +85 -0
- package/ui/src/charts.tsx +4 -1
- package/ui/src/pages/ThinkingModelsPage.tsx +9 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "principles-disciple",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Native OpenClaw plugin for Principles Disciple",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/bundle.js",
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
"build:production": "node esbuild.config.js --production && node scripts/build-web.mjs --production && node scripts/verify-build.mjs",
|
|
33
33
|
"test": "vitest run",
|
|
34
34
|
"test:coverage": "vitest run --coverage",
|
|
35
|
-
"lint": "eslint src/"
|
|
35
|
+
"lint": "eslint src/",
|
|
36
|
+
"bootstrap-rules": "node scripts/bootstrap-rules.mjs",
|
|
37
|
+
"validate-live-path": "tsx scripts/validate-live-path.ts"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@testing-library/react": "^16.3.0",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Rule Bootstrap CLI (Phase 17)
|
|
5
|
+
*
|
|
6
|
+
* Seeds 1-3 stub Rule entities for high-value deterministic principles.
|
|
7
|
+
* Idempotent: re-running skips already-existing rules.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npm run bootstrap-rules # default (3 principles)
|
|
11
|
+
* BOOTSTRAP_LIMIT=2 npm run bootstrap-rules # limit to 2 principles
|
|
12
|
+
* STATE_DIR=/path/to/state npm run bootstrap-rules # custom state dir
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
|
|
21
|
+
const PROJECT_ROOT = join(__dirname, '..');
|
|
22
|
+
const STATE_DIR = process.env.STATE_DIR || join(PROJECT_ROOT, '..', '.state');
|
|
23
|
+
const LIMIT = parseInt(process.env.BOOTSTRAP_LIMIT || '3', 10);
|
|
24
|
+
|
|
25
|
+
async function run() {
|
|
26
|
+
let BootstrapModule;
|
|
27
|
+
try {
|
|
28
|
+
BootstrapModule = await import('../dist/core/bootstrap-rules.js');
|
|
29
|
+
} catch {
|
|
30
|
+
console.error('Bootstrap module not found in dist/. Build first: node esbuild.config.js');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { bootstrapRules, validateBootstrap, selectPrinciplesForBootstrap } = BootstrapModule;
|
|
35
|
+
|
|
36
|
+
console.log('Selecting principles for bootstrap...');
|
|
37
|
+
console.log(` State dir: ${STATE_DIR}`);
|
|
38
|
+
console.log(` Limit: ${LIMIT}`);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const selectedIds = selectPrinciplesForBootstrap(STATE_DIR, LIMIT);
|
|
42
|
+
console.log(` Selected ${selectedIds.length} principle(s): ${selectedIds.join(', ')}`);
|
|
43
|
+
|
|
44
|
+
console.log('\nRunning bootstrap...');
|
|
45
|
+
const results = bootstrapRules(STATE_DIR, LIMIT);
|
|
46
|
+
|
|
47
|
+
for (const r of results) {
|
|
48
|
+
console.log(` ${r.status === 'created' ? '+' : '='} ${r.principleId} -> ${r.ruleId} (${r.status})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const created = results.filter((r) => r.status === 'created');
|
|
52
|
+
const skipped = results.filter((r) => r.status === 'skipped');
|
|
53
|
+
console.log(`\nDone: ${created.length} created, ${skipped.length} skipped.`);
|
|
54
|
+
|
|
55
|
+
if (created.length > 0) {
|
|
56
|
+
console.log('\nValidating...');
|
|
57
|
+
const valid = validateBootstrap(STATE_DIR, selectedIds);
|
|
58
|
+
console.log(` Validation: ${valid ? 'PASS' : 'FAIL'}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`\nBootstrap failed: ${err.message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
run();
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate Live Path Script (Phase 18)
|
|
4
|
+
*
|
|
5
|
+
* Validates the end-to-end nocturnal workflow path with bootstrapped principles.
|
|
6
|
+
*
|
|
7
|
+
* Purpose:
|
|
8
|
+
* - Reads bootstrapped rules from principle_training_state.json
|
|
9
|
+
* - Creates synthetic snapshot with recentPain to pass hasUsableNocturnalSnapshot() guard
|
|
10
|
+
* - Enqueues sleep_reflection task with proper file locking
|
|
11
|
+
* - Polls subagent_workflows.db directly for nocturnal workflows
|
|
12
|
+
* - Correlates workflow to queue item via taskId
|
|
13
|
+
* - Verifies state='completed' and explicit resolution (not 'expired')
|
|
14
|
+
* - Outputs summary and exits 0 on success, non-zero on failure
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* tsx scripts/validate-live-path.ts [--verbose]
|
|
18
|
+
*
|
|
19
|
+
* Environment:
|
|
20
|
+
* WORKSPACE_DIR - Optional workspace directory (defaults to process.cwd())
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
|
|
26
|
+
// ─── Constants ───────────────────────────────────────────────────────────
|
|
27
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
28
|
+
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
29
|
+
const LOCK_SUFFIX = '.lock';
|
|
30
|
+
const LOCK_MAX_RETRIES = 50;
|
|
31
|
+
const LOCK_RETRY_DELAY_MS = 50;
|
|
32
|
+
const LOCK_STALE_MS = 30_000;
|
|
33
|
+
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.cwd();
|
|
34
|
+
const STATE_DIR = path.join(WORKSPACE_DIR, '.state');
|
|
35
|
+
const QUEUE_PATH = path.join(STATE_DIR, 'EVOLUTION_QUEUE');
|
|
36
|
+
const QUEUE_LOCK_PATH = QUEUE_PATH + LOCK_SUFFIX;
|
|
37
|
+
const LEDGER_PATH = path.join(STATE_DIR, 'principle_training_state.json');
|
|
38
|
+
const DB_PATH = path.join(STATE_DIR, 'subagent_workflows.db');
|
|
39
|
+
|
|
40
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
41
|
+
interface LedgerRule {
|
|
42
|
+
id: string;
|
|
43
|
+
principleId: string;
|
|
44
|
+
action: string;
|
|
45
|
+
type: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface HybridLedgerStore {
|
|
49
|
+
tree: {
|
|
50
|
+
rules: Record<string, LedgerRule>;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface WorkflowRow {
|
|
55
|
+
workflow_id: string;
|
|
56
|
+
workflow_type: string;
|
|
57
|
+
state: string;
|
|
58
|
+
metadata_json: string;
|
|
59
|
+
created_at: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface QueueItem {
|
|
63
|
+
id: string;
|
|
64
|
+
taskKind: string;
|
|
65
|
+
status: string;
|
|
66
|
+
resolution?: string;
|
|
67
|
+
resultRef?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface LockContext {
|
|
71
|
+
lockPath: string;
|
|
72
|
+
pid: number;
|
|
73
|
+
release: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── File Lock Functions (simplified from file-lock.ts) ──────────────────
|
|
77
|
+
async function acquireLockAsync(filePath: string, options: {
|
|
78
|
+
lockSuffix?: string;
|
|
79
|
+
maxRetries?: number;
|
|
80
|
+
baseRetryDelayMs?: number;
|
|
81
|
+
lockStaleMs?: number;
|
|
82
|
+
} = {}): Promise<LockContext> {
|
|
83
|
+
const opts = {
|
|
84
|
+
lockSuffix: LOCK_SUFFIX,
|
|
85
|
+
maxRetries: LOCK_MAX_RETRIES,
|
|
86
|
+
baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
|
|
87
|
+
lockStaleMs: LOCK_STALE_MS,
|
|
88
|
+
...options,
|
|
89
|
+
};
|
|
90
|
+
const { pid } = process;
|
|
91
|
+
const lockPath = filePath + opts.lockSuffix;
|
|
92
|
+
|
|
93
|
+
for (let attempt = 0; attempt < opts.maxRetries!; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
// Check if lock file exists and is stale
|
|
96
|
+
if (fs.existsSync(lockPath)) {
|
|
97
|
+
const lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
98
|
+
const lockPid = parseInt(lockContent, 10);
|
|
99
|
+
const lockStats = fs.statSync(lockPath);
|
|
100
|
+
const lockAge = Date.now() - lockStats.mtimeMs;
|
|
101
|
+
|
|
102
|
+
// Clean up stale lock
|
|
103
|
+
if (lockAge > opts.lockStaleMs!) {
|
|
104
|
+
fs.unlinkSync(lockPath);
|
|
105
|
+
} else if (lockPid !== pid) {
|
|
106
|
+
// Lock held by another process
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, opts.baseRetryDelayMs!));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Acquire lock
|
|
113
|
+
fs.writeFileSync(lockPath, pid.toString(), { flag: 'wx' });
|
|
114
|
+
return {
|
|
115
|
+
lockPath,
|
|
116
|
+
pid,
|
|
117
|
+
release: () => {
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(lockPath)) {
|
|
120
|
+
fs.unlinkSync(lockPath);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore errors during release
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
129
|
+
if (attempt < opts.maxRetries! - 1) {
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, opts.baseRetryDelayMs!));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`Failed to acquire lock for ${filePath}: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.maxRetries} attempts`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function releaseLock(ctx: LockContext): void {
|
|
142
|
+
ctx.release();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Step 1: Check bootstrapped rules ─────────────────────────────────────
|
|
146
|
+
function loadBootstrappedRules(): LedgerRule[] {
|
|
147
|
+
if (!fs.existsSync(LEDGER_PATH)) {
|
|
148
|
+
throw new Error('FAIL: principle_training_state.json not found. Run Phase 17 bootstrap first: npm run bootstrap-rules');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const ledger: HybridLedgerStore = JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8'));
|
|
152
|
+
const bootstrappedRules = Object.values(ledger.tree.rules).filter(r =>
|
|
153
|
+
r.id.endsWith('_stub_bootstrap')
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return bootstrappedRules;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Step 2: Build synthetic snapshot ──────────────────────────────────────
|
|
160
|
+
function buildSyntheticSnapshot(taskId: string) {
|
|
161
|
+
return {
|
|
162
|
+
sessionId: `validation-${taskId}`,
|
|
163
|
+
startedAt: new Date().toISOString(),
|
|
164
|
+
updatedAt: new Date().toISOString(),
|
|
165
|
+
assistantTurns: [],
|
|
166
|
+
userTurns: [],
|
|
167
|
+
toolCalls: [],
|
|
168
|
+
painEvents: [],
|
|
169
|
+
gateBlocks: [],
|
|
170
|
+
stats: {
|
|
171
|
+
totalAssistantTurns: 0,
|
|
172
|
+
totalToolCalls: 0,
|
|
173
|
+
failureCount: 0,
|
|
174
|
+
totalPainEvents: 1,
|
|
175
|
+
totalGateBlocks: 0,
|
|
176
|
+
},
|
|
177
|
+
recentPain: [{
|
|
178
|
+
source: 'live-validation',
|
|
179
|
+
score: 50,
|
|
180
|
+
severity: 'moderate',
|
|
181
|
+
reason: 'Synthetic snapshot for live path validation',
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
}],
|
|
184
|
+
_dataSource: 'pain_context_fallback',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Step 3: Enqueue sleep_reflection task with proper file locking ──────────
|
|
189
|
+
// Uses acquireLockAsync to prevent TOCTOU race conditions (T-18-01 mitigation)
|
|
190
|
+
async function enqueueSleepReflectionTask(taskId: string): Promise<void> {
|
|
191
|
+
let lockCtx: LockContext | null = null;
|
|
192
|
+
try {
|
|
193
|
+
// Acquire lock before reading queue file (T-18-01 mitigation)
|
|
194
|
+
lockCtx = await acquireLockAsync(QUEUE_PATH, {
|
|
195
|
+
lockSuffix: LOCK_SUFFIX,
|
|
196
|
+
maxRetries: LOCK_MAX_RETRIES,
|
|
197
|
+
baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
|
|
198
|
+
lockStaleMs: LOCK_STALE_MS,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let queue: QueueItem[] = [];
|
|
202
|
+
if (fs.existsSync(QUEUE_PATH)) {
|
|
203
|
+
const queueContent = fs.readFileSync(QUEUE_PATH, 'utf8');
|
|
204
|
+
queue = JSON.parse(queueContent);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
queue.push({
|
|
208
|
+
id: taskId,
|
|
209
|
+
taskKind: 'sleep_reflection',
|
|
210
|
+
status: 'pending',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
fs.writeFileSync(QUEUE_PATH, JSON.stringify(queue, null, 2), 'utf8');
|
|
214
|
+
} finally {
|
|
215
|
+
if (lockCtx) {
|
|
216
|
+
releaseLock(lockCtx);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Step 4: Poll workflow store (raw SQLite, no WorkflowStore import) ─────
|
|
222
|
+
// Uses better-sqlite3 directly to avoid WorkflowStore async initialization issues in standalone script
|
|
223
|
+
function listNocturnalWorkflows(): WorkflowRow[] {
|
|
224
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const Database = require('better-sqlite3');
|
|
229
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
230
|
+
const rows = db.prepare(`
|
|
231
|
+
SELECT workflow_id, workflow_type, state, metadata_json, created_at
|
|
232
|
+
FROM subagent_workflows
|
|
233
|
+
WHERE workflow_type = 'nocturnal'
|
|
234
|
+
ORDER BY created_at DESC
|
|
235
|
+
`).all() as WorkflowRow[];
|
|
236
|
+
db.close();
|
|
237
|
+
return rows;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Step 5: Correlate and verify ─────────────────────────────────────────
|
|
241
|
+
function verifyWorkflowCompletion(taskId: string): {
|
|
242
|
+
workflowId: string;
|
|
243
|
+
state: string;
|
|
244
|
+
resolution: string;
|
|
245
|
+
} | null {
|
|
246
|
+
const workflows = listNocturnalWorkflows();
|
|
247
|
+
|
|
248
|
+
for (const wf of workflows) {
|
|
249
|
+
const meta = JSON.parse(wf.metadata_json);
|
|
250
|
+
if (meta.taskId !== taskId) continue;
|
|
251
|
+
if (wf.state !== 'completed') continue;
|
|
252
|
+
|
|
253
|
+
// Read resolution from queue (resolution is on queue item, not on WorkflowRow)
|
|
254
|
+
let queue: QueueItem[] = [];
|
|
255
|
+
try {
|
|
256
|
+
if (fs.existsSync(QUEUE_PATH)) {
|
|
257
|
+
const queueContent = fs.readFileSync(QUEUE_PATH, 'utf8');
|
|
258
|
+
queue = JSON.parse(queueContent);
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
// Queue file missing or corrupted — resolution unknown
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const queueItem = queue.find(q => q.id === taskId);
|
|
265
|
+
const resolution = queueItem?.resolution;
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
workflowId: wf.workflow_id,
|
|
269
|
+
state: wf.state,
|
|
270
|
+
resolution: resolution || 'MISSING',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Main ─────────────────────────────────────────────────────────────────
|
|
278
|
+
async function main() {
|
|
279
|
+
const verbose = process.argv.includes('--verbose');
|
|
280
|
+
|
|
281
|
+
// 1. Check bootstrapped rules
|
|
282
|
+
let rules: LedgerRule[];
|
|
283
|
+
try {
|
|
284
|
+
rules = loadBootstrappedRules();
|
|
285
|
+
} catch {
|
|
286
|
+
console.error('FAIL: principle_training_state.json not found. Run Phase 17 bootstrap first: npm run bootstrap-rules');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (rules.length === 0) {
|
|
291
|
+
console.error('FAIL: No _stub_bootstrap rules found. Run Phase 17 bootstrap first: npm run bootstrap-rules');
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (verbose) {
|
|
296
|
+
console.log(`Found ${rules.length} bootstrapped rule(s)`);
|
|
297
|
+
for (const rule of rules) {
|
|
298
|
+
console.log(` - ${rule.id} (principleId=${rule.principleId}, action=${rule.action})`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 2. Generate task ID
|
|
303
|
+
const taskId = `validation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
304
|
+
|
|
305
|
+
// 3. Build synthetic snapshot for validation
|
|
306
|
+
const snapshot = buildSyntheticSnapshot(taskId);
|
|
307
|
+
if (verbose) {
|
|
308
|
+
console.log(`Created synthetic snapshot: sessionId=${snapshot.sessionId}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 4. Enqueue task (with lock acquisition)
|
|
312
|
+
try {
|
|
313
|
+
await enqueueSleepReflectionTask(taskId);
|
|
314
|
+
if (verbose) {
|
|
315
|
+
console.log(`Enqueued sleep_reflection task: ${taskId}`);
|
|
316
|
+
}
|
|
317
|
+
} catch (error: unknown) {
|
|
318
|
+
console.error('FAIL: Failed to enqueue sleep_reflection task:', String(error));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 5. Poll for completion
|
|
323
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
324
|
+
if (verbose) {
|
|
325
|
+
console.log('Polling for workflow completion...');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
while (Date.now() < deadline) {
|
|
329
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
330
|
+
|
|
331
|
+
const result = verifyWorkflowCompletion(taskId);
|
|
332
|
+
if (result) {
|
|
333
|
+
console.log(`RESULT: workflow=${result.workflowId} state=${result.state} resolution=${result.resolution} taskId=${taskId}`);
|
|
334
|
+
|
|
335
|
+
if (result.resolution === 'MISSING' || result.resolution === 'expired') {
|
|
336
|
+
console.error('FAIL: resolution not explicit');
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log('PASS: Live path validation successful');
|
|
341
|
+
process.exit(0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (verbose) {
|
|
345
|
+
process.stdout.write('.');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.error('FAIL: Poll timeout — no completed nocturnal workflow found for taskId');
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
main().catch(err => {
|
|
354
|
+
console.error('FAIL:', err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Rule Bootstrap (Phase 17)
|
|
3
|
+
*
|
|
4
|
+
* Bootstraps 1-3 stub Rule entities for high-value deterministic principles.
|
|
5
|
+
* Selected by: observedViolationCount (descending) + evaluability=deterministic.
|
|
6
|
+
* Falls back to all deterministic principles if violation data is sparse.
|
|
7
|
+
*
|
|
8
|
+
* Rule ID format: {principleId}_stub_bootstrap
|
|
9
|
+
* Scope: BOOT-03 — bounded, no mass migration, no implementations.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* npx vitest run tests/core/bootstrap-rules.test.ts (tests)
|
|
13
|
+
* npm run bootstrap-rules (production)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { loadLedger, createRule, updatePrinciple } from './principle-tree-ledger.js';
|
|
17
|
+
import { loadStore } from './principle-training-state.js';
|
|
18
|
+
|
|
19
|
+
export interface BootstrapResult {
|
|
20
|
+
principleId: string;
|
|
21
|
+
ruleId: string;
|
|
22
|
+
status: 'created' | 'skipped';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Select principles for bootstrap based on violation count and evaluability.
|
|
27
|
+
*
|
|
28
|
+
* @param stateDir - State directory path
|
|
29
|
+
* @param limit - Maximum number of principles to select (default: 3)
|
|
30
|
+
* @returns Array of principle IDs sorted by observedViolationCount (descending)
|
|
31
|
+
* @throws Error if no deterministic principles found
|
|
32
|
+
*/
|
|
33
|
+
export function selectPrinciplesForBootstrap(stateDir: string, limit: number = 3): string[] {
|
|
34
|
+
// Load training store to get evaluability and violation data
|
|
35
|
+
const store = loadStore(stateDir);
|
|
36
|
+
|
|
37
|
+
// Filter for deterministic principles only
|
|
38
|
+
const deterministicEntries = Object.values(store).filter(
|
|
39
|
+
(entry) => entry.evaluability === 'deterministic'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (deterministicEntries.length === 0) {
|
|
43
|
+
throw new Error('No deterministic principles found in training store');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sort by observedViolationCount descending, then by principleId for tiebreaker
|
|
47
|
+
const sorted = deterministicEntries.sort((a, b) => {
|
|
48
|
+
const violationDiff = b.observedViolationCount - a.observedViolationCount;
|
|
49
|
+
if (violationDiff !== 0) {
|
|
50
|
+
return violationDiff;
|
|
51
|
+
}
|
|
52
|
+
// Alphabetical tiebreaker
|
|
53
|
+
return a.principleId.localeCompare(b.principleId);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Check if we have sparse violation data (all zeros or very low)
|
|
57
|
+
const hasViolations = sorted.some((entry) => entry.observedViolationCount > 0);
|
|
58
|
+
if (!hasViolations) {
|
|
59
|
+
// Log warning and use all deterministic principles
|
|
60
|
+
console.warn('[bootstrap] No violation data found, using all deterministic principles');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Return top N
|
|
64
|
+
return sorted.slice(0, limit).map((entry) => entry.principleId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bootstrap stub rules for selected principles.
|
|
69
|
+
*
|
|
70
|
+
* Creates stub Rule entities with format {principleId}_stub_bootstrap.
|
|
71
|
+
* Links rules to principles via suggestedRules array.
|
|
72
|
+
* Idempotent: skips existing rules.
|
|
73
|
+
*
|
|
74
|
+
* @param stateDir - State directory path
|
|
75
|
+
* @param limit - Maximum number of principles to bootstrap (default: 3)
|
|
76
|
+
* @returns Array of results indicating created or skipped status
|
|
77
|
+
* @throws Error if no deterministic principles found
|
|
78
|
+
*/
|
|
79
|
+
export function bootstrapRules(stateDir: string, limit: number = 3): BootstrapResult[] {
|
|
80
|
+
// Select principles for bootstrap
|
|
81
|
+
const selectedPrincipleIds = selectPrinciplesForBootstrap(stateDir, limit);
|
|
82
|
+
|
|
83
|
+
// Load current ledger state
|
|
84
|
+
const ledger = loadLedger(stateDir);
|
|
85
|
+
|
|
86
|
+
const results: BootstrapResult[] = [];
|
|
87
|
+
|
|
88
|
+
for (const principleId of selectedPrincipleIds) {
|
|
89
|
+
// Verify principle exists in ledger
|
|
90
|
+
const principle = ledger.tree.principles[principleId];
|
|
91
|
+
if (!principle) {
|
|
92
|
+
throw new Error(`Principle ${principleId} not found in ledger tree`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Compute rule ID
|
|
96
|
+
const ruleId = `${principleId}_stub_bootstrap`;
|
|
97
|
+
|
|
98
|
+
// Check if rule already exists
|
|
99
|
+
if (ledger.tree.rules[ruleId]) {
|
|
100
|
+
results.push({
|
|
101
|
+
principleId,
|
|
102
|
+
ruleId,
|
|
103
|
+
status: 'skipped',
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Create stub rule
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
const rule = createRule(stateDir, {
|
|
111
|
+
id: ruleId,
|
|
112
|
+
version: 1,
|
|
113
|
+
name: `Stub bootstrap rule for ${principleId}`,
|
|
114
|
+
description: `Placeholder rule for principle-internalization bootstrap`,
|
|
115
|
+
type: 'hook',
|
|
116
|
+
triggerCondition: 'stub: bootstrap placeholder',
|
|
117
|
+
enforcement: 'warn',
|
|
118
|
+
action: 'allow (stub)',
|
|
119
|
+
principleId,
|
|
120
|
+
status: 'proposed',
|
|
121
|
+
coverageRate: 0,
|
|
122
|
+
falsePositiveRate: 0,
|
|
123
|
+
implementationIds: [],
|
|
124
|
+
createdAt: now,
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Link rule to principle via suggestedRules
|
|
129
|
+
const existingSuggested = principle.suggestedRules || [];
|
|
130
|
+
updatePrinciple(stateDir, principleId, {
|
|
131
|
+
suggestedRules: [...existingSuggested, ruleId],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
results.push({
|
|
135
|
+
principleId,
|
|
136
|
+
ruleId,
|
|
137
|
+
status: 'created',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return results;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate that bootstrapped state is correct.
|
|
146
|
+
*
|
|
147
|
+
* @param stateDir - State directory path
|
|
148
|
+
* @param expectedPrincipleIds - Principle IDs that should be bootstrapped
|
|
149
|
+
* @returns true if validation passes
|
|
150
|
+
* @throws Error if validation fails
|
|
151
|
+
*/
|
|
152
|
+
export function validateBootstrap(stateDir: string, expectedPrincipleIds: string[]): boolean {
|
|
153
|
+
const ledger = loadLedger(stateDir);
|
|
154
|
+
|
|
155
|
+
for (const principleId of expectedPrincipleIds) {
|
|
156
|
+
// Verify principle exists
|
|
157
|
+
const principle = ledger.tree.principles[principleId];
|
|
158
|
+
if (!principle) {
|
|
159
|
+
throw new Error(`Principle ${principleId} not found in ledger tree`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Verify suggestedRules exists and is not empty
|
|
163
|
+
if (!principle.suggestedRules || principle.suggestedRules.length === 0) {
|
|
164
|
+
throw new Error(`Principle ${principleId} has empty or missing suggestedRules`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Verify each suggested rule exists in ledger
|
|
168
|
+
for (const ruleId of principle.suggestedRules) {
|
|
169
|
+
const rule = ledger.tree.rules[ruleId];
|
|
170
|
+
if (!rule) {
|
|
171
|
+
throw new Error(`Rule ${ruleId} referenced by principle ${principleId} not found in ledger`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return true;
|
|
177
|
+
}
|