create-principles-disciple 1.122.0 → 1.122.2
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/console/package.json +1 -1
- package/core/dist/runtime-v2/__tests__/admission-gate.test.js +23 -6
- package/core/dist/runtime-v2/__tests__/admission-gate.test.js.map +1 -1
- package/core/dist/runtime-v2/__tests__/context-payload-validation.test.js +1 -1
- package/core/dist/runtime-v2/__tests__/context-payload-validation.test.js.map +1 -1
- package/core/dist/runtime-v2/__tests__/evidence-guards.test.js +2 -2
- package/core/dist/runtime-v2/__tests__/evidence-guards.test.js.map +1 -1
- package/core/dist/runtime-v2/__tests__/golden-path-diagnostician-e2e.test.js +1 -1
- package/core/dist/runtime-v2/__tests__/golden-path-diagnostician-e2e.test.js.map +1 -1
- package/core/dist/runtime-v2/__tests__/pain-evidence-contract.test.js +3 -3
- package/core/dist/runtime-v2/__tests__/pain-evidence-contract.test.js.map +1 -1
- package/core/dist/runtime-v2/__tests__/pain-signal-bridge-result-shaping.test.js +1 -1
- package/core/dist/runtime-v2/__tests__/pain-signal-bridge-result-shaping.test.js.map +1 -1
- package/core/dist/runtime-v2/admission-gate.d.ts +8 -1
- package/core/dist/runtime-v2/admission-gate.d.ts.map +1 -1
- package/core/dist/runtime-v2/admission-gate.js +14 -0
- package/core/dist/runtime-v2/admission-gate.js.map +1 -1
- package/core/dist/runtime-v2/context-payload.d.ts +9 -3
- package/core/dist/runtime-v2/context-payload.d.ts.map +1 -1
- package/core/dist/runtime-v2/context-payload.js +3 -1
- package/core/dist/runtime-v2/context-payload.js.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/__tests__/observation-resolver.test.js +2 -2
- package/core/dist/runtime-v2/evidence-triage/__tests__/observation-resolver.test.js.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/observation-resolver.d.ts +1 -1
- package/core/dist/runtime-v2/evidence-triage/observation-resolver.d.ts.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/observation-resolver.js +2 -2
- package/core/dist/runtime-v2/evidence-triage/observation-resolver.js.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/types.d.ts +1 -1
- package/core/dist/runtime-v2/evidence-triage/types.d.ts.map +1 -1
- package/core/dist/runtime-v2/evolution/evolution-types.d.ts +8 -4
- package/core/dist/runtime-v2/evolution/evolution-types.d.ts.map +1 -1
- package/core/dist/runtime-v2/evolution/evolution-types.js +3 -1
- package/core/dist/runtime-v2/evolution/evolution-types.js.map +1 -1
- package/core/dist/runtime-v2/index.d.ts +2 -0
- package/core/dist/runtime-v2/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/index.js +2 -0
- package/core/dist/runtime-v2/index.js.map +1 -1
- package/core/dist/runtime-v2/pain-signal-bridge.d.ts +2 -0
- package/core/dist/runtime-v2/pain-signal-bridge.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-signal-bridge.js +4 -3
- package/core/dist/runtime-v2/pain-signal-bridge.js.map +1 -1
- package/core/dist/runtime-v2/pain-to-principle-service.d.ts +1 -0
- package/core/dist/runtime-v2/pain-to-principle-service.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-to-principle-service.js +1 -0
- package/core/dist/runtime-v2/pain-to-principle-service.js.map +1 -1
- package/core/dist/runtime-v2/runner/__tests__/pain-signal-bridge-admission.test.js +6 -6
- package/core/dist/runtime-v2/runner/__tests__/pain-signal-bridge-admission.test.js.map +1 -1
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.d.ts.map +1 -1
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js +25 -5
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js.map +1 -1
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.test.js +38 -9
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.test.js.map +1 -1
- package/core/dist/runtime-v2/task-status.d.ts +2 -1
- package/core/dist/runtime-v2/task-status.d.ts.map +1 -1
- package/core/dist/runtime-v2/task-status.js +5 -1
- package/core/dist/runtime-v2/task-status.js.map +1 -1
- package/host-runtime/dist/governance-observation-store.d.ts +7 -0
- package/host-runtime/dist/governance-observation-store.js +9 -0
- package/host-runtime/dist/governance-signal-admission.d.ts +228 -0
- package/host-runtime/dist/governance-signal-admission.js +841 -0
- package/host-runtime/dist/index.d.ts +1 -0
- package/host-runtime/dist/index.js +1 -0
- package/host-runtime/dist/production-pain-evidence.d.ts +60 -0
- package/host-runtime/dist/production-pain-evidence.js +73 -10
- package/package.json +2 -2
- package/pd-cli/dist/commands/codex-reconcile.d.ts +22 -0
- package/pd-cli/dist/commands/codex-reconcile.d.ts.map +1 -0
- package/pd-cli/dist/commands/codex-reconcile.js +61 -0
- package/pd-cli/dist/commands/codex-reconcile.js.map +1 -0
- package/pd-cli/dist/commands/pain-retry.d.ts.map +1 -1
- package/pd-cli/dist/commands/pain-retry.js +4 -2
- package/pd-cli/dist/commands/pain-retry.js.map +1 -1
- package/pd-cli/dist/index.js +23 -0
- package/pd-cli/dist/index.js.map +1 -1
- package/plugin/dist/bundle.js +505 -505
- package/plugin/dist/core/signal-collector-host.d.ts +11 -0
- package/plugin/dist/core/signal-keyword-store.d.ts +12 -35
- package/plugin/dist/governance-audit.js +106 -106
- package/plugin/dist/hooks/triage-adapter.d.ts +1 -1
- package/plugin/dist/rulehost-evidence.js +112 -112
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +2 -2
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance Signal Admission — Codex Governance Closure Slice B (PRI-623).
|
|
3
|
+
*
|
|
4
|
+
* The learning entrypoint above Slice A's observations, per Codex Governance
|
|
5
|
+
* Closure SPEC rev 2 §12/§13 and ADR-0020 §11.3/§11.4:
|
|
6
|
+
*
|
|
7
|
+
* Governance Observation → detection → admission → ONE canonical Pain
|
|
8
|
+
* → bounded evidence promotion → ONE pending Diagnostician task
|
|
9
|
+
*
|
|
10
|
+
* Ownership (SPEC §7):
|
|
11
|
+
* - shared synchronous correction-detector semantics (keyword store, rule
|
|
12
|
+
* version, STRONG classification) extracted from the OpenClaw-only wrapper —
|
|
13
|
+
* OpenClaw and Codex consume the SAME store file and the SAME core scan;
|
|
14
|
+
* - canonical pain admission through the ONE existing authority
|
|
15
|
+
* (`production-pain-evidence.ts` derivations + the unique
|
|
16
|
+
* `pain_events.canonical_pain_id` index) — never a second pain identity;
|
|
17
|
+
* - the transactional STRONG-correction rate-limit bucket persisted in
|
|
18
|
+
* trajectory.db (ADR-0020 §11.3: Codex fresh-subprocess hooks make
|
|
19
|
+
* process-local state dead state; OpenClaw's in-memory bucket and the
|
|
20
|
+
* tool-pain cooldown keep their existing behavior);
|
|
21
|
+
* - one admitted pain → exactly one pending Diagnostician task via
|
|
22
|
+
* PainToPrincipleService async mode (Runtime V2 task authority), with the
|
|
23
|
+
* task link persisted on the admission marker;
|
|
24
|
+
* - one narrow, idempotent reconciliation pass for the cross-store crash gaps
|
|
25
|
+
* (trajectory.db ↔ .pd/state.db cannot share a transaction).
|
|
26
|
+
*
|
|
27
|
+
* Exactly-once reasoning: admission marker + pain insert + rate-limit
|
|
28
|
+
* consumption commit in ONE trajectory.db transaction; canonical pain ids are
|
|
29
|
+
* content-derived, so duplicate delivery (live + transcript replay, retries,
|
|
30
|
+
* fresh subprocesses) either finds the marker, finds the pain row, or
|
|
31
|
+
* re-derives the identical id — never a second pain. Ordinary conversation and
|
|
32
|
+
* non-signals write NOTHING here (SPEC §18 scenario 6).
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import Database from 'better-sqlite3';
|
|
37
|
+
import { collectSync, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, sanitizeString, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
|
|
38
|
+
import { deriveProductionCorrectionPainIdentity, deriveProductionToolPainIdentity, hasProductionPainSchema, PRODUCTION_WRITE_TOOLS, } from './production-pain-evidence.js';
|
|
39
|
+
import { promoteGovernanceEvidence, ensureGovernanceSchema } from './governance-observation-store.js';
|
|
40
|
+
import { loadPdConfigForPlugin } from './pd-config.js';
|
|
41
|
+
// ─── Shared detector constants (one truth for both hosts) ───────────────────
|
|
42
|
+
export const GOVERNANCE_STRONG_PAIN_SCORE = 70;
|
|
43
|
+
export const GOVERNANCE_STRONG_RATE_LIMIT_PER_HOUR = 5;
|
|
44
|
+
export const GOVERNANCE_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|
45
|
+
/** Sync-only detection config: ambiguous candidates are NOT signals here (§12). */
|
|
46
|
+
const SYNC_DETECTION_CONFIG = {
|
|
47
|
+
enableLlmStage: false,
|
|
48
|
+
llmTimeoutMs: 0,
|
|
49
|
+
promptTemplate: '',
|
|
50
|
+
strongPainScore: GOVERNANCE_STRONG_PAIN_SCORE,
|
|
51
|
+
strongRateLimitPerHour: GOVERNANCE_STRONG_RATE_LIMIT_PER_HOUR,
|
|
52
|
+
};
|
|
53
|
+
const MAX_REASON_BOUND = 300;
|
|
54
|
+
// ─── Small shared guards ────────────────────────────────────────────────────
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function own(value, key) {
|
|
59
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
60
|
+
}
|
|
61
|
+
function rowField(row, key) {
|
|
62
|
+
return isRecord(row) ? own(row, key) : undefined;
|
|
63
|
+
}
|
|
64
|
+
// ─── Shared correction keyword store (host-neutral extraction) ──────────────
|
|
65
|
+
const KEYWORD_STORE_FILE = 'correction_keywords.json';
|
|
66
|
+
/** learned 词进入高精度 deterministic path 的权重阈值(仅 seed/owner_promoted; llm_learned 恒 ambiguous) */
|
|
67
|
+
export const HIGH_PRECISION_LEARNED_WEIGHT = 0.7;
|
|
68
|
+
/** 高精度纠正短语 overlay(已验证的确定性 STRONG 路径,不属于 learner seed 集) */
|
|
69
|
+
export const HIGH_PRECISION_CORRECTION_OVERLAY = [
|
|
70
|
+
['这是错的', 0.9],
|
|
71
|
+
['不要自作主张', 0.9],
|
|
72
|
+
['不应该这么做', 0.9],
|
|
73
|
+
];
|
|
74
|
+
/** empathy seed overlay(检测行为不变) */
|
|
75
|
+
export const EMPATHY_SEED_OVERLAY = [
|
|
76
|
+
['搞什么', 0.5],
|
|
77
|
+
];
|
|
78
|
+
function isValidLearnedKeyword(v) {
|
|
79
|
+
if (typeof v !== 'object' || v === null)
|
|
80
|
+
return false;
|
|
81
|
+
const k = v;
|
|
82
|
+
if (typeof k.term !== 'string' || k.term.length === 0)
|
|
83
|
+
return false;
|
|
84
|
+
if (typeof k.weight !== 'number' || !Number.isFinite(k.weight))
|
|
85
|
+
return false;
|
|
86
|
+
return k.source === 'seed' || k.source === 'llm' || k.source === 'user';
|
|
87
|
+
}
|
|
88
|
+
function mapLearnedSource(source) {
|
|
89
|
+
if (source === 'llm')
|
|
90
|
+
return 'llm_learned';
|
|
91
|
+
if (source === 'user')
|
|
92
|
+
return 'owner_promoted';
|
|
93
|
+
return 'seed';
|
|
94
|
+
}
|
|
95
|
+
function precisionFor(source, weight) {
|
|
96
|
+
if (source === 'llm')
|
|
97
|
+
return 'ambiguous';
|
|
98
|
+
return weight >= HIGH_PRECISION_LEARNED_WEIGHT ? 'high' : 'ambiguous';
|
|
99
|
+
}
|
|
100
|
+
function projectLearnedStore(raw) {
|
|
101
|
+
if (typeof raw !== 'object' || raw === null)
|
|
102
|
+
return null;
|
|
103
|
+
const keywords = raw.keywords;
|
|
104
|
+
if (!Array.isArray(keywords))
|
|
105
|
+
return null;
|
|
106
|
+
const terms = {};
|
|
107
|
+
let learnedCount = 0;
|
|
108
|
+
for (const kw of keywords) {
|
|
109
|
+
if (!isValidLearnedKeyword(kw))
|
|
110
|
+
continue;
|
|
111
|
+
const term = kw.term.trim().toLowerCase();
|
|
112
|
+
if (!term || Object.hasOwn(terms, term))
|
|
113
|
+
continue;
|
|
114
|
+
const weight = Math.max(0, Math.min(1, kw.weight));
|
|
115
|
+
terms[term] = {
|
|
116
|
+
term,
|
|
117
|
+
category: 'correction',
|
|
118
|
+
weight,
|
|
119
|
+
precision: precisionFor(kw.source, weight),
|
|
120
|
+
source: mapLearnedSource(kw.source),
|
|
121
|
+
};
|
|
122
|
+
if (kw.source === 'llm')
|
|
123
|
+
learnedCount += 1;
|
|
124
|
+
}
|
|
125
|
+
return { terms, learnedCount };
|
|
126
|
+
}
|
|
127
|
+
export function buildSharedSeedKeywordStore() {
|
|
128
|
+
const terms = {};
|
|
129
|
+
for (const [term, weight] of HIGH_PRECISION_CORRECTION_OVERLAY) {
|
|
130
|
+
terms[term] = { term, category: 'correction', weight, precision: 'high', source: 'seed' };
|
|
131
|
+
}
|
|
132
|
+
for (const [term, weight] of EMPATHY_SEED_OVERLAY) {
|
|
133
|
+
terms[term] = { term, category: 'empathy', weight, precision: 'ambiguous', source: 'seed' };
|
|
134
|
+
}
|
|
135
|
+
for (const kw of CORRECTION_SEED_KEYWORDS) {
|
|
136
|
+
if (Object.hasOwn(terms, kw.term))
|
|
137
|
+
continue;
|
|
138
|
+
terms[kw.term] = {
|
|
139
|
+
term: kw.term,
|
|
140
|
+
category: 'correction',
|
|
141
|
+
weight: kw.weight,
|
|
142
|
+
precision: kw.weight >= HIGH_PRECISION_LEARNED_WEIGHT ? 'high' : 'ambiguous',
|
|
143
|
+
source: 'seed',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { version: 2, terms };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Host-neutral live keyword store (extracted from the OpenClaw-only wrapper,
|
|
150
|
+
* SPEC §12): reads the per-workspace `<workspace>/.state/correction_keywords.json`
|
|
151
|
+
* learner projection with mtime-based refresh, falling back to the seed store.
|
|
152
|
+
* OpenClaw and Codex resolve detection through this SAME store. The store file
|
|
153
|
+
* must stay a direct child of the workspace state directory (boundary guard).
|
|
154
|
+
*/
|
|
155
|
+
export function createSharedCorrectionKeywordStore(options) {
|
|
156
|
+
const stateDirResolved = path.resolve(options.workspaceDir, '.state');
|
|
157
|
+
const filePath = path.join(stateDirResolved, KEYWORD_STORE_FILE);
|
|
158
|
+
if (!filePath.startsWith(stateDirResolved + path.sep) || path.basename(filePath) !== KEYWORD_STORE_FILE) {
|
|
159
|
+
options.onDegradation?.('SIGNAL_KEYWORD_STORE_INVALID', `resolved store path escapes the workspace state directory; refusing to load (path=${filePath.slice(0, 120)})`);
|
|
160
|
+
return {
|
|
161
|
+
resolve: () => buildSharedSeedKeywordStore(),
|
|
162
|
+
stats: () => ({ totalTerms: 0, learnedTerms: 0, lastReloadedAt: null }),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let cached = null;
|
|
166
|
+
let cachedMtimeMs = null;
|
|
167
|
+
let learnedCount = 0;
|
|
168
|
+
let lastReloadedAt = null;
|
|
169
|
+
const reload = () => {
|
|
170
|
+
let raw;
|
|
171
|
+
try {
|
|
172
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
options.onDegradation?.('SIGNAL_KEYWORD_STORE_INVALID', `cannot read ${KEYWORD_STORE_FILE}; using seed-only store (first run before the optimizer writes is normal)`);
|
|
176
|
+
cached = buildSharedSeedKeywordStore();
|
|
177
|
+
learnedCount = 0;
|
|
178
|
+
lastReloadedAt = new Date().toISOString();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const projected = projectLearnedStore(raw);
|
|
182
|
+
if (!projected) {
|
|
183
|
+
options.onDegradation?.('SIGNAL_KEYWORD_STORE_INVALID', `${KEYWORD_STORE_FILE} malformed (keywords[] missing/invalid); using seed-only store`);
|
|
184
|
+
cached = buildSharedSeedKeywordStore();
|
|
185
|
+
learnedCount = 0;
|
|
186
|
+
lastReloadedAt = new Date().toISOString();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const store = buildSharedSeedKeywordStore();
|
|
190
|
+
for (const [term, entry] of Object.entries(projected.terms)) {
|
|
191
|
+
store.terms[term] = entry;
|
|
192
|
+
}
|
|
193
|
+
cached = store;
|
|
194
|
+
({ learnedCount } = projected);
|
|
195
|
+
lastReloadedAt = new Date().toISOString();
|
|
196
|
+
options.logger?.debug?.(`[PD:Signal] keyword store reloaded: ${Object.keys(store.terms).length} terms (${learnedCount} learned)`);
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
resolve() {
|
|
200
|
+
let mtimeMs = null;
|
|
201
|
+
try {
|
|
202
|
+
({ mtimeMs } = fs.statSync(filePath));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// missing file keeps mtimeMs null → seed-only reload path above
|
|
206
|
+
}
|
|
207
|
+
if (!cached || mtimeMs !== cachedMtimeMs) {
|
|
208
|
+
reload();
|
|
209
|
+
cachedMtimeMs = mtimeMs;
|
|
210
|
+
}
|
|
211
|
+
return cached ?? buildSharedSeedKeywordStore();
|
|
212
|
+
},
|
|
213
|
+
stats() {
|
|
214
|
+
return {
|
|
215
|
+
totalTerms: cached ? Object.keys(cached.terms).length : 0,
|
|
216
|
+
learnedTerms: learnedCount,
|
|
217
|
+
lastReloadedAt,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The shared synchronous high-precision correction detection (SPEC §12):
|
|
224
|
+
* one keyword store, one rule version, one STRONG classification for both
|
|
225
|
+
* hosts. Codex consumes only the deterministic high-precision path — ambiguous
|
|
226
|
+
* candidates are not signals in a bounded hook (LLM confirmation belongs to
|
|
227
|
+
* hosts that own an async stage).
|
|
228
|
+
*/
|
|
229
|
+
export function evaluateCorrectionSignal(input) {
|
|
230
|
+
const store = input.store ?? createSharedCorrectionKeywordStore({
|
|
231
|
+
workspaceDir: input.workspaceDir,
|
|
232
|
+
...(input.onDegradation ? { onDegradation: input.onDegradation } : {}),
|
|
233
|
+
}).resolve();
|
|
234
|
+
const output = collectSync(input.text, input.sessionId, store, SYNC_DETECTION_CONFIG, input.detectedAt);
|
|
235
|
+
return { output, ruleVersion: store.version };
|
|
236
|
+
}
|
|
237
|
+
const ADMISSION_CREATE_STATEMENTS = [
|
|
238
|
+
`CREATE TABLE IF NOT EXISTS governance_signal_admissions (
|
|
239
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
240
|
+
host_kind TEXT NOT NULL,
|
|
241
|
+
logical_observation_key TEXT NOT NULL,
|
|
242
|
+
rollout_identity TEXT NOT NULL,
|
|
243
|
+
root_session_id TEXT NOT NULL,
|
|
244
|
+
signal_kind TEXT NOT NULL,
|
|
245
|
+
decision TEXT NOT NULL,
|
|
246
|
+
canonical_pain_id TEXT,
|
|
247
|
+
diagnostician_task_id TEXT,
|
|
248
|
+
rule_version INTEGER,
|
|
249
|
+
reason TEXT,
|
|
250
|
+
task_payload_json TEXT,
|
|
251
|
+
created_at TEXT NOT NULL,
|
|
252
|
+
updated_at TEXT NOT NULL,
|
|
253
|
+
UNIQUE(logical_observation_key)
|
|
254
|
+
)`,
|
|
255
|
+
`CREATE INDEX IF NOT EXISTS idx_governance_signal_admissions_missing_task
|
|
256
|
+
ON governance_signal_admissions(decision, diagnostician_task_id)`,
|
|
257
|
+
`CREATE TABLE IF NOT EXISTS governance_correction_rate_limits (
|
|
258
|
+
root_session_id TEXT NOT NULL,
|
|
259
|
+
rule_version INTEGER NOT NULL,
|
|
260
|
+
window_start TEXT NOT NULL,
|
|
261
|
+
count INTEGER NOT NULL,
|
|
262
|
+
updated_at TEXT NOT NULL,
|
|
263
|
+
PRIMARY KEY (root_session_id, rule_version)
|
|
264
|
+
)`,
|
|
265
|
+
];
|
|
266
|
+
function ensureGovernanceAdmissionSchema(db) {
|
|
267
|
+
db.transaction(() => {
|
|
268
|
+
for (const statement of ADMISSION_CREATE_STATEMENTS)
|
|
269
|
+
db.exec(statement);
|
|
270
|
+
})();
|
|
271
|
+
}
|
|
272
|
+
function openAdmissionStore(workspaceDir, factory) {
|
|
273
|
+
const dbPath = path.join(workspaceDir, '.state', 'trajectory.db');
|
|
274
|
+
if (!fs.existsSync(dbPath)) {
|
|
275
|
+
return { ok: false, reason: 'trajectory_db_not_found', nextAction: 'initialize the selected PD workspace (pd runtime init) before enabling conversation ingestion' };
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const db = factory ? factory(dbPath) : new Database(dbPath);
|
|
279
|
+
db.pragma('busy_timeout = 5000');
|
|
280
|
+
db.pragma('journal_mode = WAL');
|
|
281
|
+
ensureGovernanceAdmissionSchema(db);
|
|
282
|
+
// The reconciliation pass reads observation-owned tables (promotion
|
|
283
|
+
// tails): one open makes the whole governance schema ready.
|
|
284
|
+
ensureGovernanceSchema(db);
|
|
285
|
+
return { db, close: () => { try {
|
|
286
|
+
db.close();
|
|
287
|
+
}
|
|
288
|
+
catch { /* write result already determined */ } } };
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
292
|
+
return { ok: false, reason: `trajectory_database_unavailable:${detail}`, nextAction: 'inspect or repair the selected PD trajectory database' };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Internal control-flow signals thrown inside the admission transaction. */
|
|
296
|
+
class RateLimitedSignal extends Error {
|
|
297
|
+
constructor() {
|
|
298
|
+
super('rate_limited');
|
|
299
|
+
this.name = 'RateLimitedSignal';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Fixed-window bucket counted INSIDE the admission transaction. Only admitted
|
|
304
|
+
* corrections consume quota; duplicates short-circuit before the bucket check;
|
|
305
|
+
* a rolled-back transaction rolls the consumption back with it.
|
|
306
|
+
*/
|
|
307
|
+
function tryConsumeRateLimit({ db, rootSessionId, ruleVersion, now }) {
|
|
308
|
+
const nowMs = now.getTime();
|
|
309
|
+
const row = db.prepare('SELECT * FROM governance_correction_rate_limits WHERE root_session_id = ? AND rule_version = ?').get(rootSessionId, ruleVersion);
|
|
310
|
+
let windowStartMs;
|
|
311
|
+
let count;
|
|
312
|
+
if (isRecord(row)) {
|
|
313
|
+
const start = rowField(row, 'window_start');
|
|
314
|
+
windowStartMs = typeof start === 'string' ? Date.parse(start) : Number.NaN;
|
|
315
|
+
count = typeof rowField(row, 'count') === 'number' ? rowField(row, 'count') : 0;
|
|
316
|
+
if (!Number.isFinite(windowStartMs) || nowMs - windowStartMs >= GOVERNANCE_RATE_LIMIT_WINDOW_MS) {
|
|
317
|
+
windowStartMs = nowMs;
|
|
318
|
+
count = 0;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
windowStartMs = nowMs;
|
|
323
|
+
count = 0;
|
|
324
|
+
}
|
|
325
|
+
if (count >= GOVERNANCE_STRONG_RATE_LIMIT_PER_HOUR)
|
|
326
|
+
return false;
|
|
327
|
+
db.prepare(`INSERT INTO governance_correction_rate_limits (root_session_id, rule_version, window_start, count, updated_at)
|
|
328
|
+
VALUES (?, ?, ?, ?, ?)
|
|
329
|
+
ON CONFLICT(root_session_id, rule_version) DO UPDATE SET window_start = excluded.window_start, count = excluded.count, updated_at = excluded.updated_at`)
|
|
330
|
+
.run(rootSessionId, ruleVersion, new Date(windowStartMs).toISOString(), count + 1, now.toISOString());
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
function insertCorrectionPain({ db, candidate, workspaceDir, painId, detection, nowIso }) {
|
|
334
|
+
const reason = detection.matchedTerms.length > 0
|
|
335
|
+
? `User correction detected: ${detection.matchedTerms.join(', ')}`
|
|
336
|
+
: 'User correction detected';
|
|
337
|
+
// P1-1 privacy (review round 2): the FULL raw text must pass through the
|
|
338
|
+
// sanitizer BEFORE any truncation — sanitizeString's own contract is
|
|
339
|
+
// redact → path-replace → bound. Slicing first would split a token that
|
|
340
|
+
// crosses the 200-char boundary into a fragment that no longer matches the
|
|
341
|
+
// token regex and would be persisted verbatim. `detection.evidence.excerpt`
|
|
342
|
+
// is NOT used here: buildEvidence already truncated it upstream, so a token
|
|
343
|
+
// straddling that cut would survive sanitization the same way.
|
|
344
|
+
const excerpt = sanitizeString(candidate.text, workspaceDir);
|
|
345
|
+
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
|
|
346
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
347
|
+
.run(candidate.rootSessionId, 'user_correction', GOVERNANCE_STRONG_PAIN_SCORE, reason.slice(0, MAX_REASON_BOUND), 'severe', 'system_infer', null, excerpt, painId, null, nowIso);
|
|
348
|
+
return reason;
|
|
349
|
+
}
|
|
350
|
+
function taskSubmissionPayload(candidate, workspaceDir, reason) {
|
|
351
|
+
// P1-1 privacy (review round 2): sanitize the FULL text before any
|
|
352
|
+
// truncation — a token crossing the 200-char boundary must be redacted, not
|
|
353
|
+
// split into a fragment that survives verbatim. sanitizeString bounds itself.
|
|
354
|
+
const payload = candidate.kind === 'user_correction'
|
|
355
|
+
? {
|
|
356
|
+
painType: 'user_frustration',
|
|
357
|
+
source: 'user_correction',
|
|
358
|
+
reason: reason || 'User correction detected',
|
|
359
|
+
score: GOVERNANCE_STRONG_PAIN_SCORE,
|
|
360
|
+
sessionId: candidate.rootSessionId,
|
|
361
|
+
hostKind: 'codex',
|
|
362
|
+
evidence: [{ sourceRef: `governance_observation:${candidate.logicalObservationKey}`, note: sanitizeString(candidate.text, workspaceDir) }],
|
|
363
|
+
}
|
|
364
|
+
: {
|
|
365
|
+
painType: 'tool_failure',
|
|
366
|
+
source: candidate.toolName,
|
|
367
|
+
reason: reason || `tool=${candidate.toolName}`,
|
|
368
|
+
score: 70,
|
|
369
|
+
sessionId: candidate.rootSessionId,
|
|
370
|
+
hostKind: 'codex',
|
|
371
|
+
evidence: [{ sourceRef: `governance_observation:${candidate.logicalObservationKey}`, note: sanitizeString(reason || candidate.toolName, workspaceDir) }],
|
|
372
|
+
};
|
|
373
|
+
return JSON.stringify(payload);
|
|
374
|
+
}
|
|
375
|
+
function insertAdmissionMarker({ db, candidate, workspaceDir, canonicalPainId, ruleVersion, reason, nowIso }) {
|
|
376
|
+
const payload = taskSubmissionPayload(candidate, workspaceDir, reason);
|
|
377
|
+
db.prepare(`INSERT INTO governance_signal_admissions
|
|
378
|
+
(host_kind, logical_observation_key, rollout_identity, root_session_id, signal_kind, decision, canonical_pain_id, diagnostician_task_id, rule_version, reason, task_payload_json, created_at, updated_at)
|
|
379
|
+
VALUES (?, ?, ?, ?, ?, 'admitted', ?, NULL, ?, ?, ?, ?, ?)`)
|
|
380
|
+
.run(candidate.hostKind, candidate.logicalObservationKey, candidate.rolloutIdentity, candidate.rootSessionId, candidate.kind, canonicalPainId, ruleVersion, reason.slice(0, MAX_REASON_BOUND) || null, payload, nowIso, nowIso);
|
|
381
|
+
}
|
|
382
|
+
function admitCorrection({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDegradation }) {
|
|
383
|
+
// Detection runs OUTSIDE the write transaction (pure scan of the shared
|
|
384
|
+
// keyword store); its outcome feeds the transaction below.
|
|
385
|
+
const detection = evaluateCorrectionSignal({
|
|
386
|
+
workspaceDir,
|
|
387
|
+
text: candidate.text,
|
|
388
|
+
sessionId: candidate.rootSessionId,
|
|
389
|
+
detectedAt: nowIso,
|
|
390
|
+
...(keywordStore ? { store: keywordStore } : {}),
|
|
391
|
+
...(onDegradation ? { onDegradation } : {}),
|
|
392
|
+
});
|
|
393
|
+
const isStrongHighPrecision = detection.output.isSignal
|
|
394
|
+
&& detection.output.strength === 'STRONG'
|
|
395
|
+
&& detection.output.matchedPrecision === 'high';
|
|
396
|
+
if (!isStrongHighPrecision) {
|
|
397
|
+
// Ordinary conversation / weak negation / ambiguous candidates: no pain,
|
|
398
|
+
// no task, no quota, and no marker (SPEC §18 scenario 6 — silence).
|
|
399
|
+
return { disposition: 'not_a_signal', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey };
|
|
400
|
+
}
|
|
401
|
+
const { painId } = deriveProductionCorrectionPainIdentity({
|
|
402
|
+
workspaceDir,
|
|
403
|
+
sessionId: candidate.rootSessionId,
|
|
404
|
+
// Codex occurrence identity = the stable host turn id (SPEC §10): retry of
|
|
405
|
+
// the same real occurrence → same pain; same text in a later real turn →
|
|
406
|
+
// a NEW pain occurrence.
|
|
407
|
+
occurrenceId: candidate.hostTurnId,
|
|
408
|
+
text: candidate.text,
|
|
409
|
+
});
|
|
410
|
+
let reason = '';
|
|
411
|
+
let duplicate = false;
|
|
412
|
+
try {
|
|
413
|
+
db.transaction(() => {
|
|
414
|
+
if (db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?').get(painId) !== undefined) {
|
|
415
|
+
// Live + transcript duplicate or a crash-replay: the canonical pain
|
|
416
|
+
// already exists — no second row, no second quota consumption.
|
|
417
|
+
duplicate = true;
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if (!tryConsumeRateLimit({ db, rootSessionId: candidate.rootSessionId, ruleVersion: detection.ruleVersion, now })) {
|
|
421
|
+
throw new RateLimitedSignal();
|
|
422
|
+
}
|
|
423
|
+
reason = insertCorrectionPain({ db, candidate, workspaceDir, painId, detection: detection.output, nowIso });
|
|
424
|
+
}
|
|
425
|
+
insertAdmissionMarker({ db, candidate, workspaceDir, canonicalPainId: painId, ruleVersion: detection.ruleVersion, reason, nowIso });
|
|
426
|
+
})();
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (error instanceof RateLimitedSignal) {
|
|
430
|
+
return { disposition: 'rate_limited', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey };
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
return { disposition: 'admitted', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, canonicalPainId: painId, duplicate, ruleVersion: detection.ruleVersion };
|
|
435
|
+
}
|
|
436
|
+
function admitToolFailure({ db, candidate, workspaceDir, nowIso }) {
|
|
437
|
+
const identity = deriveProductionToolPainIdentity({
|
|
438
|
+
workspaceDir,
|
|
439
|
+
sessionId: candidate.rootSessionId,
|
|
440
|
+
turnId: candidate.hostTurnId,
|
|
441
|
+
toolName: candidate.toolName,
|
|
442
|
+
source: candidate.source,
|
|
443
|
+
...(candidate.toolInput !== undefined ? { toolInput: candidate.toolInput } : {}),
|
|
444
|
+
...(candidate.toolOutput !== undefined ? { toolOutput: candidate.toolOutput } : {}),
|
|
445
|
+
});
|
|
446
|
+
const { outcome, sanitizedParams, paramsJson, resultPreview, painId } = identity;
|
|
447
|
+
// Admission gate — identical semantics to the live production handler on a
|
|
448
|
+
// fresh process (ADR-0020 accepts cold in-memory cooldowns for Codex;
|
|
449
|
+
// canonical idempotency is the guard).
|
|
450
|
+
const sourceObservation = buildToolFailureObservation({ toolName: candidate.toolName, error: outcome.error, exitCode: outcome.exitCode });
|
|
451
|
+
const sourceKind = resolveSourceKind({
|
|
452
|
+
observedAt: nowIso,
|
|
453
|
+
workspaceId: workspaceDir,
|
|
454
|
+
sessionId: candidate.rootSessionId,
|
|
455
|
+
toolName: candidate.toolName,
|
|
456
|
+
failureSource: sourceObservation.failureSource,
|
|
457
|
+
toolNotFound: sourceObservation.toolNotFound,
|
|
458
|
+
nonZeroExit: outcome.exitCode !== 0,
|
|
459
|
+
});
|
|
460
|
+
// Risk evaluation mirrors the production handler: it reads the RAW params
|
|
461
|
+
// (sanitized params replace paths with placeholders and would lose the
|
|
462
|
+
// absolute-path risk signal). Identity still uses sanitized params.
|
|
463
|
+
const rawFile = isRecord(outcome.params) ? (own(outcome.params, 'file_path') ?? own(outcome.params, 'path')) : undefined;
|
|
464
|
+
const relativePath = String(rawFile ?? 'unknown').slice(0, 500);
|
|
465
|
+
const isRisky = path.isAbsolute(relativePath) && !path.resolve(relativePath).startsWith(`${workspaceDir}${path.sep}`);
|
|
466
|
+
const painScore = Math.min(100, (outcome.exitCode !== 0 ? 70 : 0) + (isRisky ? 20 : 0));
|
|
467
|
+
const triage = evaluateTriage({ sourceKind, score: painScore, isRisky });
|
|
468
|
+
const trigger = evaluateTriggerController({ triageResult: triage, isOwnerManual: false, isCooldownActive: false, isValid: true, score: painScore, sessionId: candidate.rootSessionId });
|
|
469
|
+
const gateAdmitted = outcome.failure && PRODUCTION_WRITE_TOOLS.has(candidate.toolName) && trigger.shouldCreateDiagnosticTask;
|
|
470
|
+
void sanitizedParams;
|
|
471
|
+
if (!gateAdmitted) {
|
|
472
|
+
return { disposition: 'not_admitted', kind: 'tool_failure', logicalObservationKey: candidate.logicalObservationKey, reason: trigger.reason };
|
|
473
|
+
}
|
|
474
|
+
let duplicate = false;
|
|
475
|
+
let duplicateWithoutPain = false;
|
|
476
|
+
let reason = '';
|
|
477
|
+
db.transaction(() => {
|
|
478
|
+
// Same duplicate probe as the production handler: an identical tool_calls
|
|
479
|
+
// row means this exact call was already recorded.
|
|
480
|
+
if (db.prepare('SELECT 1 FROM tool_calls WHERE session_id = ? AND tool_name = ? AND params_json = ? AND outcome = ? AND exit_code IS ? AND error_message IS ? AND result_preview IS ?')
|
|
481
|
+
.get(candidate.rootSessionId, candidate.toolName, paramsJson, outcome.failure ? 'failure' : 'success', outcome.exitCode, outcome.error ?? null, resultPreview) !== undefined) {
|
|
482
|
+
duplicate = true;
|
|
483
|
+
duplicateWithoutPain = db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?').get(painId) === undefined;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
db.prepare(`INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at`)
|
|
487
|
+
.run(candidate.rootSessionId, nowIso, nowIso);
|
|
488
|
+
db.prepare(`INSERT INTO tool_calls (session_id, tool_name, outcome, duration_ms, exit_code, error_type, error_message, gfi_before, gfi_after, params_json, result_preview, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
489
|
+
.run(candidate.rootSessionId, candidate.toolName, outcome.failure ? 'failure' : 'success', outcome.durationMs ?? null, outcome.exitCode, outcome.error ? outcome.error.split(/[\s:]/, 1)[0] : null, outcome.error ?? null, null, null, paramsJson, resultPreview, nowIso);
|
|
490
|
+
reason = `tool=${candidate.toolName}; error=${outcome.error ?? `exit=${outcome.exitCode}`}; path=${relativePath}`;
|
|
491
|
+
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
492
|
+
.run(candidate.rootSessionId, sourceObservation.failureSource ?? 'tool_failure', painScore, reason, painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild', 'system_infer', null, null, painId, null, nowIso);
|
|
493
|
+
}
|
|
494
|
+
if (!duplicateWithoutPain) {
|
|
495
|
+
insertAdmissionMarker({ db, candidate, workspaceDir, canonicalPainId: painId, ruleVersion: null, reason: sanitizeString(reason, workspaceDir), nowIso });
|
|
496
|
+
}
|
|
497
|
+
// A duplicate call row without an admitted pain mirrors the production
|
|
498
|
+
// handler (no pain insert on duplicates) and records no marker, so a later
|
|
499
|
+
// delivery whose gate reaches threshold can still admit.
|
|
500
|
+
})();
|
|
501
|
+
if (duplicateWithoutPain) {
|
|
502
|
+
return { disposition: 'not_admitted', kind: 'tool_failure', logicalObservationKey: candidate.logicalObservationKey, reason: 'duplicate_tool_call_without_admitted_pain' };
|
|
503
|
+
}
|
|
504
|
+
return { disposition: 'admitted', kind: 'tool_failure', logicalObservationKey: candidate.logicalObservationKey, canonicalPainId: painId, duplicate, ruleVersion: null };
|
|
505
|
+
}
|
|
506
|
+
function admitOne({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDegradation }) {
|
|
507
|
+
if (candidate.hostKind !== 'codex') {
|
|
508
|
+
return { ok: false, reason: 'unsupported_host_kind', nextAction: 'governance signal admission currently accepts codex observations only' };
|
|
509
|
+
}
|
|
510
|
+
const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(candidate.logicalObservationKey);
|
|
511
|
+
if (isRecord(marker)) {
|
|
512
|
+
const canonicalPainId = rowField(marker, 'canonical_pain_id');
|
|
513
|
+
const taskId = rowField(marker, 'diagnostician_task_id');
|
|
514
|
+
return {
|
|
515
|
+
disposition: 'already_admitted',
|
|
516
|
+
kind: candidate.kind,
|
|
517
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
518
|
+
canonicalPainId: typeof canonicalPainId === 'string' ? canonicalPainId : '',
|
|
519
|
+
diagnosticianTaskId: typeof taskId === 'string' ? taskId : null,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (candidate.kind === 'user_correction') {
|
|
523
|
+
return admitCorrection({ db, candidate, workspaceDir, now, nowIso, ...(keywordStore ? { keywordStore } : {}), ...(onDegradation ? { onDegradation } : {}) });
|
|
524
|
+
}
|
|
525
|
+
return admitToolFailure({ db, candidate, workspaceDir, nowIso });
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Admit governance signal candidates. Each candidate commits in its own
|
|
529
|
+
* trajectory.db transaction (marker + pain row + quota together), so one
|
|
530
|
+
* failing candidate never rolls back another's admission.
|
|
531
|
+
*/
|
|
532
|
+
export function admitGovernanceSignals(input) {
|
|
533
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
534
|
+
if (!('db' in opened))
|
|
535
|
+
return opened;
|
|
536
|
+
const { db, close } = opened;
|
|
537
|
+
if (!hasProductionPainSchema(db)) {
|
|
538
|
+
close();
|
|
539
|
+
return { ok: false, reason: 'trajectory_schema_invalid', nextAction: 'run the supported PD workspace migration before enabling conversation ingestion' };
|
|
540
|
+
}
|
|
541
|
+
const now = input.now ?? new Date();
|
|
542
|
+
const nowIso = now.toISOString();
|
|
543
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
544
|
+
const outcomes = [];
|
|
545
|
+
try {
|
|
546
|
+
for (const candidate of input.candidates) {
|
|
547
|
+
outcomes.push(admitOne({ db, candidate, workspaceDir, now, nowIso, ...(input.keywordStore ? { keywordStore: input.keywordStore } : {}), ...(input.onDegradation ? { onDegradation: input.onDegradation } : {}) }));
|
|
548
|
+
}
|
|
549
|
+
return { ok: true, outcomes };
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
553
|
+
return { ok: false, reason: `governance_admission_failed:${detail}`, nextAction: 'inspect the workspace trajectory database and retry; committed candidates remain durable' };
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
close();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ─── Diagnostician continuation (one pain → exactly one pending task) ───────
|
|
560
|
+
function isGovernanceTaskPayload(value) {
|
|
561
|
+
if (!isRecord(value))
|
|
562
|
+
return false;
|
|
563
|
+
const painType = own(value, 'painType');
|
|
564
|
+
if (painType !== 'user_frustration' && painType !== 'tool_failure')
|
|
565
|
+
return false;
|
|
566
|
+
for (const key of ['source', 'reason', 'sessionId']) {
|
|
567
|
+
const candidate = own(value, key);
|
|
568
|
+
if (typeof candidate !== 'string' || candidate.length === 0)
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (typeof own(value, 'score') !== 'number')
|
|
572
|
+
return false;
|
|
573
|
+
if (own(value, 'hostKind') !== 'codex')
|
|
574
|
+
return false;
|
|
575
|
+
const evidence = own(value, 'evidence');
|
|
576
|
+
if (!Array.isArray(evidence) || evidence.length === 0 || evidence.length > 8)
|
|
577
|
+
return false;
|
|
578
|
+
for (const entry of evidence) {
|
|
579
|
+
if (!isRecord(entry))
|
|
580
|
+
return false;
|
|
581
|
+
const sourceRef = own(entry, 'sourceRef');
|
|
582
|
+
const note = own(entry, 'note');
|
|
583
|
+
if (typeof sourceRef !== 'string' || sourceRef.length === 0 || sourceRef.length > 300)
|
|
584
|
+
return false;
|
|
585
|
+
// The note is produced by sanitizeString, whose own contract bounds the
|
|
586
|
+
// output to MAX_EVIDENCE_VALUE_CHARS plus the truncation marker — so the
|
|
587
|
+
// validator must accept that real output bound, not the pre-sanitizer
|
|
588
|
+
// input bound. (Review round 3 P1-1: sanitize FIRST, then let the
|
|
589
|
+
// sanitizer truncate; a stricter check here would reject valid payloads.)
|
|
590
|
+
const MAX_SANITIZED_NOTE_CHARS = MAX_EVIDENCE_VALUE_CHARS + '___TRUNCATED___'.length;
|
|
591
|
+
if (typeof note !== 'string' || note.length > MAX_SANITIZED_NOTE_CHARS)
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Ensure exactly one pending Diagnostician task exists for an admitted
|
|
598
|
+
* canonical pain (SPEC §13). Idempotent across hook retries, reconciliation,
|
|
599
|
+
* and crash restarts: deterministic task id + task-store PK + marker link.
|
|
600
|
+
* Never awaits an LLM — PainToPrincipleService async mode only enqueues.
|
|
601
|
+
*
|
|
602
|
+
* `linkRepaired` is true ONLY when this call actually wrote the marker task
|
|
603
|
+
* link because the task already existed (Case B recovery). A marker that
|
|
604
|
+
* already carried the link reports linkRepaired=false — no fake repair.
|
|
605
|
+
*/
|
|
606
|
+
export async function ensureGovernanceDiagnosticianTask(input) {
|
|
607
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
608
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
609
|
+
if (!('db' in opened))
|
|
610
|
+
return opened;
|
|
611
|
+
const { db, close } = opened;
|
|
612
|
+
try {
|
|
613
|
+
const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ? AND canonical_pain_id = ?').get(input.logicalObservationKey, input.canonicalPainId);
|
|
614
|
+
const existingTaskId = rowField(marker, 'diagnostician_task_id');
|
|
615
|
+
if (isRecord(marker) && typeof existingTaskId === 'string' && existingTaskId.length > 0) {
|
|
616
|
+
return { ok: true, taskId: existingTaskId, created: false, duplicate: true, linkRepaired: false };
|
|
617
|
+
}
|
|
618
|
+
if (!isRecord(marker)) {
|
|
619
|
+
return { ok: false, reason: 'admission_marker_not_found', nextAction: 'admit the signal before ensuring its Diagnostician continuation' };
|
|
620
|
+
}
|
|
621
|
+
const payloadRaw = rowField(marker, 'task_payload_json');
|
|
622
|
+
let payloadParsed;
|
|
623
|
+
try {
|
|
624
|
+
payloadParsed = typeof payloadRaw === 'string' ? JSON.parse(payloadRaw) : null;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
payloadParsed = null;
|
|
628
|
+
}
|
|
629
|
+
if (!isGovernanceTaskPayload(payloadParsed)) {
|
|
630
|
+
return { ok: false, reason: 'admission_task_payload_invalid', nextAction: 're-admit the signal or run reconciliation after repairing the workspace state' };
|
|
631
|
+
}
|
|
632
|
+
const taskId = createDiagnosticianTaskId(input.canonicalPainId);
|
|
633
|
+
// Cross-store crash recovery (SPEC §13): a task may already exist from a
|
|
634
|
+
// crashed prior attempt (crash-after-create, before the link write).
|
|
635
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
636
|
+
await stateManager.initialize();
|
|
637
|
+
const existing = await stateManager.getTask(taskId);
|
|
638
|
+
if (existing === null) {
|
|
639
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
640
|
+
const config = loadPdConfigForPlugin(workspaceDir);
|
|
641
|
+
if (!config.ok) {
|
|
642
|
+
return { ok: false, reason: `pd_config_invalid:${config.errors[0]?.reason ?? 'unknown'}`, nextAction: config.errors[0]?.nextAction ?? 'Repair .pd/config.yaml and run reconciliation.' };
|
|
643
|
+
}
|
|
644
|
+
const service = new PainToPrincipleService({
|
|
645
|
+
workspaceDir,
|
|
646
|
+
stateDir,
|
|
647
|
+
ledgerAdapter: new PrincipleTreeLedgerAdapter({ stateDir }),
|
|
648
|
+
owner: 'codex-governance',
|
|
649
|
+
asyncMode: true,
|
|
650
|
+
effectiveConfig: config.effective,
|
|
651
|
+
getEnvVar: (name) => process.env[name],
|
|
652
|
+
});
|
|
653
|
+
const result = await service.recordPain({
|
|
654
|
+
painId: input.canonicalPainId,
|
|
655
|
+
painType: payloadParsed.painType,
|
|
656
|
+
source: payloadParsed.source,
|
|
657
|
+
reason: payloadParsed.reason,
|
|
658
|
+
score: payloadParsed.score,
|
|
659
|
+
sessionId: payloadParsed.sessionId,
|
|
660
|
+
provenance: 'host_context_bound',
|
|
661
|
+
hostKind: 'codex',
|
|
662
|
+
evidence: [...payloadParsed.evidence],
|
|
663
|
+
recordObservability: true,
|
|
664
|
+
});
|
|
665
|
+
if (result.status === 'failed') {
|
|
666
|
+
return { ok: false, reason: `task_submit_failed:${(result.message ?? result.failureCategory ?? 'unknown').slice(0, 160)}`, nextAction: 'inspect the diagnostician runtime profile; reconciliation retries task creation without losing the admitted pain' };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
db.prepare('UPDATE governance_signal_admissions SET diagnostician_task_id = ?, updated_at = ? WHERE logical_observation_key = ?')
|
|
670
|
+
.run(taskId, new Date().toISOString(), input.logicalObservationKey);
|
|
671
|
+
return {
|
|
672
|
+
ok: true,
|
|
673
|
+
taskId,
|
|
674
|
+
created: existing === null,
|
|
675
|
+
duplicate: existing !== null,
|
|
676
|
+
// The link was just written because the task already existed → a real
|
|
677
|
+
// Case B repair (not a no-op on an already-linked marker).
|
|
678
|
+
linkRepaired: existing !== null,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
683
|
+
return { ok: false, reason: `governance_task_ensure_failed:${detail}`, nextAction: 'inspect the workspace state database; reconciliation retries task creation' };
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
close();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Promote the bounded evidence window (≤12 preceding turns + trigger + next
|
|
691
|
+
* completed assistant turn) for an admitted pain, using Slice A's promotion
|
|
692
|
+
* substrate with the canonical pain id as the caller-provided pain reference.
|
|
693
|
+
*/
|
|
694
|
+
export function promoteAdmittedGovernanceEvidence(input) {
|
|
695
|
+
const result = promoteGovernanceEvidence({
|
|
696
|
+
workspaceDir: input.workspaceDir,
|
|
697
|
+
hostKind: 'codex',
|
|
698
|
+
rolloutIdentity: input.rolloutIdentity,
|
|
699
|
+
triggerLogicalKey: input.triggerLogicalKey,
|
|
700
|
+
painRef: input.canonicalPainId,
|
|
701
|
+
...(input.databaseFactory ? { databaseFactory: input.databaseFactory } : {}),
|
|
702
|
+
});
|
|
703
|
+
if (!result.ok)
|
|
704
|
+
return { ok: false, reason: result.reason, nextAction: result.nextAction };
|
|
705
|
+
return { ok: true, promoted: result.promoted, tailState: result.tailState };
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Given an admitted canonical pain (identified by its marker), ensure both
|
|
709
|
+
* the Diagnostician task AND the evidence promotion exist idempotently.
|
|
710
|
+
* Single ensure path for both fresh admits and crash recovery (already_admitted
|
|
711
|
+
* redelivery). The marker row carries rollout_identity so the function is
|
|
712
|
+
* self-contained (caller only needs workspaceDir, logicalObservationKey, painId).
|
|
713
|
+
*/
|
|
714
|
+
export async function ensureGovernanceContinuation(input) {
|
|
715
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
716
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
717
|
+
if (!('db' in opened))
|
|
718
|
+
return opened;
|
|
719
|
+
const { db, close } = opened;
|
|
720
|
+
try {
|
|
721
|
+
const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ? AND canonical_pain_id = ?').get(input.logicalObservationKey, input.canonicalPainId);
|
|
722
|
+
if (!isRecord(marker)) {
|
|
723
|
+
return { ok: false, reason: 'admission_marker_not_found', nextAction: 'admit the signal before ensuring its continuation' };
|
|
724
|
+
}
|
|
725
|
+
const rolloutIdentity = rowField(marker, 'rollout_identity');
|
|
726
|
+
if (typeof rolloutIdentity !== 'string' || rolloutIdentity.length === 0) {
|
|
727
|
+
return { ok: false, reason: 'marker_missing_rollout_identity', nextAction: 're-admit the signal' };
|
|
728
|
+
}
|
|
729
|
+
// 1. Ensure task (idempotent)
|
|
730
|
+
const ensured = await ensureGovernanceDiagnosticianTask({ workspaceDir, logicalObservationKey: input.logicalObservationKey, canonicalPainId: input.canonicalPainId });
|
|
731
|
+
if (!ensured.ok) {
|
|
732
|
+
return { ok: false, reason: ensured.reason, nextAction: ensured.nextAction };
|
|
733
|
+
}
|
|
734
|
+
// 2. Ensure promotion (idempotent substrate — already promoted = no-op,
|
|
735
|
+
// pending tail = re-complete, never started = start now).
|
|
736
|
+
const promoted = promoteAdmittedGovernanceEvidence({
|
|
737
|
+
workspaceDir,
|
|
738
|
+
rolloutIdentity,
|
|
739
|
+
triggerLogicalKey: input.logicalObservationKey,
|
|
740
|
+
canonicalPainId: input.canonicalPainId,
|
|
741
|
+
});
|
|
742
|
+
if (!promoted.ok) {
|
|
743
|
+
return { ok: false, reason: promoted.reason, nextAction: promoted.nextAction };
|
|
744
|
+
}
|
|
745
|
+
return { ok: true, taskId: ensured.taskId, taskCreated: ensured.created, linkRepaired: ensured.linkRepaired, promoted: promoted.promoted, tailState: promoted.tailState };
|
|
746
|
+
}
|
|
747
|
+
finally {
|
|
748
|
+
close();
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Narrow idempotent reconciliation between the trajectory admission markers
|
|
753
|
+
* and the Runtime V2 task store (separate SQLite databases — no cross-store
|
|
754
|
+
* transaction exists, SPEC §13). Recovers:
|
|
755
|
+
* - Case A: pain admitted, crash before task creation → create the task now;
|
|
756
|
+
* - Case B: task exists, crash before the link write → repair the link;
|
|
757
|
+
* - Case C: pain+task, crash before promotion → promote evidence now;
|
|
758
|
+
* - Case D: promotion started → pending tail → retry completion once; stale
|
|
759
|
+
* tails are reported (never silently dropped).
|
|
760
|
+
* Not a background worker — Slice C's Companion worker and the CLI call this.
|
|
761
|
+
*/
|
|
762
|
+
export async function reconcileGovernanceContinuation(input) {
|
|
763
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
764
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
765
|
+
if (!('db' in opened)) {
|
|
766
|
+
return { ok: false, reason: opened.reason, nextAction: opened.nextAction, tasksEnsured: 0, linksRepaired: 0, pendingTails: 0, completedTails: 0, staleTails: 0, degradations: [opened.reason] };
|
|
767
|
+
}
|
|
768
|
+
const { db, close } = opened;
|
|
769
|
+
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
|
|
770
|
+
const degradations = [];
|
|
771
|
+
let tasksEnsured = 0;
|
|
772
|
+
let linksRepaired = 0;
|
|
773
|
+
let pendingTails = 0;
|
|
774
|
+
let completedTails = 0;
|
|
775
|
+
try {
|
|
776
|
+
// Scan ONLY admitted markers that need recovery (bounded). A healthy marker
|
|
777
|
+
// — task linked AND promotion started (observation promoted) — does NOT
|
|
778
|
+
// match this predicate, so it leaves the working set and the pass advances
|
|
779
|
+
// past it. Without this, LIMIT always starts at the oldest admitted marker
|
|
780
|
+
// and markers beyond the batch starve forever (review round 3 P1-2).
|
|
781
|
+
//
|
|
782
|
+
// Recovery needed:
|
|
783
|
+
// Case A/B: task link missing (crash before task, or before the link write)
|
|
784
|
+
// Case C: promotion never started (no observation carries this painRef)
|
|
785
|
+
//
|
|
786
|
+
// A pending tail (promotion started, waiting for the next assistant turn)
|
|
787
|
+
// is NOT a recovery need — it's a forward-looking state resolved by future
|
|
788
|
+
// evidence. The live already_admitted redelivery path (ensureContinuation
|
|
789
|
+
// → promote) handles tail completion when new data arrives; reconcile does
|
|
790
|
+
// not retry pending tails, so pending-tail markers do not block later
|
|
791
|
+
// markers. Stale tails are NOT auto-recoverable (substrate refuses to
|
|
792
|
+
// re-arm them): they are counted separately below and reported.
|
|
793
|
+
const markers = db.prepare(`SELECT a.logical_observation_key, a.canonical_pain_id
|
|
794
|
+
FROM governance_signal_admissions a
|
|
795
|
+
WHERE a.decision = 'admitted'
|
|
796
|
+
AND (
|
|
797
|
+
a.diagnostician_task_id IS NULL
|
|
798
|
+
OR NOT EXISTS (SELECT 1 FROM governance_observations o
|
|
799
|
+
WHERE o.promotion_ref = a.canonical_pain_id)
|
|
800
|
+
)
|
|
801
|
+
ORDER BY a.id LIMIT ?`).all(limit);
|
|
802
|
+
for (const row of markers) {
|
|
803
|
+
const key = rowField(row, 'logical_observation_key');
|
|
804
|
+
const painId = rowField(row, 'canonical_pain_id');
|
|
805
|
+
if (typeof key !== 'string' || typeof painId !== 'string')
|
|
806
|
+
continue;
|
|
807
|
+
const cont = await ensureGovernanceContinuation({ workspaceDir, logicalObservationKey: key, canonicalPainId: painId });
|
|
808
|
+
if (cont.ok) {
|
|
809
|
+
// Only count REAL actions: a task actually created, a link actually
|
|
810
|
+
// repaired (task existed but the marker link was missing). Healthy
|
|
811
|
+
// no-op markers are excluded by the predicate above, so a healthy
|
|
812
|
+
// pass reports 0/0 (review round 2 P2).
|
|
813
|
+
if (cont.taskCreated)
|
|
814
|
+
tasksEnsured += 1;
|
|
815
|
+
if (cont.linkRepaired)
|
|
816
|
+
linksRepaired += 1;
|
|
817
|
+
if (cont.tailState === 'pending')
|
|
818
|
+
pendingTails += 1;
|
|
819
|
+
else if (cont.tailState === 'completed')
|
|
820
|
+
completedTails += 1;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
degradations.push(`${key}:${cont.reason}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Also report stale tails scoped to governance admissions.
|
|
827
|
+
const stale = db.prepare(`SELECT COUNT(*) AS n FROM governance_pending_promotion_tails t
|
|
828
|
+
WHERE t.state = 'stale'
|
|
829
|
+
AND EXISTS (SELECT 1 FROM governance_signal_admissions a WHERE a.canonical_pain_id = t.pain_ref)`).get();
|
|
830
|
+
const staleTails = typeof rowField(stale, 'n') === 'number' ? rowField(stale, 'n') : 0;
|
|
831
|
+
return {
|
|
832
|
+
ok: degradations.length === 0,
|
|
833
|
+
...(degradations.length > 0 ? { reason: 'reconciliation_degradations', nextAction: 'inspect the per-item degradations; admitted pains and evidence remain durable' } : {}),
|
|
834
|
+
tasksEnsured, linksRepaired, pendingTails, completedTails, staleTails,
|
|
835
|
+
degradations: degradations.slice(0, 10),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
close();
|
|
840
|
+
}
|
|
841
|
+
}
|