create-principles-disciple 1.146.9 → 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.
Files changed (112) hide show
  1. package/_release/product-identity.json +1 -1
  2. package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +6 -0
  3. package/codex-adapter/dist/ingestion/explicit-owner-statement.js +6 -0
  4. package/codex-adapter/dist/pd-hook.js +5 -2
  5. package/codex-adapter/dist/worker/workspace-worker.d.ts +13 -1
  6. package/codex-adapter/dist/worker/workspace-worker.js +26 -3
  7. package/codex-adapter/package.json +1 -1
  8. package/core/dist/runtime-v2/index.d.ts +2 -2
  9. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  10. package/core/dist/runtime-v2/index.js +1 -1
  11. package/core/dist/runtime-v2/index.js.map +1 -1
  12. package/core/dist/runtime-v2/pain-ingress-payload.d.ts +13 -1
  13. package/core/dist/runtime-v2/pain-ingress-payload.d.ts.map +1 -1
  14. package/core/dist/runtime-v2/pain-ingress-payload.js +21 -2
  15. package/core/dist/runtime-v2/pain-ingress-payload.js.map +1 -1
  16. package/core/dist/runtime-v2/pain-ingress.d.ts.map +1 -1
  17. package/core/dist/runtime-v2/pain-ingress.js +3 -0
  18. package/core/dist/runtime-v2/pain-ingress.js.map +1 -1
  19. package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts +35 -0
  20. package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts.map +1 -0
  21. package/core/dist/runtime-v2/signal-collector/confidence-gate.js +48 -0
  22. package/core/dist/runtime-v2/signal-collector/confidence-gate.js.map +1 -0
  23. package/core/dist/runtime-v2/signal-collector/index.d.ts +1 -0
  24. package/core/dist/runtime-v2/signal-collector/index.d.ts.map +1 -1
  25. package/core/dist/runtime-v2/signal-collector/index.js +1 -0
  26. package/core/dist/runtime-v2/signal-collector/index.js.map +1 -1
  27. package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts +4 -6
  28. package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
  29. package/core/dist/runtime-v2/signal-collector/keyword-stage.js +100 -9
  30. package/core/dist/runtime-v2/signal-collector/keyword-stage.js.map +1 -1
  31. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.d.ts.map +1 -1
  32. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js +8 -4
  33. package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js.map +1 -1
  34. package/core/package.json +1 -1
  35. package/dist/installer.d.ts.map +1 -1
  36. package/dist/installer.js +52 -1
  37. package/dist/installer.js.map +1 -1
  38. package/dist/installers/mcp-registration-conflict.d.ts +35 -0
  39. package/dist/installers/mcp-registration-conflict.d.ts.map +1 -0
  40. package/dist/installers/mcp-registration-conflict.js +559 -0
  41. package/dist/installers/mcp-registration-conflict.js.map +1 -0
  42. package/dist/installers/openclaw-host-installer.d.ts.map +1 -1
  43. package/dist/installers/openclaw-host-installer.js +20 -0
  44. package/dist/installers/openclaw-host-installer.js.map +1 -1
  45. package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
  46. package/dist/installers/workbuddy-host-installer.js +124 -2
  47. package/dist/installers/workbuddy-host-installer.js.map +1 -1
  48. package/dist/mcp-server-layout-compat.d.ts +18 -0
  49. package/dist/mcp-server-layout-compat.d.ts.map +1 -0
  50. package/dist/mcp-server-layout-compat.js +22 -0
  51. package/dist/mcp-server-layout-compat.js.map +1 -0
  52. package/host-runtime/dist/governance-signal-admission.d.ts +147 -4
  53. package/host-runtime/dist/governance-signal-admission.js +1065 -60
  54. package/host-runtime/dist/index.d.ts +2 -0
  55. package/host-runtime/dist/index.js +2 -0
  56. package/host-runtime/dist/signal-classifier.d.ts +46 -0
  57. package/host-runtime/dist/signal-classifier.js +167 -0
  58. package/host-runtime/dist/signal-stage2-cycle.d.ts +42 -0
  59. package/host-runtime/dist/signal-stage2-cycle.js +184 -0
  60. package/host-runtime/package.json +1 -1
  61. package/install-layout/dist/index.d.ts +15 -0
  62. package/install-layout/dist/index.js +10 -0
  63. package/install-layout/package.json +1 -1
  64. package/package.json +9 -7
  65. package/pd-cli/dist/commands/health-codex.d.ts.map +1 -1
  66. package/pd-cli/dist/commands/health-codex.js +12 -0
  67. package/pd-cli/dist/commands/health-codex.js.map +1 -1
  68. package/pd-cli/dist/commands/health-mcp.d.ts +74 -0
  69. package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -0
  70. package/pd-cli/dist/commands/health-mcp.js +723 -0
  71. package/pd-cli/dist/commands/health-mcp.js.map +1 -0
  72. package/pd-cli/dist/commands/health.d.ts +2 -0
  73. package/pd-cli/dist/commands/health.d.ts.map +1 -1
  74. package/pd-cli/dist/commands/health.js +11 -6
  75. package/pd-cli/dist/commands/health.js.map +1 -1
  76. package/pd-mcp-server/dist/budget.d.ts +15 -0
  77. package/pd-mcp-server/dist/budget.js +19 -0
  78. package/pd-mcp-server/dist/deadline.d.ts +75 -0
  79. package/pd-mcp-server/dist/deadline.js +136 -0
  80. package/pd-mcp-server/dist/envelope.d.ts +52 -0
  81. package/pd-mcp-server/dist/envelope.js +63 -0
  82. package/pd-mcp-server/dist/index.d.ts +10 -0
  83. package/pd-mcp-server/dist/index.js +76 -0
  84. package/pd-mcp-server/dist/logging.d.ts +12 -0
  85. package/pd-mcp-server/dist/logging.js +42 -0
  86. package/pd-mcp-server/dist/server.d.ts +90 -0
  87. package/pd-mcp-server/dist/server.js +178 -0
  88. package/pd-mcp-server/dist/version.d.ts +13 -0
  89. package/pd-mcp-server/dist/version.js +13 -0
  90. package/pd-mcp-server/dist/workspace.d.ts +23 -0
  91. package/pd-mcp-server/dist/workspace.js +77 -0
  92. package/pd-mcp-server/package.json +49 -0
  93. package/plugin/dist/bundle.js +414 -412
  94. package/plugin/dist/core/mcp-binding.d.ts +41 -0
  95. package/plugin/dist/index.d.ts +33 -0
  96. package/plugin/dist/openclaw.plugin.json +10 -1
  97. package/plugin/dist/utils/workspace-resolver.d.ts +17 -0
  98. package/plugin/openclaw.plugin.json +10 -1
  99. package/plugin/package.json +2 -1
  100. package/release-manager/dist/installer.js +52 -1
  101. package/release-manager/dist/installers/mcp-registration-conflict.js +559 -0
  102. package/release-manager/dist/installers/openclaw-host-installer.js +20 -0
  103. package/release-manager/dist/installers/workbuddy-host-installer.js +124 -2
  104. package/release-manager/dist/mcp-server-layout-compat.js +22 -0
  105. package/release-manager/package.json +7 -5
  106. package/workbuddy-adapter/dist/pd-hook.js +28 -5
  107. package/workbuddy-adapter/dist/worker/workspace-worker.d.ts +6 -1
  108. package/workbuddy-adapter/dist/worker/workspace-worker.js +24 -3
  109. package/workbuddy-adapter/package.json +1 -1
  110. package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +2 -2
  111. package/workbuddy-adapter/plugin/.mcp.json +10 -0
  112. 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
