create-principles-disciple 1.146.8 → 1.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_release/product-identity.json +1 -1
- package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +6 -0
- package/codex-adapter/dist/ingestion/explicit-owner-statement.js +6 -0
- package/codex-adapter/dist/pd-hook.js +5 -2
- package/codex-adapter/dist/worker/workspace-worker.d.ts +13 -1
- package/codex-adapter/dist/worker/workspace-worker.js +26 -3
- package/codex-adapter/package.json +1 -1
- package/console/dist/server/models/ExaminerEvidenceModel.d.ts +159 -0
- package/console/dist/server/models/ExaminerEvidenceModel.js +274 -0
- package/console/dist/server/models/ReceiptsConsoleModel.js +15 -1
- package/console/dist/server/routes/examiner-executor.d.ts +40 -4
- package/console/dist/server/routes/examiner-executor.js +54 -45
- package/console/dist/server/routes/examiner-runs.d.ts +196 -0
- package/console/dist/server/routes/examiner-runs.js +938 -0
- package/console/dist/server/routes/examiner-study-inputs.d.ts +147 -0
- package/console/dist/server/routes/examiner-study-inputs.js +263 -0
- package/console/dist/server/routes/examiner.d.ts +26 -17
- package/console/dist/server/routes/examiner.js +261 -12
- package/console/dist/types.d.ts +1 -0
- package/console/dist/web/assets/app.css +92 -0
- package/console/dist/web/assets/app.js +38 -32
- package/console/lab/lib/challenge-openclaw-host.mjs +17 -0
- package/console/lab/lib/challenge-runner.mjs +9 -0
- package/console/lab/lib/examiner-study.mjs +55 -0
- package/core/dist/runtime-v2/index.d.ts +2 -2
- package/core/dist/runtime-v2/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/index.js +1 -1
- package/core/dist/runtime-v2/index.js.map +1 -1
- package/core/dist/runtime-v2/pain-ingress-payload.d.ts +13 -1
- package/core/dist/runtime-v2/pain-ingress-payload.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-ingress-payload.js +21 -2
- package/core/dist/runtime-v2/pain-ingress-payload.js.map +1 -1
- package/core/dist/runtime-v2/pain-ingress.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-ingress.js +3 -0
- package/core/dist/runtime-v2/pain-ingress.js.map +1 -1
- package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts +35 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts.map +1 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.js +48 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.js.map +1 -0
- package/core/dist/runtime-v2/signal-collector/index.d.ts +1 -0
- package/core/dist/runtime-v2/signal-collector/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/signal-collector/index.js +1 -0
- package/core/dist/runtime-v2/signal-collector/index.js.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts +4 -6
- package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.js +100 -9
- package/core/dist/runtime-v2/signal-collector/keyword-stage.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 +8 -4
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js.map +1 -1
- package/core/package.json +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +52 -1
- package/dist/installer.js.map +1 -1
- package/dist/installers/mcp-registration-conflict.d.ts +35 -0
- package/dist/installers/mcp-registration-conflict.d.ts.map +1 -0
- package/dist/installers/mcp-registration-conflict.js +559 -0
- package/dist/installers/mcp-registration-conflict.js.map +1 -0
- package/dist/installers/openclaw-host-installer.d.ts.map +1 -1
- package/dist/installers/openclaw-host-installer.js +20 -0
- package/dist/installers/openclaw-host-installer.js.map +1 -1
- package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/dist/installers/workbuddy-host-installer.js +124 -2
- package/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/dist/mcp-server-layout-compat.d.ts +18 -0
- package/dist/mcp-server-layout-compat.d.ts.map +1 -0
- package/dist/mcp-server-layout-compat.js +22 -0
- package/dist/mcp-server-layout-compat.js.map +1 -0
- package/host-runtime/dist/governance-signal-admission.d.ts +147 -4
- package/host-runtime/dist/governance-signal-admission.js +1065 -60
- package/host-runtime/dist/index.d.ts +2 -0
- package/host-runtime/dist/index.js +2 -0
- package/host-runtime/dist/signal-classifier.d.ts +46 -0
- package/host-runtime/dist/signal-classifier.js +167 -0
- package/host-runtime/dist/signal-stage2-cycle.d.ts +42 -0
- package/host-runtime/dist/signal-stage2-cycle.js +184 -0
- package/host-runtime/package.json +1 -1
- package/install-layout/dist/index.d.ts +15 -0
- package/install-layout/dist/index.js +10 -0
- package/install-layout/package.json +1 -1
- package/package.json +9 -7
- package/pd-cli/dist/commands/health-codex.d.ts.map +1 -1
- package/pd-cli/dist/commands/health-codex.js +12 -0
- package/pd-cli/dist/commands/health-codex.js.map +1 -1
- package/pd-cli/dist/commands/health-mcp.d.ts +74 -0
- package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -0
- package/pd-cli/dist/commands/health-mcp.js +723 -0
- package/pd-cli/dist/commands/health-mcp.js.map +1 -0
- package/pd-cli/dist/commands/health.d.ts +2 -0
- package/pd-cli/dist/commands/health.d.ts.map +1 -1
- package/pd-cli/dist/commands/health.js +11 -6
- package/pd-cli/dist/commands/health.js.map +1 -1
- package/pd-mcp-server/dist/budget.d.ts +15 -0
- package/pd-mcp-server/dist/budget.js +19 -0
- package/pd-mcp-server/dist/deadline.d.ts +75 -0
- package/pd-mcp-server/dist/deadline.js +136 -0
- package/pd-mcp-server/dist/envelope.d.ts +52 -0
- package/pd-mcp-server/dist/envelope.js +63 -0
- package/pd-mcp-server/dist/index.d.ts +10 -0
- package/pd-mcp-server/dist/index.js +76 -0
- package/pd-mcp-server/dist/logging.d.ts +12 -0
- package/pd-mcp-server/dist/logging.js +42 -0
- package/pd-mcp-server/dist/server.d.ts +90 -0
- package/pd-mcp-server/dist/server.js +178 -0
- package/pd-mcp-server/dist/version.d.ts +13 -0
- package/pd-mcp-server/dist/version.js +13 -0
- package/pd-mcp-server/dist/workspace.d.ts +23 -0
- package/pd-mcp-server/dist/workspace.js +77 -0
- package/pd-mcp-server/package.json +49 -0
- package/plugin/dist/bundle.js +414 -412
- package/plugin/dist/core/mcp-binding.d.ts +41 -0
- package/plugin/dist/index.d.ts +33 -0
- package/plugin/dist/openclaw.plugin.json +10 -1
- package/plugin/dist/utils/workspace-resolver.d.ts +17 -0
- package/plugin/openclaw.plugin.json +10 -1
- package/plugin/package.json +2 -1
- package/release-manager/dist/installer.js +52 -1
- package/release-manager/dist/installers/mcp-registration-conflict.js +559 -0
- package/release-manager/dist/installers/openclaw-host-installer.js +20 -0
- package/release-manager/dist/installers/workbuddy-host-installer.js +124 -2
- package/release-manager/dist/mcp-server-layout-compat.js +22 -0
- package/release-manager/package.json +7 -5
- package/workbuddy-adapter/dist/pd-hook.js +28 -5
- package/workbuddy-adapter/dist/worker/workspace-worker.d.ts +6 -1
- package/workbuddy-adapter/dist/worker/workspace-worker.js +24 -3
- package/workbuddy-adapter/package.json +1 -1
- package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +2 -2
- package/workbuddy-adapter/plugin/.mcp.json +10 -0
- package/workbuddy-adapter/plugin/mcp/launcher.cjs +347 -0
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import fs from 'node:fs';
|
|
35
35
|
import path from 'node:path';
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
36
37
|
import Database from 'better-sqlite3';
|
|
37
|
-
import { collectSync, computeFeatureFlagsFromConfig, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, disposePainSignalBridgesForWorkspace, sanitizeString, boundCorrectionEvidenceText, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
|
|
38
|
+
import { collectSync, computeFeatureFlagsFromConfig, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, disposePainSignalBridgesForWorkspace, sanitizeString, boundCorrectionEvidenceText, MAX_EVIDENCE_VALUE_CHARS, evaluateStage2ConfidenceGate, evaluatePainIngress, STAGE2_MAX_ATTEMPTS, PENDING_CANDIDATE_TTL_MS, } from '@principles/core/runtime-v2';
|
|
38
39
|
import { deriveProductionCorrectionPainIdentity, deriveProductionToolPainIdentity, hasProductionPainSchema, PRODUCTION_WRITE_TOOLS, } from './production-pain-evidence.js';
|
|
39
40
|
import { promoteGovernanceEvidence, ensureGovernanceSchema } from './governance-observation-store.js';
|
|
40
41
|
import { loadPdConfigForPlugin } from './pd-config.js';
|
|
@@ -67,18 +68,22 @@ function rowField(row, key) {
|
|
|
67
68
|
const KEYWORD_STORE_FILE = 'correction_keywords.json';
|
|
68
69
|
/** seed/owner_promoted 词进入高精度 deterministic path 的权重阈值 */
|
|
69
70
|
export const HIGH_PRECISION_LEARNED_WEIGHT = 0.7;
|
|
70
|
-
/** PRI-788 G3: llm 学习词 earned precision 的 TP 门槛(且要求零 FP) */
|
|
71
|
-
export const EARNED_PRECISION_MIN_TP = 3;
|
|
72
71
|
/** 高精度纠正短语 overlay(已验证的确定性 STRONG 路径,不属于 learner seed 集) */
|
|
73
72
|
export const HIGH_PRECISION_CORRECTION_OVERLAY = [
|
|
74
73
|
['这是错的', 0.9],
|
|
75
74
|
['不要自作主张', 0.9],
|
|
76
75
|
['不应该这么做', 0.9],
|
|
77
76
|
// PRI-788 G3(Owner 批准清单):中文高精度纠正句式
|
|
78
|
-
['我说的是', 0.9],
|
|
79
77
|
['不是让你', 0.85],
|
|
80
78
|
['谁让你', 0.85],
|
|
81
79
|
['又搞错', 0.8],
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* SPEC v0.3 §3.2.1 降级词:口语高频、可被嵌入引用或否定句,不构成 high。
|
|
83
|
+
* 它们仍是候选(进入 Stage 2),但永不直达 STRONG。
|
|
84
|
+
*/
|
|
85
|
+
export const AMBIGUOUS_CORRECTION_OVERLAY = [
|
|
86
|
+
['我说的是', 0.9],
|
|
82
87
|
['都说了', 0.8],
|
|
83
88
|
];
|
|
84
89
|
/** empathy seed overlay(检测行为不变) */
|
|
@@ -111,17 +116,18 @@ function mapLearnedSource(source) {
|
|
|
111
116
|
return 'seed';
|
|
112
117
|
}
|
|
113
118
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
119
|
+
* SPEC v0.3 §3.2.3(D5):llm 学习词永不自升 high。历史 TP/FP 计数可以保留为
|
|
120
|
+
* 学习证据,但「TP≥3 且零 FP → 直通」的 PRI-788 G3 行为被显式逆转:学习词
|
|
121
|
+
* 只能作为 Stage 2 优先候选,由 LLM 分类 + 统一准入判断。
|
|
122
|
+
* owner_promoted(Owner 显式决策)一般按权重判定,但降级词例外。
|
|
118
123
|
*/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
124
|
-
|
|
124
|
+
const ALWAYS_AMBIGUOUS_TERMS = new Set(AMBIGUOUS_CORRECTION_OVERLAY.map(([term]) => term));
|
|
125
|
+
function precisionFor(source, term, weight) {
|
|
126
|
+
// SPEC v0.3 §3.2.1: 降级词无论来源(含 owner_promoted)都只能作候选。
|
|
127
|
+
if (ALWAYS_AMBIGUOUS_TERMS.has(term))
|
|
128
|
+
return 'ambiguous';
|
|
129
|
+
if (source === 'llm')
|
|
130
|
+
return 'ambiguous';
|
|
125
131
|
return weight >= HIGH_PRECISION_LEARNED_WEIGHT ? 'high' : 'ambiguous';
|
|
126
132
|
}
|
|
127
133
|
function projectLearnedStore(raw) {
|
|
@@ -143,7 +149,7 @@ function projectLearnedStore(raw) {
|
|
|
143
149
|
term,
|
|
144
150
|
category: 'correction',
|
|
145
151
|
weight,
|
|
146
|
-
precision: precisionFor(kw.source,
|
|
152
|
+
precision: precisionFor(kw.source, term, weight),
|
|
147
153
|
source: mapLearnedSource(kw.source),
|
|
148
154
|
};
|
|
149
155
|
if (kw.source === 'llm')
|
|
@@ -159,6 +165,11 @@ export function buildSharedSeedKeywordStore() {
|
|
|
159
165
|
for (const [term, weight] of EMPATHY_SEED_OVERLAY) {
|
|
160
166
|
terms[term] = { term, category: 'empathy', weight, precision: 'ambiguous', source: 'seed' };
|
|
161
167
|
}
|
|
168
|
+
for (const [term, weight] of AMBIGUOUS_CORRECTION_OVERLAY) {
|
|
169
|
+
if (Object.hasOwn(terms, term))
|
|
170
|
+
continue;
|
|
171
|
+
terms[term] = { term, category: 'correction', weight, precision: 'ambiguous', source: 'seed' };
|
|
172
|
+
}
|
|
162
173
|
for (const kw of CORRECTION_SEED_KEYWORDS) {
|
|
163
174
|
if (Object.hasOwn(terms, kw.term))
|
|
164
175
|
continue;
|
|
@@ -403,14 +414,18 @@ function tryConsumeRateLimit({ db, rootSessionId, ruleVersion, now }) {
|
|
|
403
414
|
.run(rootSessionId, ruleVersion, new Date(windowStartMs).toISOString(), count + 1, now.toISOString());
|
|
404
415
|
return true;
|
|
405
416
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
417
|
+
/** ONE reason builder for inline admission and the pending→admitted transition. */
|
|
418
|
+
function correctionReason(candidate, matchedTerms) {
|
|
419
|
+
if (candidate.explicitOwnerStatement === true) {
|
|
420
|
+
return candidate.explicitChannel === 'codex_explicit_agent'
|
|
409
421
|
? 'Codex explicit owner statement (agent-assisted, no prefix)'
|
|
410
|
-
: `${candidate.hostKind === 'workbuddy' ? 'WorkBuddy' : 'Codex'} explicit owner statement (PD记录痛苦 prefix)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
422
|
+
: `${candidate.hostKind === 'workbuddy' ? 'WorkBuddy' : 'Codex'} explicit owner statement (PD记录痛苦 prefix)`;
|
|
423
|
+
}
|
|
424
|
+
return matchedTerms.length > 0
|
|
425
|
+
? `User correction detected: ${matchedTerms.join(', ')}`
|
|
426
|
+
: 'User correction detected';
|
|
427
|
+
}
|
|
428
|
+
function insertCorrectionPain({ db, candidate, workspaceDir, painId, reason, nowIso }) {
|
|
414
429
|
// P1-1 privacy (review round 2): the FULL raw text must pass through the
|
|
415
430
|
// sanitizer BEFORE any truncation — sanitizeString's own contract is
|
|
416
431
|
// redact → path-replace → bound. Slicing first would split a token that
|
|
@@ -429,7 +444,7 @@ function insertCorrectionPain({ db, candidate, workspaceDir, painId, detection,
|
|
|
429
444
|
* admission path and the WorkBuddy bare-pain sweep must produce the SAME
|
|
430
445
|
* shape, differing only in the attributed host and the pain facts.
|
|
431
446
|
*/
|
|
432
|
-
function
|
|
447
|
+
function buildToolFailureTaskPayload(fields) {
|
|
433
448
|
// P1-1 privacy (review round 2): sanitize the FULL text before any
|
|
434
449
|
// truncation — a token crossing the 200-char boundary must be redacted, not
|
|
435
450
|
// split into a fragment that survives verbatim. sanitizeString bounds itself.
|
|
@@ -442,10 +457,35 @@ function toolFailureTaskPayload(fields) {
|
|
|
442
457
|
hostKind: fields.hostKind,
|
|
443
458
|
evidence: [{ sourceRef: `governance_observation:${fields.logicalObservationKey}`, note: sanitizeString(fields.reason || fields.toolName, fields.workspaceDir) }],
|
|
444
459
|
};
|
|
445
|
-
return
|
|
460
|
+
return payload;
|
|
446
461
|
}
|
|
447
|
-
function
|
|
448
|
-
|
|
462
|
+
function toolFailureTaskPayload(fields) {
|
|
463
|
+
return JSON.stringify(buildToolFailureTaskPayload(fields));
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* P1 (review round 3): a `behavior_trace` may only name a row that EXISTS in
|
|
467
|
+
* trajectory.db with the SAME lineage tuple. WorkBuddy currently has no
|
|
468
|
+
* observation writer, so an automatic WorkBuddy correction usually has no
|
|
469
|
+
* persisted behavior evidence and evaluates to `observation_only`. Never
|
|
470
|
+
* synthesize a trace from the Pain id, prompt text, session id, or logical key.
|
|
471
|
+
*/
|
|
472
|
+
function persistedBehaviorTraceEvidence(args) {
|
|
473
|
+
const row = args.db.prepare(`SELECT visible_text FROM governance_observations
|
|
474
|
+
WHERE logical_key = ? AND host_kind = ? AND rollout_identity = ? AND root_session_id = ? AND host_turn_id = ?`)
|
|
475
|
+
.get(args.logicalObservationKey, args.hostKind, args.rolloutIdentity, args.rootSessionId, args.hostTurnId);
|
|
476
|
+
if (!isRecord(row))
|
|
477
|
+
return [];
|
|
478
|
+
// The durable observation is the authority for the note: it was sanitized at
|
|
479
|
+
// ingestion. Only when it carries no text do we sanitize the caller's copy.
|
|
480
|
+
const storedText = rowField(row, 'visible_text');
|
|
481
|
+
return [{
|
|
482
|
+
kind: 'behavior_trace',
|
|
483
|
+
sourceRef: `governance_observation:${args.logicalObservationKey}`,
|
|
484
|
+
note: typeof storedText === 'string' ? storedText : sanitizeString(args.text, args.workspaceDir),
|
|
485
|
+
}];
|
|
486
|
+
}
|
|
487
|
+
function buildTaskSubmissionPayload(candidate, workspaceDir, details) {
|
|
488
|
+
const { reason, db } = details;
|
|
449
489
|
if (candidate.kind === 'user_correction') {
|
|
450
490
|
// P1-1 privacy (review round 2): sanitize the FULL text before any
|
|
451
491
|
// truncation — a token crossing the 200-char boundary must be redacted, not
|
|
@@ -457,16 +497,16 @@ function taskSubmissionPayload(candidate, workspaceDir, details) {
|
|
|
457
497
|
score: GOVERNANCE_STRONG_PAIN_SCORE,
|
|
458
498
|
sessionId: candidate.rootSessionId,
|
|
459
499
|
hostKind: candidate.hostKind,
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
500
|
+
evidence: persistedBehaviorTraceEvidence({
|
|
501
|
+
db,
|
|
502
|
+
hostKind: candidate.hostKind,
|
|
503
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
504
|
+
rolloutIdentity: candidate.rolloutIdentity,
|
|
505
|
+
rootSessionId: candidate.rootSessionId,
|
|
506
|
+
hostTurnId: candidate.hostTurnId,
|
|
507
|
+
text: candidate.text,
|
|
508
|
+
workspaceDir,
|
|
509
|
+
}),
|
|
470
510
|
// SPEC-CODEX-PAIN-CLOSURE-001 CR-02 (merged from main): an explicit
|
|
471
511
|
// Owner statement rides as classified correction evidence — never
|
|
472
512
|
// summarized by an AI, never inside evidence[] pseudo-trace entries.
|
|
@@ -476,9 +516,9 @@ function taskSubmissionPayload(candidate, workspaceDir, details) {
|
|
|
476
516
|
: { text: sanitizeString(candidate.text, workspaceDir), classification: 'owner_statement', channel: candidate.explicitChannel === 'codex_explicit_agent' ? 'codex_explicit_agent' : 'codex_explicit_prefix' } }
|
|
477
517
|
: {}),
|
|
478
518
|
};
|
|
479
|
-
return
|
|
519
|
+
return payload;
|
|
480
520
|
}
|
|
481
|
-
return
|
|
521
|
+
return buildToolFailureTaskPayload({
|
|
482
522
|
hostKind: candidate.hostKind,
|
|
483
523
|
logicalObservationKey: candidate.logicalObservationKey,
|
|
484
524
|
sessionId: candidate.rootSessionId,
|
|
@@ -488,12 +528,265 @@ function taskSubmissionPayload(candidate, workspaceDir, details) {
|
|
|
488
528
|
workspaceDir,
|
|
489
529
|
});
|
|
490
530
|
}
|
|
531
|
+
function taskSubmissionPayload(candidate, workspaceDir, details) {
|
|
532
|
+
return JSON.stringify(buildTaskSubmissionPayload(candidate, workspaceDir, details));
|
|
533
|
+
}
|
|
491
534
|
function insertAdmissionMarker({ db, identity, canonicalPainId, ruleVersion, reason, payloadJson, nowIso }) {
|
|
492
535
|
db.prepare(`INSERT INTO governance_signal_admissions
|
|
493
536
|
(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)
|
|
494
537
|
VALUES (?, ?, ?, ?, ?, 'admitted', ?, NULL, ?, ?, ?, ?, ?)`)
|
|
495
538
|
.run(identity.hostKind, identity.logicalObservationKey, identity.rolloutIdentity, identity.rootSessionId, identity.signalKind, canonicalPainId, ruleVersion, reason.slice(0, MAX_REASON_BOUND) || null, payloadJson, nowIso, nowIso);
|
|
496
539
|
}
|
|
540
|
+
// ─── Pending LLM review state machine (SPEC v0.3 §4.4 / §4.4.8) ─────────────
|
|
541
|
+
//
|
|
542
|
+
// Ambiguous Stage-1 candidates are no longer silently dropped: they get a
|
|
543
|
+
// `pending_llm_review` marker in the SAME table (zero DDL). The marker's
|
|
544
|
+
// `task_payload_json` carries two lifecycle shapes:
|
|
545
|
+
// - pending: { kind:'pending_llm_review', task_payload, review, trigger }
|
|
546
|
+
// - terminal: { kind:'pending_review_terminal', decision, reason, ... }
|
|
547
|
+
// `isGovernanceTaskPayload` rejects both shapes, so a pending/terminal row can
|
|
548
|
+
// never be mistaken for an admitted task payload (fail-loud at ensure time).
|
|
549
|
+
/** A claim older than this may be re-claimed (worker crashed mid-review). */
|
|
550
|
+
const PENDING_REVIEW_CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
551
|
+
/** Internal control-flow signal for a lost pending CAS race. */
|
|
552
|
+
class PendingCasLost extends Error {
|
|
553
|
+
constructor() {
|
|
554
|
+
super('pending_cas_lost');
|
|
555
|
+
this.name = 'PendingCasLost';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function parsePendingReviewMeta(value) {
|
|
559
|
+
if (!isRecord(value))
|
|
560
|
+
return null;
|
|
561
|
+
const attempts = own(value, 'attempts');
|
|
562
|
+
if (typeof attempts !== 'number' || !Number.isSafeInteger(attempts) || attempts < 0)
|
|
563
|
+
return null;
|
|
564
|
+
const ruleVersion = own(value, 'ruleVersion');
|
|
565
|
+
if (ruleVersion !== null && ruleVersion !== undefined && (typeof ruleVersion !== 'number' || !Number.isSafeInteger(ruleVersion)))
|
|
566
|
+
return null;
|
|
567
|
+
const claimedAt = own(value, 'claimedAt');
|
|
568
|
+
if (claimedAt !== undefined && typeof claimedAt !== 'string')
|
|
569
|
+
return null;
|
|
570
|
+
const claimToken = own(value, 'claimToken');
|
|
571
|
+
if (claimToken !== undefined && typeof claimToken !== 'string')
|
|
572
|
+
return null;
|
|
573
|
+
const lastAttemptAt = own(value, 'lastAttemptAt');
|
|
574
|
+
if (lastAttemptAt !== undefined && typeof lastAttemptAt !== 'string')
|
|
575
|
+
return null;
|
|
576
|
+
const lastReason = own(value, 'lastReason');
|
|
577
|
+
if (lastReason !== undefined && typeof lastReason !== 'string')
|
|
578
|
+
return null;
|
|
579
|
+
const reviewRaw = own(value, 'reviewResult');
|
|
580
|
+
let reviewResult;
|
|
581
|
+
if (reviewRaw !== undefined) {
|
|
582
|
+
if (!isRecord(reviewRaw))
|
|
583
|
+
return null;
|
|
584
|
+
const verdict = own(reviewRaw, 'verdict');
|
|
585
|
+
if (verdict !== 'correction' && verdict !== 'none' && verdict !== 'unclassified_empathy'
|
|
586
|
+
&& verdict !== 'pending_low_confidence' && verdict !== 'pending_llm_error')
|
|
587
|
+
return null;
|
|
588
|
+
const confidence = own(reviewRaw, 'confidence');
|
|
589
|
+
if (confidence !== null && (typeof confidence !== 'number' || !Number.isFinite(confidence)))
|
|
590
|
+
return null;
|
|
591
|
+
const normalizedConfidence = typeof confidence === 'number' ? confidence : null;
|
|
592
|
+
const reason = own(reviewRaw, 'reason');
|
|
593
|
+
if (typeof reason !== 'string')
|
|
594
|
+
return null;
|
|
595
|
+
reviewResult = { verdict, confidence: normalizedConfidence, reason };
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
attempts,
|
|
599
|
+
ruleVersion: typeof ruleVersion === 'number' ? ruleVersion : null,
|
|
600
|
+
...(typeof claimedAt === 'string' ? { claimedAt } : {}),
|
|
601
|
+
...(typeof claimToken === 'string' ? { claimToken } : {}),
|
|
602
|
+
...(typeof lastAttemptAt === 'string' ? { lastAttemptAt } : {}),
|
|
603
|
+
...(typeof lastReason === 'string' ? { lastReason } : {}),
|
|
604
|
+
...(reviewResult !== undefined ? { reviewResult } : {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function parsePendingReviewPayload(raw) {
|
|
608
|
+
if (!isRecord(raw))
|
|
609
|
+
return null;
|
|
610
|
+
if (own(raw, 'kind') !== 'pending_llm_review')
|
|
611
|
+
return null;
|
|
612
|
+
const painId = own(raw, 'pain_id');
|
|
613
|
+
if (typeof painId !== 'string' || painId.length === 0)
|
|
614
|
+
return null;
|
|
615
|
+
const taskPayload = own(raw, 'task_payload');
|
|
616
|
+
// Declared later in this module (function declaration, hoisted); the shape
|
|
617
|
+
// guard is the single validator shared with ensureGovernanceDiagnosticianTask.
|
|
618
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
619
|
+
if (!isGovernanceTaskPayload(taskPayload))
|
|
620
|
+
return null;
|
|
621
|
+
const review = parsePendingReviewMeta(own(raw, 'review'));
|
|
622
|
+
if (review === null)
|
|
623
|
+
return null;
|
|
624
|
+
const trigger = own(raw, 'trigger');
|
|
625
|
+
if (!isRecord(trigger))
|
|
626
|
+
return null;
|
|
627
|
+
const text = own(trigger, 'text');
|
|
628
|
+
const hostTurnId = own(trigger, 'hostTurnId');
|
|
629
|
+
const matchedTerms = own(trigger, 'matchedTerms');
|
|
630
|
+
if (typeof text !== 'string' || typeof hostTurnId !== 'string' || !Array.isArray(matchedTerms))
|
|
631
|
+
return null;
|
|
632
|
+
const normalizedTerms = [];
|
|
633
|
+
for (const term of matchedTerms) {
|
|
634
|
+
if (typeof term !== 'string')
|
|
635
|
+
return null;
|
|
636
|
+
normalizedTerms.push(term);
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
kind: 'pending_llm_review',
|
|
640
|
+
pain_id: painId,
|
|
641
|
+
task_payload: taskPayload,
|
|
642
|
+
review,
|
|
643
|
+
trigger: { text, matchedTerms: normalizedTerms, hostTurnId },
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function buildPendingReviewPayload(args) {
|
|
647
|
+
const { candidate, workspaceDir, db, painId, matchedTerms, ruleVersion } = args;
|
|
648
|
+
const reason = correctionReason(candidate, matchedTerms);
|
|
649
|
+
return {
|
|
650
|
+
kind: 'pending_llm_review',
|
|
651
|
+
pain_id: painId,
|
|
652
|
+
task_payload: buildTaskSubmissionPayload(candidate, workspaceDir, { reason, db }),
|
|
653
|
+
review: { attempts: 0, ruleVersion, lastReason: 'ambiguous_candidate' },
|
|
654
|
+
trigger: {
|
|
655
|
+
text: sanitizeString(candidate.text, workspaceDir),
|
|
656
|
+
matchedTerms: [...matchedTerms],
|
|
657
|
+
hostTurnId: candidate.hostTurnId,
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* @deprecated SPEC v0.3 §3.2.3 (D5) retired earned precision: learned keywords
|
|
663
|
+
* are Stage-2 candidates only. This constant is retained ONLY for public API
|
|
664
|
+
* compatibility; no admission code consults it.
|
|
665
|
+
*/
|
|
666
|
+
export const EARNED_PRECISION_MIN_TP = 3;
|
|
667
|
+
function pendingTerminalPayload(args) {
|
|
668
|
+
// D6: the Owner-approved minimal terminal field set. No trigger text,
|
|
669
|
+
// matchedTerms, reviewResult, or other classification detail survives.
|
|
670
|
+
return JSON.stringify({
|
|
671
|
+
decision: args.decision,
|
|
672
|
+
reason: args.reason,
|
|
673
|
+
confidence: args.confidence,
|
|
674
|
+
attempts: args.attempts,
|
|
675
|
+
settledAt: args.nowIso,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Build the ONE PainIngressReport for an automatic keyword/Stage-2 correction.
|
|
680
|
+
* Evidence is always present (the trigger text), so a bound automatic signal
|
|
681
|
+
* evaluates to `submit` — the mapping must never degrade it to
|
|
682
|
+
* observation_only (SPEC §5 hard requirement).
|
|
683
|
+
*/
|
|
684
|
+
function buildCorrectionIngressReport(facts) {
|
|
685
|
+
const [first, ...rest] = facts.evidence;
|
|
686
|
+
const bundle = first === undefined
|
|
687
|
+
? { status: 'unavailable', reason: 'evidence_invalid' }
|
|
688
|
+
: { status: 'available', entries: [first, ...rest] };
|
|
689
|
+
const correlation = facts.hostKind === 'codex'
|
|
690
|
+
? {
|
|
691
|
+
status: 'bound',
|
|
692
|
+
hostKind: 'codex',
|
|
693
|
+
rootSessionId: facts.rootSessionId,
|
|
694
|
+
rolloutIdentity: facts.rolloutIdentity,
|
|
695
|
+
logicalObservationKey: facts.logicalObservationKey,
|
|
696
|
+
hostTurnId: facts.hostTurnId,
|
|
697
|
+
}
|
|
698
|
+
: {
|
|
699
|
+
status: 'bound',
|
|
700
|
+
hostKind: 'workbuddy',
|
|
701
|
+
sessionId: facts.rootSessionId,
|
|
702
|
+
logicalObservationKey: facts.logicalObservationKey,
|
|
703
|
+
};
|
|
704
|
+
return {
|
|
705
|
+
identity: { kind: 'host_observation', observationId: facts.painId },
|
|
706
|
+
painType: 'user_frustration',
|
|
707
|
+
source: 'user_correction',
|
|
708
|
+
reason: facts.reason,
|
|
709
|
+
score: GOVERNANCE_STRONG_PAIN_SCORE,
|
|
710
|
+
origin: {
|
|
711
|
+
kind: 'automatic_hook',
|
|
712
|
+
source: facts.hostKind === 'codex' ? 'codex:user_prompt' : 'workbuddy:user_prompt_submit',
|
|
713
|
+
},
|
|
714
|
+
correlation,
|
|
715
|
+
evidence: bundle,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* SPEC v0.3 §5: an explicit Owner statement is `owner_manual`, keeps its
|
|
720
|
+
* correctionEvidence channel, and uses `manual_pain_id` identity — it must
|
|
721
|
+
* never be rebuilt as an automatic hook. Bound owner reports with no transcript
|
|
722
|
+
* evidence legitimately evaluate to `degrade`, never `observation_only`.
|
|
723
|
+
*/
|
|
724
|
+
function buildOwnerCorrectionIngressReport(facts) {
|
|
725
|
+
const [first, ...rest] = facts.evidence;
|
|
726
|
+
const bundle = first === undefined
|
|
727
|
+
? { status: 'unavailable', reason: 'trajectory_unavailable' }
|
|
728
|
+
: { status: 'available', entries: [first, ...rest] };
|
|
729
|
+
const correlation = facts.hostKind === 'codex'
|
|
730
|
+
? {
|
|
731
|
+
status: 'bound',
|
|
732
|
+
hostKind: 'codex',
|
|
733
|
+
rootSessionId: facts.rootSessionId,
|
|
734
|
+
rolloutIdentity: facts.rolloutIdentity,
|
|
735
|
+
logicalObservationKey: facts.logicalObservationKey,
|
|
736
|
+
hostTurnId: facts.hostTurnId,
|
|
737
|
+
}
|
|
738
|
+
: {
|
|
739
|
+
status: 'bound',
|
|
740
|
+
hostKind: 'workbuddy',
|
|
741
|
+
sessionId: facts.rootSessionId,
|
|
742
|
+
logicalObservationKey: facts.logicalObservationKey,
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
identity: { kind: 'manual_pain_id', painId: facts.painId },
|
|
746
|
+
painType: 'user_frustration',
|
|
747
|
+
source: 'user_correction',
|
|
748
|
+
reason: facts.reason,
|
|
749
|
+
score: GOVERNANCE_STRONG_PAIN_SCORE,
|
|
750
|
+
origin: { kind: 'owner_manual', channel: facts.channel },
|
|
751
|
+
correlation,
|
|
752
|
+
evidence: bundle,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function ownerExplicitChannel(candidate) {
|
|
756
|
+
if (candidate.explicitChannel !== undefined)
|
|
757
|
+
return candidate.explicitChannel;
|
|
758
|
+
return candidate.hostKind === 'workbuddy' ? 'workbuddy_explicit_prefix' : 'codex_explicit_prefix';
|
|
759
|
+
}
|
|
760
|
+
function correctionIngressEvidence(args) {
|
|
761
|
+
// Real persisted behavior evidence only (P1 review round 3). No Pain-id /
|
|
762
|
+
// logical-key / session-id pseudo-traces — WorkBuddy yields no evidence here.
|
|
763
|
+
return persistedBehaviorTraceEvidence(args);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Run the shared ingress semantics BEFORE any Pain/limit/marker side effect
|
|
767
|
+
* (v0.3.1 §1.2). A refusal/observation-only signal becomes a terminal
|
|
768
|
+
* `abandoned(reasonCode)` marker with zero Pain, zero quota.
|
|
769
|
+
*/
|
|
770
|
+
function ingressTerminalReason(facts) {
|
|
771
|
+
const decision = evaluatePainIngress(buildCorrectionIngressReport(facts));
|
|
772
|
+
if (decision.action === 'refuse' || decision.action === 'observation_only') {
|
|
773
|
+
return decision.reasonCode;
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
function insertTerminalCorrectionMarker(args) {
|
|
778
|
+
args.db.prepare(`INSERT INTO governance_signal_admissions
|
|
779
|
+
(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)
|
|
780
|
+
VALUES (?, ?, ?, ?, 'user_correction', 'abandoned', NULL, NULL, ?, ?, ?, ?, ?)`)
|
|
781
|
+
.run(args.candidate.hostKind, args.candidate.logicalObservationKey, args.candidate.rolloutIdentity, args.candidate.rootSessionId, args.ruleVersion, args.reason.slice(0, MAX_REASON_BOUND), pendingTerminalPayload({ decision: 'abandoned', reason: args.reason, attempts: 0, confidence: null, nowIso: args.nowIso }), args.nowIso, args.nowIso);
|
|
782
|
+
}
|
|
783
|
+
/** Insert the pending marker. UNIQUE-violation races are resolved by the caller. */
|
|
784
|
+
function insertPendingCorrectionMarker(args) {
|
|
785
|
+
args.db.prepare(`INSERT INTO governance_signal_admissions
|
|
786
|
+
(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)
|
|
787
|
+
VALUES (?, ?, ?, ?, 'user_correction', 'pending_llm_review', NULL, NULL, ?, ?, ?, ?, ?)`)
|
|
788
|
+
.run(args.candidate.hostKind, args.candidate.logicalObservationKey, args.candidate.rolloutIdentity, args.candidate.rootSessionId, args.ruleVersion, 'ambiguous correction candidate awaiting Stage 2 review', args.payloadJson, args.nowIso, args.nowIso);
|
|
789
|
+
}
|
|
497
790
|
/**
|
|
498
791
|
* Convergence probe for the concurrent-redelivery race: two admission
|
|
499
792
|
* processes passed the pre-transaction marker probe before either committed,
|
|
@@ -548,11 +841,154 @@ function admitCorrection({ db, candidate, workspaceDir, now, nowIso, keywordStor
|
|
|
548
841
|
&& detection.output.isSignal
|
|
549
842
|
&& detection.output.strength === 'STRONG'
|
|
550
843
|
&& detection.output.matchedPrecision === 'high');
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
844
|
+
// Ordinary conversation / zero-hit discovery is decided BEFORE the ingress
|
|
845
|
+
// gate: it has no Pain, no task, no quota, and no marker (SPEC §18 scenario
|
|
846
|
+
// 6). Ingress evidence only governs actual signals — running it first would
|
|
847
|
+
// turn silence into an audit row and mask `not_a_signal`.
|
|
848
|
+
const ambiguousTerms = detection?.output.matchedTerms ?? [];
|
|
849
|
+
const isAmbiguousCandidate = detection !== undefined
|
|
850
|
+
&& detection.output.matchedPrecision === 'ambiguous'
|
|
851
|
+
&& ambiguousTerms.length > 0;
|
|
852
|
+
if (!isStrongHighPrecision && !isAmbiguousCandidate) {
|
|
554
853
|
return { disposition: 'not_a_signal', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey };
|
|
555
854
|
}
|
|
855
|
+
// v0.3.1 §1.2: shared ingress semantics run BEFORE any Pain/limit/marker
|
|
856
|
+
// side effect — for explicit Owner statements AND automatic hooks. The
|
|
857
|
+
// origin/channel are derived from the signal's real source.
|
|
858
|
+
{
|
|
859
|
+
const { painId } = deriveProductionCorrectionPainIdentity({
|
|
860
|
+
workspaceDir,
|
|
861
|
+
sessionId: candidate.rootSessionId,
|
|
862
|
+
occurrenceId: candidate.hostTurnId,
|
|
863
|
+
text: candidate.text,
|
|
864
|
+
});
|
|
865
|
+
const ingressReasonValue = correctionReason(candidate, detection?.output.matchedTerms ?? []);
|
|
866
|
+
const ingressDecision = isExplicit
|
|
867
|
+
? evaluatePainIngress(buildOwnerCorrectionIngressReport({
|
|
868
|
+
hostKind: candidate.hostKind,
|
|
869
|
+
channel: ownerExplicitChannel(candidate),
|
|
870
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
871
|
+
rolloutIdentity: candidate.rolloutIdentity,
|
|
872
|
+
rootSessionId: candidate.rootSessionId,
|
|
873
|
+
hostTurnId: candidate.hostTurnId,
|
|
874
|
+
painId,
|
|
875
|
+
reason: ingressReasonValue,
|
|
876
|
+
// Codex has a real governance observation anchor when ingestion
|
|
877
|
+
// persisted one; otherwise (ingestion disabled) the statement degrades
|
|
878
|
+
// to the Owner Manual path instead of fabricating a trace. WorkBuddy
|
|
879
|
+
// explicit intake has no transcript evidence and degrades honestly.
|
|
880
|
+
evidence: correctionIngressEvidence({
|
|
881
|
+
db,
|
|
882
|
+
hostKind: candidate.hostKind,
|
|
883
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
884
|
+
rolloutIdentity: candidate.rolloutIdentity,
|
|
885
|
+
rootSessionId: candidate.rootSessionId,
|
|
886
|
+
hostTurnId: candidate.hostTurnId,
|
|
887
|
+
text: candidate.text,
|
|
888
|
+
workspaceDir,
|
|
889
|
+
}),
|
|
890
|
+
}))
|
|
891
|
+
: evaluatePainIngress(buildCorrectionIngressReport({
|
|
892
|
+
hostKind: candidate.hostKind,
|
|
893
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
894
|
+
rolloutIdentity: candidate.rolloutIdentity,
|
|
895
|
+
rootSessionId: candidate.rootSessionId,
|
|
896
|
+
hostTurnId: candidate.hostTurnId,
|
|
897
|
+
painId,
|
|
898
|
+
reason: ingressReasonValue,
|
|
899
|
+
evidence: correctionIngressEvidence({
|
|
900
|
+
db,
|
|
901
|
+
hostKind: candidate.hostKind,
|
|
902
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
903
|
+
rolloutIdentity: candidate.rolloutIdentity,
|
|
904
|
+
rootSessionId: candidate.rootSessionId,
|
|
905
|
+
hostTurnId: candidate.hostTurnId,
|
|
906
|
+
text: candidate.text,
|
|
907
|
+
workspaceDir,
|
|
908
|
+
}),
|
|
909
|
+
}));
|
|
910
|
+
const ingressReason = ingressDecision.action === 'refuse' || ingressDecision.action === 'observation_only'
|
|
911
|
+
? ingressDecision.reasonCode
|
|
912
|
+
: null;
|
|
913
|
+
if (ingressReason !== null) {
|
|
914
|
+
try {
|
|
915
|
+
insertTerminalCorrectionMarker({
|
|
916
|
+
db,
|
|
917
|
+
candidate,
|
|
918
|
+
decision: 'abandoned',
|
|
919
|
+
reason: ingressReason,
|
|
920
|
+
ruleVersion: detection?.ruleVersion ?? null,
|
|
921
|
+
nowIso,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
const existing = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(candidate.logicalObservationKey);
|
|
926
|
+
if (isRecord(existing)) {
|
|
927
|
+
const existingReason = rowField(existing, 'reason');
|
|
928
|
+
return {
|
|
929
|
+
disposition: 'abandoned',
|
|
930
|
+
kind: 'user_correction',
|
|
931
|
+
logicalObservationKey: candidate.logicalObservationKey,
|
|
932
|
+
reason: typeof existingReason === 'string' ? existingReason : ingressReason,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
return { disposition: 'abandoned', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, reason: ingressReason };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (!isStrongHighPrecision) {
|
|
941
|
+
// SPEC §4.4.8: ambiguous candidates become auditable pending rows (the
|
|
942
|
+
// explicit change from the old "silence" contract). The canonical id is
|
|
943
|
+
// derived from host identity facts — identical before and after review.
|
|
944
|
+
const { painId } = deriveProductionCorrectionPainIdentity({
|
|
945
|
+
workspaceDir,
|
|
946
|
+
sessionId: candidate.rootSessionId,
|
|
947
|
+
occurrenceId: candidate.hostTurnId,
|
|
948
|
+
text: candidate.text,
|
|
949
|
+
});
|
|
950
|
+
const ruleVersion = detection?.ruleVersion ?? null;
|
|
951
|
+
try {
|
|
952
|
+
insertPendingCorrectionMarker({
|
|
953
|
+
db,
|
|
954
|
+
candidate,
|
|
955
|
+
ruleVersion,
|
|
956
|
+
payloadJson: JSON.stringify(buildPendingReviewPayload({
|
|
957
|
+
candidate,
|
|
958
|
+
workspaceDir,
|
|
959
|
+
db,
|
|
960
|
+
painId,
|
|
961
|
+
matchedTerms: ambiguousTerms,
|
|
962
|
+
ruleVersion,
|
|
963
|
+
})),
|
|
964
|
+
nowIso,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
// Concurrent redelivery hit UNIQUE: converge on the durable row instead
|
|
969
|
+
// of surfacing a governance failure (rc-7 fresh re-read).
|
|
970
|
+
const existing = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(candidate.logicalObservationKey);
|
|
971
|
+
if (isRecord(existing)) {
|
|
972
|
+
const decision = rowField(existing, 'decision');
|
|
973
|
+
if (decision === 'pending_llm_review') {
|
|
974
|
+
return { disposition: 'pending_llm_review', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, duplicate: true };
|
|
975
|
+
}
|
|
976
|
+
if (decision === 'resolved_none' || decision === 'abandoned') {
|
|
977
|
+
const reason = rowField(existing, 'reason');
|
|
978
|
+
return { disposition: decision, kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, reason: typeof reason === 'string' ? reason : null };
|
|
979
|
+
}
|
|
980
|
+
if (decision === 'admitted') {
|
|
981
|
+
const canonicalPainId = rowField(existing, 'canonical_pain_id');
|
|
982
|
+
const taskId = rowField(existing, 'diagnostician_task_id');
|
|
983
|
+
if (typeof canonicalPainId === 'string') {
|
|
984
|
+
return { disposition: 'already_admitted', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, canonicalPainId, diagnosticianTaskId: typeof taskId === 'string' ? taskId : null };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
throw error;
|
|
989
|
+
}
|
|
990
|
+
return { disposition: 'pending_llm_review', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, duplicate: false };
|
|
991
|
+
}
|
|
556
992
|
const { painId } = deriveProductionCorrectionPainIdentity({
|
|
557
993
|
workspaceDir,
|
|
558
994
|
sessionId: candidate.rootSessionId,
|
|
@@ -581,7 +1017,7 @@ function admitCorrection({ db, candidate, workspaceDir, now, nowIso, keywordStor
|
|
|
581
1017
|
if (!tryConsumeRateLimit({ db, rootSessionId: candidate.rootSessionId, ruleVersion: rateLimitRuleVersion, now })) {
|
|
582
1018
|
throw new RateLimitedSignal();
|
|
583
1019
|
}
|
|
584
|
-
reason = insertCorrectionPain({ db, candidate, workspaceDir, painId,
|
|
1020
|
+
reason = insertCorrectionPain({ db, candidate, workspaceDir, painId, reason: correctionReason(candidate, detection?.output.matchedTerms ?? []), nowIso });
|
|
585
1021
|
}
|
|
586
1022
|
insertAdmissionMarker({
|
|
587
1023
|
db,
|
|
@@ -591,7 +1027,7 @@ function admitCorrection({ db, candidate, workspaceDir, now, nowIso, keywordStor
|
|
|
591
1027
|
// detection, so `detection` is optional — no ruleVersion exists for them.
|
|
592
1028
|
ruleVersion: detection?.ruleVersion ?? null,
|
|
593
1029
|
reason,
|
|
594
|
-
payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason,
|
|
1030
|
+
payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason, db }),
|
|
595
1031
|
nowIso,
|
|
596
1032
|
});
|
|
597
1033
|
})();
|
|
@@ -691,7 +1127,7 @@ function admitToolFailure({ db, candidate, workspaceDir, nowIso }) {
|
|
|
691
1127
|
canonicalPainId: painId,
|
|
692
1128
|
ruleVersion: null,
|
|
693
1129
|
reason: sanitizeString(reason, workspaceDir),
|
|
694
|
-
payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason }),
|
|
1130
|
+
payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason, db }),
|
|
695
1131
|
nowIso,
|
|
696
1132
|
});
|
|
697
1133
|
}
|
|
@@ -710,13 +1146,27 @@ function admitOne({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDe
|
|
|
710
1146
|
}
|
|
711
1147
|
const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(candidate.logicalObservationKey);
|
|
712
1148
|
if (isRecord(marker)) {
|
|
1149
|
+
// SPEC v0.3 §4.4.3 pending guard: a redelivered candidate whose marker is
|
|
1150
|
+
// pending/terminal must return that semantic — never a bogus
|
|
1151
|
+
// `already_admitted` with an empty canonical id.
|
|
1152
|
+
const decision = rowField(marker, 'decision');
|
|
1153
|
+
if (decision === 'pending_llm_review') {
|
|
1154
|
+
return { disposition: 'pending_llm_review', kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, duplicate: true };
|
|
1155
|
+
}
|
|
1156
|
+
if (decision === 'resolved_none' || decision === 'abandoned') {
|
|
1157
|
+
const reason = rowField(marker, 'reason');
|
|
1158
|
+
return { disposition: decision, kind: 'user_correction', logicalObservationKey: candidate.logicalObservationKey, reason: typeof reason === 'string' ? reason : null };
|
|
1159
|
+
}
|
|
713
1160
|
const canonicalPainId = rowField(marker, 'canonical_pain_id');
|
|
714
1161
|
const taskId = rowField(marker, 'diagnostician_task_id');
|
|
1162
|
+
if (typeof canonicalPainId !== 'string' || canonicalPainId.length === 0) {
|
|
1163
|
+
return { ok: false, reason: 'admission_marker_corrupt', nextAction: 'Inspect governance_signal_admissions for an admitted row with a missing canonical_pain_id; do not re-admit into a corrupt marker.' };
|
|
1164
|
+
}
|
|
715
1165
|
return {
|
|
716
1166
|
disposition: 'already_admitted',
|
|
717
1167
|
kind: candidate.kind,
|
|
718
1168
|
logicalObservationKey: candidate.logicalObservationKey,
|
|
719
|
-
canonicalPainId
|
|
1169
|
+
canonicalPainId,
|
|
720
1170
|
diagnosticianTaskId: typeof taskId === 'string' ? taskId : null,
|
|
721
1171
|
};
|
|
722
1172
|
}
|
|
@@ -774,6 +1224,443 @@ function admitGovernanceSignalsInternal(input, trustedWorkbuddyLive = false) {
|
|
|
774
1224
|
export function admitGovernanceSignals(input) {
|
|
775
1225
|
return admitGovernanceSignalsInternal(input);
|
|
776
1226
|
}
|
|
1227
|
+
function pendingReviewMarkerRow(db, key) {
|
|
1228
|
+
const row = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(key);
|
|
1229
|
+
return isRecord(row) ? row : null;
|
|
1230
|
+
}
|
|
1231
|
+
function parsePendingRow(row) {
|
|
1232
|
+
const payloadText = rowField(row, 'task_payload_json');
|
|
1233
|
+
const createdAt = rowField(row, 'created_at');
|
|
1234
|
+
const hostKind = rowField(row, 'host_kind');
|
|
1235
|
+
const rolloutIdentity = rowField(row, 'rollout_identity');
|
|
1236
|
+
const rootSessionId = rowField(row, 'root_session_id');
|
|
1237
|
+
if (typeof payloadText !== 'string' || typeof createdAt !== 'string' || typeof hostKind !== 'string'
|
|
1238
|
+
|| typeof rolloutIdentity !== 'string' || typeof rootSessionId !== 'string')
|
|
1239
|
+
return null;
|
|
1240
|
+
if (hostKind !== 'codex' && hostKind !== 'workbuddy')
|
|
1241
|
+
return null;
|
|
1242
|
+
let parsed;
|
|
1243
|
+
try {
|
|
1244
|
+
parsed = JSON.parse(payloadText);
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
const payload = parsePendingReviewPayload(parsed);
|
|
1250
|
+
if (payload === null)
|
|
1251
|
+
return null;
|
|
1252
|
+
return { payloadText, payload, createdAt, hostKind, rolloutIdentity, rootSessionId };
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Claim bounded pending candidates with an optimistic CAS on the exact payload
|
|
1256
|
+
* text. Rows whose claim is younger than PENDING_REVIEW_CLAIM_TTL_MS are
|
|
1257
|
+
* skipped (another worker is reviewing them); TTL-expired rows become
|
|
1258
|
+
* `abandoned(ttl_expired)` here, before any LLM work.
|
|
1259
|
+
*/
|
|
1260
|
+
export function claimPendingCorrectionCandidates(input) {
|
|
1261
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
1262
|
+
if (!('db' in opened))
|
|
1263
|
+
return opened;
|
|
1264
|
+
const { db, close } = opened;
|
|
1265
|
+
const now = input.now ?? new Date();
|
|
1266
|
+
const nowIso = now.toISOString();
|
|
1267
|
+
const limit = Math.min(Math.max(input.limit ?? 2, 1), 8);
|
|
1268
|
+
const candidates = [];
|
|
1269
|
+
let expired = 0;
|
|
1270
|
+
try {
|
|
1271
|
+
const rows = db.prepare(`SELECT * FROM governance_signal_admissions
|
|
1272
|
+
WHERE decision = 'pending_llm_review' ORDER BY id LIMIT ?`).all(limit * 3);
|
|
1273
|
+
for (const row of rows) {
|
|
1274
|
+
if (candidates.length >= limit)
|
|
1275
|
+
break;
|
|
1276
|
+
if (!isRecord(row))
|
|
1277
|
+
continue;
|
|
1278
|
+
const key = rowField(row, 'logical_observation_key');
|
|
1279
|
+
if (typeof key !== 'string')
|
|
1280
|
+
continue;
|
|
1281
|
+
const parsed = parsePendingRow(row);
|
|
1282
|
+
if (parsed === null) {
|
|
1283
|
+
// Corrupt pending payload: fail loud into a terminal row so it cannot
|
|
1284
|
+
// poison every future batch (rc-3), with no Pain and no task.
|
|
1285
|
+
db.prepare(`UPDATE governance_signal_admissions
|
|
1286
|
+
SET decision = 'abandoned', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1287
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review'`)
|
|
1288
|
+
.run('invalid_pending_payload', pendingTerminalPayload({ decision: 'abandoned', reason: 'invalid_pending_payload', attempts: 0, confidence: null, nowIso }), nowIso, key);
|
|
1289
|
+
expired += 1;
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const ageMs = Date.parse(parsed.createdAt);
|
|
1293
|
+
if (!Number.isFinite(ageMs) || now.getTime() - ageMs >= PENDING_CANDIDATE_TTL_MS) {
|
|
1294
|
+
db.prepare(`UPDATE governance_signal_admissions
|
|
1295
|
+
SET decision = 'abandoned', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1296
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review'`)
|
|
1297
|
+
.run('ttl_expired', pendingTerminalPayload({ decision: 'abandoned', reason: 'ttl_expired', attempts: parsed.payload.review.attempts, confidence: null, nowIso }), nowIso, key);
|
|
1298
|
+
expired += 1;
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const { claimedAt } = parsed.payload.review;
|
|
1302
|
+
const hasReviewResult = parsed.payload.review.reviewResult !== undefined;
|
|
1303
|
+
if (!hasReviewResult && typeof claimedAt === 'string') {
|
|
1304
|
+
const claimedMs = Date.parse(claimedAt);
|
|
1305
|
+
if (Number.isFinite(claimedMs) && now.getTime() - claimedMs < PENDING_REVIEW_CLAIM_TTL_MS)
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
let claimToken = null;
|
|
1309
|
+
if (!hasReviewResult) {
|
|
1310
|
+
// `claimedAt` is the TTL clock; the random token is the fencing identity.
|
|
1311
|
+
claimToken = randomUUID();
|
|
1312
|
+
const nextPayload = { ...parsed.payload, review: { ...parsed.payload.review, claimedAt: nowIso, claimToken } };
|
|
1313
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET task_payload_json = ?, updated_at = ?
|
|
1314
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1315
|
+
.run(JSON.stringify(nextPayload), nowIso, key, parsed.payloadText);
|
|
1316
|
+
if (changes !== 1)
|
|
1317
|
+
continue; // another worker won the claim
|
|
1318
|
+
}
|
|
1319
|
+
candidates.push({
|
|
1320
|
+
logicalObservationKey: key,
|
|
1321
|
+
hostKind: parsed.hostKind === 'workbuddy' ? 'workbuddy' : 'codex',
|
|
1322
|
+
rolloutIdentity: parsed.rolloutIdentity,
|
|
1323
|
+
rootSessionId: parsed.rootSessionId,
|
|
1324
|
+
hostTurnId: parsed.payload.trigger.hostTurnId,
|
|
1325
|
+
text: parsed.payload.trigger.text,
|
|
1326
|
+
matchedTerms: parsed.payload.trigger.matchedTerms,
|
|
1327
|
+
attempts: parsed.payload.review.attempts,
|
|
1328
|
+
claimedAt: hasReviewResult ? null : nowIso,
|
|
1329
|
+
claimToken,
|
|
1330
|
+
reviewResult: parsed.payload.review.reviewResult ?? null,
|
|
1331
|
+
createdAt: parsed.createdAt,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
return { ok: true, candidates, expired };
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
1338
|
+
return { ok: false, reason: `pending_claim_failed:${detail}`, nextAction: 'inspect the workspace trajectory database; pending rows remain durable' };
|
|
1339
|
+
}
|
|
1340
|
+
finally {
|
|
1341
|
+
close();
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Release THIS worker's claim when no classification started (budget exhausted
|
|
1346
|
+
* or classifier unavailable), so the next cycle can re-claim immediately.
|
|
1347
|
+
*
|
|
1348
|
+
* Guard rails:
|
|
1349
|
+
* - only `pending_llm_review` rows;
|
|
1350
|
+
* - never when a durable reviewResult exists (that outcome must be consumed by
|
|
1351
|
+
* settle, not thrown away);
|
|
1352
|
+
* - the persisted claim token must still be the one this worker took;
|
|
1353
|
+
* - CAS on the exact payload text, so a concurrent worker's state is untouched.
|
|
1354
|
+
*/
|
|
1355
|
+
export function releasePendingCorrectionClaim(input) {
|
|
1356
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
1357
|
+
if (!('db' in opened))
|
|
1358
|
+
return opened;
|
|
1359
|
+
const { db, close } = opened;
|
|
1360
|
+
try {
|
|
1361
|
+
const row = pendingReviewMarkerRow(db, input.logicalObservationKey);
|
|
1362
|
+
if (row === null)
|
|
1363
|
+
return { ok: true, released: false, reason: 'not_pending' };
|
|
1364
|
+
if (rowField(row, 'decision') !== 'pending_llm_review') {
|
|
1365
|
+
return { ok: true, released: false, reason: 'not_pending' };
|
|
1366
|
+
}
|
|
1367
|
+
const parsed = parsePendingRow(row);
|
|
1368
|
+
if (parsed === null)
|
|
1369
|
+
return { ok: true, released: false, reason: 'claim_changed' };
|
|
1370
|
+
if (parsed.payload.review.reviewResult !== undefined) {
|
|
1371
|
+
return { ok: true, released: false, reason: 'review_already_persisted' };
|
|
1372
|
+
}
|
|
1373
|
+
const currentClaim = parsed.payload.review.claimToken;
|
|
1374
|
+
if (currentClaim === undefined)
|
|
1375
|
+
return { ok: true, released: false, reason: 'claim_absent' };
|
|
1376
|
+
if (currentClaim !== input.claimToken)
|
|
1377
|
+
return { ok: true, released: false, reason: 'claim_not_owned' };
|
|
1378
|
+
const { claimedAt: _consumedClaim, claimToken: _consumedToken, ...reviewWithoutClaim } = parsed.payload.review;
|
|
1379
|
+
void _consumedClaim;
|
|
1380
|
+
void _consumedToken;
|
|
1381
|
+
const nextPayload = { ...parsed.payload, review: reviewWithoutClaim };
|
|
1382
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET task_payload_json = ?, updated_at = ?
|
|
1383
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1384
|
+
.run(JSON.stringify(nextPayload), new Date().toISOString(), input.logicalObservationKey, parsed.payloadText);
|
|
1385
|
+
if (changes !== 1)
|
|
1386
|
+
return { ok: true, released: false, reason: 'claim_changed' };
|
|
1387
|
+
return { ok: true, released: true, reason: 'released' };
|
|
1388
|
+
}
|
|
1389
|
+
finally {
|
|
1390
|
+
close();
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Durable Phase 1: persist the Stage-2 verdict + attempts into the pending
|
|
1395
|
+
* payload (CAS on the payload text). A crash after this commit loses no LLM
|
|
1396
|
+
* result; `settlePendingCorrection` finishes the transition without re-calling.
|
|
1397
|
+
*/
|
|
1398
|
+
export function recordPendingCorrectionReview(input) {
|
|
1399
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
1400
|
+
if (!('db' in opened))
|
|
1401
|
+
return opened;
|
|
1402
|
+
const { db, close } = opened;
|
|
1403
|
+
const now = input.now ?? new Date();
|
|
1404
|
+
const nowIso = now.toISOString();
|
|
1405
|
+
try {
|
|
1406
|
+
const row = pendingReviewMarkerRow(db, input.logicalObservationKey);
|
|
1407
|
+
if (row === null)
|
|
1408
|
+
return { ok: true, disposition: 'not_pending', logicalObservationKey: input.logicalObservationKey, attempts: 0, reason: 'marker_missing' };
|
|
1409
|
+
if (rowField(row, 'decision') !== 'pending_llm_review') {
|
|
1410
|
+
return { ok: true, disposition: 'not_pending', logicalObservationKey: input.logicalObservationKey, attempts: 0, reason: 'marker_not_pending' };
|
|
1411
|
+
}
|
|
1412
|
+
const parsed = parsePendingRow(row);
|
|
1413
|
+
if (parsed === null) {
|
|
1414
|
+
return { ok: false, reason: 'pending_payload_invalid', nextAction: 'inspect the pending marker payload; the row stays pending until repaired' };
|
|
1415
|
+
}
|
|
1416
|
+
// Fencing: if another worker re-claimed this row (our claim exceeded the
|
|
1417
|
+
// 10-minute TTL and was taken over), our verdict must NOT overwrite the
|
|
1418
|
+
// new owner's state. Compare the fencing token, not only the payload text.
|
|
1419
|
+
const currentClaimToken = parsed.payload.review.claimToken;
|
|
1420
|
+
if (currentClaimToken === undefined || currentClaimToken !== input.claimToken) {
|
|
1421
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: parsed.payload.review.attempts, reason: 'claim_not_owned' };
|
|
1422
|
+
}
|
|
1423
|
+
const gate = evaluateStage2ConfidenceGate(input.llmResult);
|
|
1424
|
+
const attempts = parsed.payload.review.attempts + 1;
|
|
1425
|
+
const reviewResult = { verdict: gate.verdict, confidence: gate.confidence, reason: gate.reason };
|
|
1426
|
+
const nextPayload = {
|
|
1427
|
+
...parsed.payload,
|
|
1428
|
+
review: {
|
|
1429
|
+
...parsed.payload.review,
|
|
1430
|
+
attempts,
|
|
1431
|
+
reviewResult,
|
|
1432
|
+
lastAttemptAt: nowIso,
|
|
1433
|
+
lastReason: gate.verdict,
|
|
1434
|
+
// Keep the SAME claim stamp/token so a parallel worker skips this row;
|
|
1435
|
+
// a crash after this commit is handled by the reviewResult-present path.
|
|
1436
|
+
},
|
|
1437
|
+
};
|
|
1438
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET task_payload_json = ?, updated_at = ?
|
|
1439
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1440
|
+
.run(JSON.stringify(nextPayload), nowIso, input.logicalObservationKey, parsed.payloadText);
|
|
1441
|
+
if (changes !== 1) {
|
|
1442
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: parsed.payload.review.attempts, reason: 'payload_changed' };
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
ok: true,
|
|
1446
|
+
disposition: 'pending',
|
|
1447
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1448
|
+
attempts,
|
|
1449
|
+
reason: gate.reason,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
finally {
|
|
1453
|
+
close();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Durable Phase 2: apply the persisted verdict. Correction → admitted (CAS,
|
|
1458
|
+
* pain dedup, rate limit, marker swap); confident none → resolved_none;
|
|
1459
|
+
* low-confidence/error/empathy → pending until MAX_ATTEMPTS or TTL; rate
|
|
1460
|
+
* limiting rolls the transaction back and leaves the row pending without
|
|
1461
|
+
* consuming another attempt (no LLM happened here).
|
|
1462
|
+
*/
|
|
1463
|
+
export async function settlePendingCorrection(input) {
|
|
1464
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
1465
|
+
const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
|
|
1466
|
+
if (!('db' in opened))
|
|
1467
|
+
return opened;
|
|
1468
|
+
const { db, close } = opened;
|
|
1469
|
+
const now = input.now ?? new Date();
|
|
1470
|
+
const nowIso = now.toISOString();
|
|
1471
|
+
try {
|
|
1472
|
+
const row = pendingReviewMarkerRow(db, input.logicalObservationKey);
|
|
1473
|
+
if (row === null)
|
|
1474
|
+
return { ok: true, disposition: 'not_pending', logicalObservationKey: input.logicalObservationKey, attempts: 0, reason: 'marker_missing' };
|
|
1475
|
+
const decision = rowField(row, 'decision');
|
|
1476
|
+
if (decision === 'admitted') {
|
|
1477
|
+
const canonicalPainId = rowField(row, 'canonical_pain_id');
|
|
1478
|
+
const taskId = rowField(row, 'diagnostician_task_id');
|
|
1479
|
+
return { ok: true, disposition: 'admitted', logicalObservationKey: input.logicalObservationKey, ...(typeof canonicalPainId === 'string' ? { canonicalPainId } : {}), ...(typeof taskId === 'string' ? { taskId } : {}), attempts: 0, reason: null };
|
|
1480
|
+
}
|
|
1481
|
+
if (decision !== 'pending_llm_review') {
|
|
1482
|
+
const reason = rowField(row, 'reason');
|
|
1483
|
+
return { ok: true, disposition: 'not_pending', logicalObservationKey: input.logicalObservationKey, attempts: 0, reason: typeof reason === 'string' ? reason : null };
|
|
1484
|
+
}
|
|
1485
|
+
const parsed = parsePendingRow(row);
|
|
1486
|
+
if (parsed === null) {
|
|
1487
|
+
return { ok: false, reason: 'pending_payload_invalid', nextAction: 'inspect the pending marker payload; the row stays pending until repaired' };
|
|
1488
|
+
}
|
|
1489
|
+
const { payload, payloadText, createdAt, rootSessionId, rolloutIdentity, hostKind } = parsed;
|
|
1490
|
+
// Fencing: a settlement that depends on this worker's claim must still hold
|
|
1491
|
+
// that claim. Omitted token = crash recovery of an already durable result,
|
|
1492
|
+
// which any successor may finish (reviewResult is the durable evidence).
|
|
1493
|
+
if (input.claimToken !== undefined) {
|
|
1494
|
+
const currentClaimToken = payload.review.claimToken;
|
|
1495
|
+
if (currentClaimToken === undefined || currentClaimToken !== input.claimToken) {
|
|
1496
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'claim_not_owned' };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const review = payload.review.reviewResult;
|
|
1500
|
+
if (review === undefined) {
|
|
1501
|
+
return { ok: true, disposition: 'pending', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'review_not_recorded' };
|
|
1502
|
+
}
|
|
1503
|
+
const ageMs = Date.parse(createdAt);
|
|
1504
|
+
const ttlExpired = Number.isFinite(ageMs) && now.getTime() - ageMs >= PENDING_CANDIDATE_TTL_MS;
|
|
1505
|
+
// TTL bounds EVERY pending verdict, including a late correction: a candidate
|
|
1506
|
+
// that sat past the window is no longer trustworthy evidence.
|
|
1507
|
+
if (ttlExpired) {
|
|
1508
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET decision = 'abandoned', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1509
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1510
|
+
.run('ttl_expired', pendingTerminalPayload({ decision: 'abandoned', reason: 'ttl_expired', attempts: payload.review.attempts, confidence: review.confidence, nowIso }), nowIso, input.logicalObservationKey, payloadText);
|
|
1511
|
+
if (changes !== 1) {
|
|
1512
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1513
|
+
}
|
|
1514
|
+
return { ok: true, disposition: 'abandoned', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'ttl_expired' };
|
|
1515
|
+
}
|
|
1516
|
+
if (review.verdict !== 'correction' && review.verdict !== 'none') {
|
|
1517
|
+
if (payload.review.attempts >= STAGE2_MAX_ATTEMPTS) {
|
|
1518
|
+
const reason = review.verdict === 'pending_llm_error' ? 'llm_error' : 'low_confidence';
|
|
1519
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET decision = 'abandoned', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1520
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1521
|
+
.run(reason, pendingTerminalPayload({ decision: 'abandoned', reason, attempts: payload.review.attempts, confidence: review.confidence, nowIso }), nowIso, input.logicalObservationKey, payloadText);
|
|
1522
|
+
if (changes !== 1) {
|
|
1523
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1524
|
+
}
|
|
1525
|
+
return { ok: true, disposition: 'abandoned', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason };
|
|
1526
|
+
}
|
|
1527
|
+
// Retryable verdict: consume the review result and release the claim so
|
|
1528
|
+
// the next worker cycle re-claims and calls the LLM again. Both claim
|
|
1529
|
+
// properties must be ABSENT (claim logic tests `reviewResult !==
|
|
1530
|
+
// undefined` / `claimToken`), and the CAS must use the exact payload we
|
|
1531
|
+
// read (concurrent-settle guard).
|
|
1532
|
+
const { reviewResult: _consumedReview, claimedAt: _consumedClaim, claimToken: _consumedToken, ...retainedReview } = payload.review;
|
|
1533
|
+
void _consumedReview;
|
|
1534
|
+
void _consumedClaim;
|
|
1535
|
+
void _consumedToken;
|
|
1536
|
+
const nextPayload = {
|
|
1537
|
+
...payload,
|
|
1538
|
+
review: { ...retainedReview, lastAttemptAt: nowIso, lastReason: review.verdict },
|
|
1539
|
+
};
|
|
1540
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET task_payload_json = ?, updated_at = ?
|
|
1541
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1542
|
+
.run(JSON.stringify(nextPayload), nowIso, input.logicalObservationKey, payloadText);
|
|
1543
|
+
if (changes !== 1) {
|
|
1544
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1545
|
+
}
|
|
1546
|
+
return { ok: true, disposition: 'pending', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: review.verdict };
|
|
1547
|
+
}
|
|
1548
|
+
if (review.verdict === 'none') {
|
|
1549
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET decision = 'resolved_none', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1550
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1551
|
+
.run(review.reason.slice(0, MAX_REASON_BOUND) || 'stage2_none', pendingTerminalPayload({ decision: 'resolved_none', reason: review.reason, attempts: payload.review.attempts, confidence: review.confidence, nowIso }), nowIso, input.logicalObservationKey, payloadText);
|
|
1552
|
+
if (changes !== 1) {
|
|
1553
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1554
|
+
}
|
|
1555
|
+
return { ok: true, disposition: 'resolved_none', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: review.reason };
|
|
1556
|
+
}
|
|
1557
|
+
// Correction → Pain Admission. The canonical id was derived pre-review from
|
|
1558
|
+
// host identity facts; recompute it here from the same facts so the two
|
|
1559
|
+
// stages cannot drift (rc-6).
|
|
1560
|
+
const candidate = {
|
|
1561
|
+
kind: 'user_correction',
|
|
1562
|
+
hostKind: hostKind === 'workbuddy' ? 'workbuddy' : 'codex',
|
|
1563
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1564
|
+
rolloutIdentity,
|
|
1565
|
+
rootSessionId,
|
|
1566
|
+
hostTurnId: payload.trigger.hostTurnId,
|
|
1567
|
+
text: payload.trigger.text,
|
|
1568
|
+
observedAt: createdAt,
|
|
1569
|
+
};
|
|
1570
|
+
// rc-6: reuse the id derived BEFORE review (the persisted trigger text may
|
|
1571
|
+
// be sanitized/truncated, so re-deriving from it would change the hash).
|
|
1572
|
+
const painId = payload.pain_id;
|
|
1573
|
+
const reason = correctionReason(candidate, payload.trigger.matchedTerms);
|
|
1574
|
+
// v0.3.1 §1.2: the shared ingress decision precedes the admitted CAS.
|
|
1575
|
+
// P1 review round 3: re-derive evidence from trajectory.db instead of
|
|
1576
|
+
// trusting whatever the pending payload recorded. A pending candidate with
|
|
1577
|
+
// no real persisted observation (e.g. WorkBuddy automatic) cannot be
|
|
1578
|
+
// admitted — it settles as observation_only with zero Pain/quota.
|
|
1579
|
+
const ingressReason = ingressTerminalReason({
|
|
1580
|
+
hostKind: candidate.hostKind,
|
|
1581
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1582
|
+
rolloutIdentity,
|
|
1583
|
+
rootSessionId,
|
|
1584
|
+
hostTurnId: candidate.hostTurnId,
|
|
1585
|
+
painId,
|
|
1586
|
+
reason,
|
|
1587
|
+
evidence: correctionIngressEvidence({
|
|
1588
|
+
db,
|
|
1589
|
+
hostKind: candidate.hostKind,
|
|
1590
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1591
|
+
rolloutIdentity,
|
|
1592
|
+
rootSessionId,
|
|
1593
|
+
hostTurnId: candidate.hostTurnId,
|
|
1594
|
+
text: payload.trigger.text,
|
|
1595
|
+
workspaceDir,
|
|
1596
|
+
}),
|
|
1597
|
+
});
|
|
1598
|
+
if (ingressReason !== null) {
|
|
1599
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions SET decision = 'abandoned', reason = ?, task_payload_json = ?, updated_at = ?
|
|
1600
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review' AND task_payload_json = ?`)
|
|
1601
|
+
.run(ingressReason, pendingTerminalPayload({ decision: 'abandoned', reason: ingressReason, attempts: payload.review.attempts, confidence: review.confidence, nowIso }), nowIso, input.logicalObservationKey, payloadText);
|
|
1602
|
+
if (changes !== 1) {
|
|
1603
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1604
|
+
}
|
|
1605
|
+
return { ok: true, disposition: 'abandoned', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: ingressReason };
|
|
1606
|
+
}
|
|
1607
|
+
let duplicate = false;
|
|
1608
|
+
try {
|
|
1609
|
+
db.transaction(() => {
|
|
1610
|
+
const { changes } = db.prepare(`UPDATE governance_signal_admissions
|
|
1611
|
+
SET decision = 'admitted', canonical_pain_id = ?, rule_version = ?, reason = ?, task_payload_json = ?, updated_at = ?
|
|
1612
|
+
WHERE logical_observation_key = ? AND decision = 'pending_llm_review'`)
|
|
1613
|
+
.run(painId, payload.review.ruleVersion, reason.slice(0, MAX_REASON_BOUND), JSON.stringify(payload.task_payload), nowIso, input.logicalObservationKey);
|
|
1614
|
+
if (changes !== 1)
|
|
1615
|
+
throw new PendingCasLost();
|
|
1616
|
+
if (db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?').get(painId) !== undefined) {
|
|
1617
|
+
duplicate = true;
|
|
1618
|
+
db.prepare('UPDATE pain_events SET host_kind = ? WHERE canonical_pain_id = ? AND host_kind IS NULL').run(candidate.hostKind, painId);
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
const ruleVersion = payload.review.ruleVersion ?? EXPLICIT_OWNER_STATEMENT_RULE_VERSION;
|
|
1622
|
+
if (!tryConsumeRateLimit({ db, rootSessionId: candidate.rootSessionId, ruleVersion, now }))
|
|
1623
|
+
throw new RateLimitedSignal();
|
|
1624
|
+
insertCorrectionPain({ db, candidate, workspaceDir, painId, reason, nowIso });
|
|
1625
|
+
}
|
|
1626
|
+
})();
|
|
1627
|
+
}
|
|
1628
|
+
catch (error) {
|
|
1629
|
+
if (error instanceof PendingCasLost) {
|
|
1630
|
+
return { ok: true, disposition: 'concurrent_loss', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'cas_lost' };
|
|
1631
|
+
}
|
|
1632
|
+
if (error instanceof RateLimitedSignal) {
|
|
1633
|
+
// Non-LLM failure: the row stays pending with its attempts unchanged
|
|
1634
|
+
// (the review result is already durable). TTL eventually bounds it.
|
|
1635
|
+
return { ok: true, disposition: 'pending', logicalObservationKey: input.logicalObservationKey, attempts: payload.review.attempts, reason: 'rate_limited' };
|
|
1636
|
+
}
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- declared below; function declarations are hoisted
|
|
1640
|
+
const ensured = await ensureGovernanceDiagnosticianTask({
|
|
1641
|
+
workspaceDir,
|
|
1642
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1643
|
+
canonicalPainId: painId,
|
|
1644
|
+
...(input.databaseFactory ? { databaseFactory: input.databaseFactory } : {}),
|
|
1645
|
+
});
|
|
1646
|
+
return {
|
|
1647
|
+
ok: true,
|
|
1648
|
+
disposition: 'admitted',
|
|
1649
|
+
logicalObservationKey: input.logicalObservationKey,
|
|
1650
|
+
canonicalPainId: painId,
|
|
1651
|
+
attempts: payload.review.attempts,
|
|
1652
|
+
reason: duplicate ? 'duplicate_pain' : reason,
|
|
1653
|
+
...(ensured.ok ? { taskId: ensured.taskId } : {}),
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
1658
|
+
return { ok: false, reason: `pending_settle_failed:${detail}`, nextAction: 'inspect the workspace trajectory database; admitted rows are durably recoverable via reconcile' };
|
|
1659
|
+
}
|
|
1660
|
+
finally {
|
|
1661
|
+
close();
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
777
1664
|
/** P0: a four-term projection of existing canonical high-precision seeds. */
|
|
778
1665
|
const WORKBUDDY_AUTO_TERMS = new Set(['这是错的', '不要自作主张', '不应该这么做', '又搞错']);
|
|
779
1666
|
const WORKBUDDY_MAX_PROMPT_BYTES = 16 * 1024;
|
|
@@ -800,23 +1687,37 @@ export function resolveSharedCorrectionKeywordStoreForDetection(workspaceDir) {
|
|
|
800
1687
|
return createSharedCorrectionKeywordStore({ workspaceDir }).resolve();
|
|
801
1688
|
}
|
|
802
1689
|
/**
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
1690
|
+
* Shared WorkBuddy Stage-1 assessment. `strong` may admit inline (no LLM);
|
|
1691
|
+
* `ambiguous` persists a pending Stage-2 candidate (zero LLM in the hook);
|
|
1692
|
+
* `none` writes nothing. The startsWith pre-filter (SPEC P-5) is retained
|
|
1693
|
+
* until the cross-host negative matrix authorizes its retirement.
|
|
806
1694
|
*/
|
|
807
|
-
export function
|
|
1695
|
+
export function assessWorkbuddyCorrectionCandidate(text, sessionId, store) {
|
|
808
1696
|
if (Buffer.byteLength(text, 'utf8') > WORKBUDDY_MAX_PROMPT_BYTES) {
|
|
809
|
-
return {
|
|
1697
|
+
return { kind: 'none', reason: 'prompt_too_large' };
|
|
810
1698
|
}
|
|
811
1699
|
const resolved = store ?? workbuddyP0KeywordStore();
|
|
812
1700
|
// Prefer precision to recall: reject quoted/embedded/reported phrases.
|
|
813
1701
|
if (!Object.keys(resolved.terms).some(term => text.trimStart().toLowerCase().startsWith(term.toLowerCase()))) {
|
|
814
|
-
return {
|
|
1702
|
+
return { kind: 'none', reason: 'not_a_signal' };
|
|
815
1703
|
}
|
|
816
1704
|
const out = collectSync(text, sessionId, resolved, SYNC_DETECTION_CONFIG, new Date().toISOString());
|
|
817
|
-
const
|
|
1705
|
+
const strong = out.isSignal && out.strength === 'STRONG' &&
|
|
818
1706
|
out.type === 'correction' && out.matchedPrecision === 'high';
|
|
819
|
-
|
|
1707
|
+
if (strong)
|
|
1708
|
+
return { kind: 'strong', reason: 'matched' };
|
|
1709
|
+
if (out.matchedPrecision === 'ambiguous' && out.matchedTerms.length > 0) {
|
|
1710
|
+
return { kind: 'ambiguous', reason: 'matched' };
|
|
1711
|
+
}
|
|
1712
|
+
return { kind: 'none', reason: 'not_a_signal' };
|
|
1713
|
+
}
|
|
1714
|
+
/** Backward-compatible boolean view: only `strong` counts as matched. */
|
|
1715
|
+
export function assessWorkbuddyLiveKeyword(text, sessionId, store) {
|
|
1716
|
+
const assessment = assessWorkbuddyCorrectionCandidate(text, sessionId, store);
|
|
1717
|
+
return {
|
|
1718
|
+
matched: assessment.kind === 'strong',
|
|
1719
|
+
reason: assessment.kind === 'strong' ? 'matched' : assessment.reason,
|
|
1720
|
+
};
|
|
820
1721
|
}
|
|
821
1722
|
/**
|
|
822
1723
|
* Narrow native event gateway: validate true current user origin and flags.
|
|
@@ -850,7 +1751,8 @@ export function admitTrustedWorkbuddyLiveCorrection(input) {
|
|
|
850
1751
|
// 词与 admitOne 的检测判定不会漂移。自动 Pain 仍只允许 high 精度 correction
|
|
851
1752
|
// 词(两道门各自强制),无 LLM、无 transcript 读取。
|
|
852
1753
|
const sharedStore = resolveSharedCorrectionKeywordStoreForDetection(input.workspaceDir);
|
|
853
|
-
|
|
1754
|
+
const assessment = assessWorkbuddyCorrectionCandidate(prompt, sessionId, sharedStore);
|
|
1755
|
+
if (assessment.kind === 'none') {
|
|
854
1756
|
return { ok: true, outcomes: [] };
|
|
855
1757
|
}
|
|
856
1758
|
const nativeIdentity = JSON.stringify(['workbuddy', sessionId, generationId]);
|
|
@@ -943,13 +1845,18 @@ export async function ensureGovernanceDiagnosticianTask(input) {
|
|
|
943
1845
|
try {
|
|
944
1846
|
const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ? AND canonical_pain_id = ?').get(input.logicalObservationKey, input.canonicalPainId);
|
|
945
1847
|
const existingTaskId = rowField(marker, 'diagnostician_task_id');
|
|
946
|
-
if (isRecord(marker) && typeof existingTaskId === 'string' && existingTaskId.length > 0) {
|
|
947
|
-
return { ok: true, taskId: existingTaskId, created: false, duplicate: true, linkRepaired: false };
|
|
948
|
-
}
|
|
949
1848
|
if (!isRecord(marker)) {
|
|
950
1849
|
return { ok: false, reason: 'admission_marker_not_found', nextAction: 'admit the signal before ensuring its Diagnostician continuation' };
|
|
951
1850
|
}
|
|
952
1851
|
const payloadRaw = rowField(marker, 'task_payload_json');
|
|
1852
|
+
// Legacy linked marker: an old admitted row may carry no payload at all.
|
|
1853
|
+
// The task already exists, there is no ingress fact to rebuild, and
|
|
1854
|
+
// demanding a payload here would starve reconciliation forever. A PRESENT
|
|
1855
|
+
// but inconsistent payload still fails closed below.
|
|
1856
|
+
if (typeof existingTaskId === 'string' && existingTaskId.length > 0
|
|
1857
|
+
&& (payloadRaw === null || payloadRaw === undefined)) {
|
|
1858
|
+
return { ok: true, taskId: existingTaskId, created: false, duplicate: true, linkRepaired: false };
|
|
1859
|
+
}
|
|
953
1860
|
let payloadParsed;
|
|
954
1861
|
try {
|
|
955
1862
|
payloadParsed = typeof payloadRaw === 'string' ? JSON.parse(payloadRaw) : null;
|
|
@@ -960,6 +1867,103 @@ export async function ensureGovernanceDiagnosticianTask(input) {
|
|
|
960
1867
|
if (!isGovernanceTaskPayload(payloadParsed)) {
|
|
961
1868
|
return { ok: false, reason: 'admission_task_payload_invalid', nextAction: 're-admit the signal or run reconciliation after repairing the workspace state' };
|
|
962
1869
|
}
|
|
1870
|
+
// SPEC v0.3.1 §1.2: provenance is DERIVED from the marker's ingress facts,
|
|
1871
|
+
// never hardcoded. The marker is host-context-bound by construction, and
|
|
1872
|
+
// its payload carries the evidence entries that make this a `submit`.
|
|
1873
|
+
const rolloutIdentity = rowField(marker, 'rollout_identity');
|
|
1874
|
+
const markerKey = input.logicalObservationKey;
|
|
1875
|
+
const hostTurnId = payloadParsed.hostKind === 'codex'
|
|
1876
|
+
? (markerKey.split('|')[2] ?? markerKey)
|
|
1877
|
+
: (markerKey.lastIndexOf('|') > 0 ? markerKey.slice(0, markerKey.lastIndexOf('|')) : markerKey);
|
|
1878
|
+
const markerHostKind = payloadParsed.hostKind;
|
|
1879
|
+
// P1 review round 3: never trust the payload's evidence list — re-derive it
|
|
1880
|
+
// from trajectory.db with the full lineage tuple. A marker that references a
|
|
1881
|
+
// non-existent observation (or a WorkBuddy automatic correction) evaluates
|
|
1882
|
+
// to observation_only and fails closed below.
|
|
1883
|
+
// A tool_failure pain is its own persisted evidence (the pain row), not a
|
|
1884
|
+
// correction claim about behavior — the correction re-derivation must not
|
|
1885
|
+
// run for it. Only corrections are gated on a real observation.
|
|
1886
|
+
const isCorrectionPayload = payloadParsed.painType !== 'tool_failure';
|
|
1887
|
+
const ingressEvidence = isCorrectionPayload && (markerHostKind === 'codex' || markerHostKind === 'workbuddy')
|
|
1888
|
+
? correctionIngressEvidence({
|
|
1889
|
+
db,
|
|
1890
|
+
hostKind: markerHostKind,
|
|
1891
|
+
logicalObservationKey: markerKey,
|
|
1892
|
+
rolloutIdentity: typeof rolloutIdentity === 'string' ? rolloutIdentity : '',
|
|
1893
|
+
rootSessionId: payloadParsed.sessionId,
|
|
1894
|
+
hostTurnId,
|
|
1895
|
+
text: payloadParsed.reason,
|
|
1896
|
+
workspaceDir,
|
|
1897
|
+
})
|
|
1898
|
+
: payloadParsed.evidence.map((entry) => ({
|
|
1899
|
+
kind: 'behavior_trace',
|
|
1900
|
+
sourceRef: entry.sourceRef,
|
|
1901
|
+
note: entry.note,
|
|
1902
|
+
}));
|
|
1903
|
+
let provenance;
|
|
1904
|
+
let ingressWarning;
|
|
1905
|
+
if (isCorrectionPayload && (markerHostKind === 'codex' || markerHostKind === 'workbuddy')) {
|
|
1906
|
+
// SPEC v0.3 §5 / v0.3.1 §1.2: rebuild the report from the marker's REAL
|
|
1907
|
+
// source. An explicit Owner statement is owner_manual (manual_pain_id,
|
|
1908
|
+
// correctionEvidence channel); everything else is the automatic hook.
|
|
1909
|
+
const ownerChannel = payloadParsed.correctionEvidence?.channel;
|
|
1910
|
+
const ingressDecision = ownerChannel !== undefined
|
|
1911
|
+
? evaluatePainIngress(buildOwnerCorrectionIngressReport({
|
|
1912
|
+
hostKind: markerHostKind,
|
|
1913
|
+
channel: ownerChannel,
|
|
1914
|
+
logicalObservationKey: markerKey,
|
|
1915
|
+
rolloutIdentity: typeof rolloutIdentity === 'string' ? rolloutIdentity : '',
|
|
1916
|
+
rootSessionId: payloadParsed.sessionId,
|
|
1917
|
+
hostTurnId,
|
|
1918
|
+
painId: input.canonicalPainId,
|
|
1919
|
+
reason: payloadParsed.reason,
|
|
1920
|
+
// CR-02: never fabricate transcript evidence for an Owner statement;
|
|
1921
|
+
// an empty list is the honest basis and bound explicit reports
|
|
1922
|
+
// legitimately degrade.
|
|
1923
|
+
evidence: ingressEvidence,
|
|
1924
|
+
}))
|
|
1925
|
+
: evaluatePainIngress(buildCorrectionIngressReport({
|
|
1926
|
+
hostKind: markerHostKind,
|
|
1927
|
+
logicalObservationKey: markerKey,
|
|
1928
|
+
rolloutIdentity: typeof rolloutIdentity === 'string' ? rolloutIdentity : '',
|
|
1929
|
+
rootSessionId: payloadParsed.sessionId,
|
|
1930
|
+
hostTurnId,
|
|
1931
|
+
painId: input.canonicalPainId,
|
|
1932
|
+
reason: payloadParsed.reason,
|
|
1933
|
+
evidence: ingressEvidence,
|
|
1934
|
+
}));
|
|
1935
|
+
if (ingressDecision.action === 'refuse' || ingressDecision.action === 'observation_only') {
|
|
1936
|
+
// Fail closed: an inconsistent/evidence-less admitted marker must not
|
|
1937
|
+
// silently become a task (and must never be relabelled automatic_hook).
|
|
1938
|
+
return {
|
|
1939
|
+
ok: false,
|
|
1940
|
+
reason: `pain_ingress_${ingressDecision.reasonCode}`,
|
|
1941
|
+
nextAction: ingressDecision.action === 'refuse' ? ingressDecision.nextAction : ingressDecision.note,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
const { legacy: { provenance: decidedProvenance } } = ingressDecision;
|
|
1945
|
+
provenance = decidedProvenance;
|
|
1946
|
+
if (ingressDecision.action === 'degrade')
|
|
1947
|
+
ingressWarning = ingressDecision.warning;
|
|
1948
|
+
}
|
|
1949
|
+
else {
|
|
1950
|
+
// OpenClaw markers do not flow through this governance seam today; keep
|
|
1951
|
+
// the historical provenance rather than inventing an ingress report.
|
|
1952
|
+
provenance = 'host_context_bound';
|
|
1953
|
+
}
|
|
1954
|
+
// Recovery path: a task link already exists. The ingress decision above
|
|
1955
|
+
// still ran, so an inconsistent admitted marker fails closed instead of
|
|
1956
|
+
// being reported as a healthy duplicate.
|
|
1957
|
+
if (typeof existingTaskId === 'string' && existingTaskId.length > 0) {
|
|
1958
|
+
return {
|
|
1959
|
+
ok: true,
|
|
1960
|
+
taskId: existingTaskId,
|
|
1961
|
+
created: false,
|
|
1962
|
+
duplicate: true,
|
|
1963
|
+
linkRepaired: false,
|
|
1964
|
+
...(ingressWarning !== undefined ? { ingressWarning } : {}),
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
963
1967
|
const taskId = createDiagnosticianTaskId(input.canonicalPainId);
|
|
964
1968
|
// Cross-store crash recovery (SPEC §13): a task may already exist from a
|
|
965
1969
|
// crashed prior attempt (crash-after-create, before the link write).
|
|
@@ -992,12 +1996,12 @@ export async function ensureGovernanceDiagnosticianTask(input) {
|
|
|
992
1996
|
reason: payloadParsed.reason,
|
|
993
1997
|
score: payloadParsed.score,
|
|
994
1998
|
sessionId: payloadParsed.sessionId,
|
|
995
|
-
provenance
|
|
1999
|
+
provenance,
|
|
996
2000
|
// The admitting host comes from the marker's own payload — the ONE
|
|
997
2001
|
// durable attribution fact (existing codex markers keep 'codex',
|
|
998
2002
|
// workbuddy markers attribute 'workbuddy').
|
|
999
2003
|
hostKind: payloadParsed.hostKind,
|
|
1000
|
-
evidence: [...
|
|
2004
|
+
evidence: [...ingressEvidence],
|
|
1001
2005
|
// CR-02: replay the explicit Owner statement verbatim (classified,
|
|
1002
2006
|
// channel-disclosed) so re-created tasks keep the same input basis.
|
|
1003
2007
|
...(payloadParsed.correctionEvidence !== undefined
|
|
@@ -1032,6 +2036,7 @@ export async function ensureGovernanceDiagnosticianTask(input) {
|
|
|
1032
2036
|
// The link was just written because the task already existed → a real
|
|
1033
2037
|
// Case B repair (not a no-op on an already-linked marker).
|
|
1034
2038
|
linkRepaired: existing !== null,
|
|
2039
|
+
...(ingressWarning !== undefined ? { ingressWarning } : {}),
|
|
1035
2040
|
};
|
|
1036
2041
|
}
|
|
1037
2042
|
finally {
|
|
@@ -1379,7 +2384,7 @@ export function readGovernanceAdmissionCounts(args) {
|
|
|
1379
2384
|
const admissions = db.prepare(`SELECT
|
|
1380
2385
|
SUM(CASE WHEN decision = 'admitted' THEN 1 ELSE 0 END) AS admitted,
|
|
1381
2386
|
SUM(CASE WHEN decision = 'admitted' AND diagnostician_task_id IS NULL THEN 1 ELSE 0 END) AS admitted_without_task,
|
|
1382
|
-
MAX(created_at) AS last_admission
|
|
2387
|
+
MAX(CASE WHEN decision = 'admitted' THEN created_at END) AS last_admission
|
|
1383
2388
|
FROM governance_signal_admissions`).get();
|
|
1384
2389
|
if (!isRecord(admissions)) {
|
|
1385
2390
|
return { ok: false, reason: 'governance_admission_counts_unavailable', nextAction: 'Inspect the governance_signal_admissions table in the workspace trajectory.db.' };
|