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.
Files changed (82) hide show
  1. package/console/package.json +1 -1
  2. package/core/dist/runtime-v2/__tests__/admission-gate.test.js +23 -6
  3. package/core/dist/runtime-v2/__tests__/admission-gate.test.js.map +1 -1
  4. package/core/dist/runtime-v2/__tests__/context-payload-validation.test.js +1 -1
  5. package/core/dist/runtime-v2/__tests__/context-payload-validation.test.js.map +1 -1
  6. package/core/dist/runtime-v2/__tests__/evidence-guards.test.js +2 -2
  7. package/core/dist/runtime-v2/__tests__/evidence-guards.test.js.map +1 -1
  8. package/core/dist/runtime-v2/__tests__/golden-path-diagnostician-e2e.test.js +1 -1
  9. package/core/dist/runtime-v2/__tests__/golden-path-diagnostician-e2e.test.js.map +1 -1
  10. package/core/dist/runtime-v2/__tests__/pain-evidence-contract.test.js +3 -3
  11. package/core/dist/runtime-v2/__tests__/pain-evidence-contract.test.js.map +1 -1
  12. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-result-shaping.test.js +1 -1
  13. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-result-shaping.test.js.map +1 -1
  14. package/core/dist/runtime-v2/admission-gate.d.ts +8 -1
  15. package/core/dist/runtime-v2/admission-gate.d.ts.map +1 -1
  16. package/core/dist/runtime-v2/admission-gate.js +14 -0
  17. package/core/dist/runtime-v2/admission-gate.js.map +1 -1
  18. package/core/dist/runtime-v2/context-payload.d.ts +9 -3
  19. package/core/dist/runtime-v2/context-payload.d.ts.map +1 -1
  20. package/core/dist/runtime-v2/context-payload.js +3 -1
  21. package/core/dist/runtime-v2/context-payload.js.map +1 -1
  22. package/core/dist/runtime-v2/evidence-triage/__tests__/observation-resolver.test.js +2 -2
  23. package/core/dist/runtime-v2/evidence-triage/__tests__/observation-resolver.test.js.map +1 -1
  24. package/core/dist/runtime-v2/evidence-triage/observation-resolver.d.ts +1 -1
  25. package/core/dist/runtime-v2/evidence-triage/observation-resolver.d.ts.map +1 -1
  26. package/core/dist/runtime-v2/evidence-triage/observation-resolver.js +2 -2
  27. package/core/dist/runtime-v2/evidence-triage/observation-resolver.js.map +1 -1
  28. package/core/dist/runtime-v2/evidence-triage/types.d.ts +1 -1
  29. package/core/dist/runtime-v2/evidence-triage/types.d.ts.map +1 -1
  30. package/core/dist/runtime-v2/evolution/evolution-types.d.ts +8 -4
  31. package/core/dist/runtime-v2/evolution/evolution-types.d.ts.map +1 -1
  32. package/core/dist/runtime-v2/evolution/evolution-types.js +3 -1
  33. package/core/dist/runtime-v2/evolution/evolution-types.js.map +1 -1
  34. package/core/dist/runtime-v2/index.d.ts +2 -0
  35. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  36. package/core/dist/runtime-v2/index.js +2 -0
  37. package/core/dist/runtime-v2/index.js.map +1 -1
  38. package/core/dist/runtime-v2/pain-signal-bridge.d.ts +2 -0
  39. package/core/dist/runtime-v2/pain-signal-bridge.d.ts.map +1 -1
  40. package/core/dist/runtime-v2/pain-signal-bridge.js +4 -3
  41. package/core/dist/runtime-v2/pain-signal-bridge.js.map +1 -1
  42. package/core/dist/runtime-v2/pain-to-principle-service.d.ts +1 -0
  43. package/core/dist/runtime-v2/pain-to-principle-service.d.ts.map +1 -1
  44. package/core/dist/runtime-v2/pain-to-principle-service.js +1 -0
  45. package/core/dist/runtime-v2/pain-to-principle-service.js.map +1 -1
  46. package/core/dist/runtime-v2/runner/__tests__/pain-signal-bridge-admission.test.js +6 -6
  47. package/core/dist/runtime-v2/runner/__tests__/pain-signal-bridge-admission.test.js.map +1 -1
  48. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.d.ts.map +1 -1
  49. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js +25 -5
  50. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js.map +1 -1
  51. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.test.js +38 -9
  52. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.test.js.map +1 -1
  53. package/core/dist/runtime-v2/task-status.d.ts +2 -1
  54. package/core/dist/runtime-v2/task-status.d.ts.map +1 -1
  55. package/core/dist/runtime-v2/task-status.js +5 -1
  56. package/core/dist/runtime-v2/task-status.js.map +1 -1
  57. package/host-runtime/dist/governance-observation-store.d.ts +7 -0
  58. package/host-runtime/dist/governance-observation-store.js +9 -0
  59. package/host-runtime/dist/governance-signal-admission.d.ts +228 -0
  60. package/host-runtime/dist/governance-signal-admission.js +841 -0
  61. package/host-runtime/dist/index.d.ts +1 -0
  62. package/host-runtime/dist/index.js +1 -0
  63. package/host-runtime/dist/production-pain-evidence.d.ts +60 -0
  64. package/host-runtime/dist/production-pain-evidence.js +73 -10
  65. package/package.json +2 -2
  66. package/pd-cli/dist/commands/codex-reconcile.d.ts +22 -0
  67. package/pd-cli/dist/commands/codex-reconcile.d.ts.map +1 -0
  68. package/pd-cli/dist/commands/codex-reconcile.js +61 -0
  69. package/pd-cli/dist/commands/codex-reconcile.js.map +1 -0
  70. package/pd-cli/dist/commands/pain-retry.d.ts.map +1 -1
  71. package/pd-cli/dist/commands/pain-retry.js +4 -2
  72. package/pd-cli/dist/commands/pain-retry.js.map +1 -1
  73. package/pd-cli/dist/index.js +23 -0
  74. package/pd-cli/dist/index.js.map +1 -1
  75. package/plugin/dist/bundle.js +505 -505
  76. package/plugin/dist/core/signal-collector-host.d.ts +11 -0
  77. package/plugin/dist/core/signal-keyword-store.d.ts +12 -35
  78. package/plugin/dist/governance-audit.js +106 -106
  79. package/plugin/dist/hooks/triage-adapter.d.ts +1 -1
  80. package/plugin/dist/rulehost-evidence.js +112 -112
  81. package/plugin/openclaw.plugin.json +1 -1
  82. 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
+ }