- * PRI-788 G3 earned precision:llm 学习词不再恒为 ambiguous——确认证据达标
115
- * (TP ≥ 3 且零 FP)时升 high,出现任一 FP 即降回 ambiguous。安全姿态不变:
116
- * LLM 单次建议无法自升 STRONG,必须积累 LLM 确认的正例。owner_promoted 仍按
117
- * 权重判定(Owner 显式决策不受证据门槛约束)。
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
- function precisionFor(source, weight, stats) {
120
- if (source === 'llm') {
121
- const tp = stats?.truePositiveCount ?? 0;
122
- const fp = stats?.falsePositiveCount ?? 0;
123
- return tp >= EARNED_PRECISION_MIN_TP && fp === 0 ? 'high' : 'ambiguous';
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, weight, kw),
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
- function insertCorrectionPain({ db, candidate, workspaceDir, painId, detection, nowIso }) {
407
- const reason = candidate.explicitOwnerStatement === true
408
- ? candidate.explicitChannel === 'codex_explicit_agent'
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
- : detection !== undefined && detection.matchedTerms.length > 0
412
- ? `User correction detected: ${detection.matchedTerms.join(', ')}`
413
- : 'User correction detected';
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 toolFailureTaskPayload(fields) {
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 JSON.stringify(payload);
460
+ return payload;
446
461
  }
447
- function taskSubmissionPayload(candidate, workspaceDir, details) {
448
- const { reason, canonicalPainId } = details;
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
- // WorkBuddy explicit intake has no transcript observation to promote.
461
- // The classified Owner statement is its input basis, not a pseudo-trace.
462
- evidence: candidate.hostKind === 'workbuddy'
463
- ? candidate.explicitOwnerStatement === true ? [] : [{
464
- // WorkBuddy has no governance_observation, anchor to the canonical
465
- // Pain persisted in this same trajectory.db transaction.
466
- sourceRef: 'pain_event:' + (canonicalPainId ?? ''),
467
- note: sanitizeString(candidate.text, workspaceDir),
468
- }]
469
- : [{ sourceRef: 'governance_observation:' + candidate.logicalObservationKey, note: sanitizeString(candidate.text, workspaceDir) }],
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 JSON.stringify(payload);
519
+ return payload;
480
520
  }
481
- return toolFailureTaskPayload({
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
- if (!isStrongHighPrecision) {
552
- // Ordinary conversation / weak negation / ambiguous candidates: no pain,
553
- // no task, no quota, and no marker (SPEC §18 scenario 6 — silence).
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, detection: detection?.output, nowIso });
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, canonicalPainId: painId }),
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: typeof canonicalPainId === 'string' ? 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
- * Pure check: no DB, no classifier, no historical transcript. The optional
804
- * store parameter is how the shared live vocabulary enters (AUDIT-P3-01);
805
- * omitted, the check keeps the P0 four-term projection (backward compatible).
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 assessWorkbuddyLiveKeyword(text, sessionId, store) {
1695
+ export function assessWorkbuddyCorrectionCandidate(text, sessionId, store) {
808
1696
  if (Buffer.byteLength(text, 'utf8') > WORKBUDDY_MAX_PROMPT_BYTES) {
809
- return { matched: false, reason: 'prompt_too_large' };
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 { matched: false, reason: 'not_a_signal' };
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 matched = out.isSignal && out.strength === 'STRONG' &&
1705
+ const strong = out.isSignal && out.strength === 'STRONG' &&
818
1706
  out.type === 'correction' && out.matchedPrecision === 'high';
819
- return { matched, reason: matched ? 'matched' : 'not_a_signal' };
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
- if (!assessWorkbuddyLiveKeyword(prompt, sessionId, sharedStore).matched) {
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: 'host_context_bound',
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: [...payloadParsed.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.' };