harness-everything 0.30.9 → 0.31.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +1 -1
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +8 -9
  6. package/VERIFICATION.md +1 -1
  7. package/benchmarks/results/live-host/claude-2026-10-08-issue-297/README.md +27 -0
  8. package/benchmarks/results/live-host/codex-2026-10-13/README.md +55 -0
  9. package/benchmarks/results/live-host/codex-2026-10-13/observation-records.json +88 -0
  10. package/benchmarks/results/pr-303-review-2026-10-13/README.md +81 -0
  11. package/benchmarks/results/pr-303-review-2026-10-13/ci/run.json +1330 -0
  12. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/mechanism-35-clean-env.log +1 -0
  13. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/mechanism-35-inherited-flag.log +34 -0
  14. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate-captured.json +46 -0
  15. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate-diagnosis.md +13 -0
  16. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate.json +46 -0
  17. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/summary.json +57 -0
  18. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-collision.log +8 -0
  19. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-consistency.log +624 -0
  20. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-docs-capabilities.log +131 -0
  21. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-platform-compatibility.log +9 -0
  22. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-plugin-openai.log +6 -0
  23. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-plugin-submission.log +5 -0
  24. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-references.log +6 -0
  25. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-release.log +73 -0
  26. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-repo-contract.log +5 -0
  27. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-routing-invariants.log +785 -0
  28. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-routing-skills.log +44 -0
  29. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/summary.json +12 -0
  30. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/test-consistency.log +624 -0
  31. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/test-release.log +73 -0
  32. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/summary.json +79 -0
  33. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test-mechanism.log +1737 -0
  34. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test-mutations.log +16 -0
  35. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test.log +3297 -0
  36. package/benchmarks/results/pr-303-review-2026-10-13/manifest.json +319 -0
  37. package/benchmarks/results/pr-303-review-2026-10-13/regression/binding-resolution-green.log +36 -0
  38. package/benchmarks/results/pr-303-review-2026-10-13/regression/determinism.json +174 -0
  39. package/benchmarks/results/pr-303-review-2026-10-13/regression/quality-evidence.json +314 -0
  40. package/benchmarks/results/pr-303-review-2026-10-13/regression/quality-report.json +638 -0
  41. package/benchmarks/results/pr-303-review-2026-10-13/regression/red.log +240 -0
  42. package/benchmarks/results/pr-303-review-2026-10-13/regression/untyped-fable-probe.json +22 -0
  43. package/benchmarks/results/pr-303-review-2026-10-13/regression/workflow-obligations-green.log +254 -0
  44. package/benchmarks/results/pr-303-review-2026-10-13/verification/mechanism-35-clean-env-fixed.log +1 -0
  45. package/benchmarks/results/pr-303-review-2026-10-13/verification/mechanism-35-inherited-flag-fixed.log +1 -0
  46. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.meta.json +8 -0
  47. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.stderr.log +90 -0
  48. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.stdout.log +3186 -0
  49. package/benchmarks/results/pr-303-review-2026-10-13/verification/verify-gate-final.json +46 -0
  50. package/bin/cli.js +1 -1
  51. package/ci/doc-capability-consistency.test.js +11 -1
  52. package/ci/invariant-routing.test.js +7 -4
  53. package/ci/mechanism-2ag-router-worktree-contract.test.js +2 -2
  54. package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +2 -1
  55. package/ci/mechanism-2al-codex-hooks-compat.test.js +19 -0
  56. package/ci/mechanism-2am-observations-session-import.test.js +221 -0
  57. package/ci/mechanism-2an-workflow-obligations.test.js +352 -3
  58. package/ci/mechanism-2ao-plugin-sync.test.js +129 -4
  59. package/ci/mechanism-2aq-fable-stage-bindings.test.js +168 -0
  60. package/ci/mechanism-2ar-binding-resolution.test.js +211 -0
  61. package/ci/mechanism-2w-platform-compatibility.test.js +12 -0
  62. package/ci/mechanism-34-observations.test.js +64 -0
  63. package/ci/mechanism-35-verification-contract.test.js +1 -1
  64. package/ci/mutation-check.js +2 -0
  65. package/ci/mutation-loader.js +4 -0
  66. package/ci/openai-plugin-package.test.js +7 -5
  67. package/ci/prompt-contract-regressions.test.js +7 -6
  68. package/ci/router-workflow-plan.test.js +109 -14
  69. package/ci/skill-routing-check.js +44 -15
  70. package/ci/workflow-plan-consumers.test.js +41 -1
  71. package/docs/architecture.md +10 -10
  72. package/docs/enforcement-locks.md +1 -1
  73. package/docs/mechanism-first-skill-mesh.md +12 -11
  74. package/docs/openai-plugin.md +1 -1
  75. package/docs/philosophy.md +7 -6
  76. package/docs/platform-capabilities.md +14 -12
  77. package/docs/platform-compatibility.json +5 -5
  78. package/docs/routing.md +16 -17
  79. package/docs/system-one-observations.md +36 -9
  80. package/docs/system-one-routing.md +10 -7
  81. package/docs/system-one-suggestion-gates.md +5 -5
  82. package/docs/workflow-runtime.md +6 -2
  83. package/docs/workflows/harness-everything.md +14 -14
  84. package/evals/harness-everything/tasks/positive-tier1.yaml +1 -1
  85. package/evals/harness-everything/tasks/positive-tier2.yaml +1 -1
  86. package/evals/harness-everything/tasks/positive-tier3.yaml +1 -1
  87. package/fable-mode/CONTRACT-FORMAT.md +4 -1
  88. package/fable-mode/SKILL.md +6 -8
  89. package/fable-mode/agents/fable-orchestrator.md +10 -3
  90. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  91. package/fable-mode/fable-haiku/SKILL.md +1 -1
  92. package/fable-mode/fable-opus/SKILL.md +1 -1
  93. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  94. package/fable-mode/scripts/workflow-plan-consumer.js +101 -2
  95. package/harness-everything/SKILL.md +8 -7
  96. package/harness-everything/references/router-workflow-plan.md +11 -6
  97. package/harness-everything/references/triage-and-tiers.md +21 -23
  98. package/harness-everything/schemas/router-workflow-plan.schema.json +2 -0
  99. package/harness-everything/scripts/kernel-router-core.js +52 -55
  100. package/harness-everything/scripts/kernel-router.js +48 -5
  101. package/harness-everything/scripts/router-contract-core.js +7 -7
  102. package/harness-everything/scripts/router-contract.js +1 -3
  103. package/harness-everything/scripts/routing-keywords.json +417 -121
  104. package/harness-everything/scripts/tier-router.js +64 -113
  105. package/hooks/scripts/contract-test.js +29 -3
  106. package/hooks/scripts/lib/binding-resolver.js +76 -0
  107. package/hooks/scripts/lib/fable-contracts.js +1 -1
  108. package/hooks/scripts/lib/observations.js +88 -12
  109. package/hooks/scripts/lib/workflow-obligations.js +292 -7
  110. package/hooks/scripts/lib/workflow-runtime.js +8 -1
  111. package/hooks/scripts/workflow-disposition.js +116 -2
  112. package/opencode-plugin/plugin.json +1 -1
  113. package/package.json +1 -1
  114. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  115. package/plugins/harness-everything/hooks/scripts/contract-test.js +29 -3
  116. package/plugins/harness-everything/hooks/scripts/lib/binding-resolver.js +76 -0
  117. package/plugins/harness-everything/hooks/scripts/lib/fable-contracts.js +1 -1
  118. package/plugins/harness-everything/hooks/scripts/lib/observations.js +88 -12
  119. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +292 -7
  120. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +8 -1
  121. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +116 -2
  122. package/plugins/harness-everything/plugin.json +1 -1
  123. package/plugins/harness-everything/scripts/lib/advisory-text.js +2 -2
  124. package/plugins/harness-everything/scripts/lib/generated-skills.js +78 -0
  125. package/plugins/harness-everything/skills/fable-mode/CONTRACT-FORMAT.md +4 -1
  126. package/plugins/harness-everything/skills/fable-mode/SKILL.md +6 -8
  127. package/plugins/harness-everything/skills/fable-mode/agents/fable-orchestrator.md +10 -3
  128. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  129. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  130. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  131. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  132. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +101 -2
  133. package/plugins/harness-everything/skills/harness-everything/SKILL.md +8 -7
  134. package/plugins/harness-everything/skills/harness-everything/references/router-workflow-plan.md +11 -6
  135. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +21 -23
  136. package/plugins/harness-everything/skills/harness-everything/schemas/router-workflow-plan.schema.json +2 -0
  137. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +52 -55
  138. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +48 -5
  139. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +7 -7
  140. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract.js +1 -3
  141. package/plugins/harness-everything/skills/harness-everything/scripts/routing-keywords.json +417 -121
  142. package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +64 -113
  143. package/sbom.cdx.json +7 -7
  144. package/scripts/codex-hooks-compat.js +39 -14
  145. package/scripts/lib/advisory-text.js +2 -2
  146. package/scripts/lib/generated-skills.js +78 -0
  147. package/scripts/plugin-sync.js +100 -29
  148. package/scripts/system-one-observations-export.js +29 -2
  149. package/scripts/system-one-observations-import-codex-sessions.js +307 -0
  150. package/scripts/system-one-observations-review.js +11 -1
@@ -39,16 +39,26 @@ function resultState(payload) {
39
39
  return { failed, exitCode, evidence };
40
40
  }
41
41
 
42
+ function unresolvedBindings(contract) {
43
+ const bindings = [...(contract.requiredBindings || []), ...(contract.optionalBindings || [])];
44
+ return bindings.filter(binding => binding.required !== false
45
+ ? binding.status !== 'loaded'
46
+ : !['loaded', 'not-needed'].includes(binding.status));
47
+ }
48
+
42
49
  function updateRunContract(entry, payload, command, sessionId) {
43
50
  const { failed, exitCode, evidence } = resultState(payload);
51
+ const unresolved = unresolvedBindings(entry.contract);
52
+ const status = failed ? 'fail' : unresolved.length ? 'binding-unresolved' : 'pass';
44
53
  const observedAt = new Date().toISOString();
45
54
  const contract = {
46
55
  ...entry.contract,
47
56
  workerId: entry.contract.workerId || getWorkerId(payload),
48
- status: failed ? 'fail' : 'pass',
57
+ status,
49
58
  lastObservedCommand: command,
50
59
  lastObservedExitCode: exitCode ?? null,
51
60
  evidence,
61
+ unresolvedBindings: unresolved.map(binding => binding.id),
52
62
  verifiedAt: observedAt,
53
63
  updatedAt: observedAt,
54
64
  };
@@ -63,7 +73,9 @@ function updateRunContract(entry, payload, command, sessionId) {
63
73
  sessionId: sessionId || contract.sessionId || null,
64
74
  workerId: contract.workerId || null,
65
75
  checkCommand: command,
66
- status: failed ? 'fail' : 'pass',
76
+ status,
77
+ checkStatus: failed ? 'fail' : 'pass',
78
+ unresolvedBindings: unresolved.map(binding => binding.id),
67
79
  exitCode: exitCode ?? null,
68
80
  evidence,
69
81
  observedAt,
@@ -72,6 +84,15 @@ function updateRunContract(entry, payload, command, sessionId) {
72
84
  return contract;
73
85
  }
74
86
 
87
+ function activeFableStages(runRoot) {
88
+ for (const relative of ['../../fable-mode/scripts/workflow-plan-consumer.js', '../../skills/fable-mode/scripts/workflow-plan-consumer.js']) {
89
+ const file = path.resolve(__dirname, relative);
90
+ if (!fs.existsSync(file)) continue;
91
+ try { return require(file).activeStageBindings(runRoot); } catch (_) { return []; }
92
+ }
93
+ return [];
94
+ }
95
+
75
96
  function resolveLegacyContract(stateRoot, command, payload) {
76
97
  const contractsDir = path.join(stateRoot, 'contracts');
77
98
  let files = [];
@@ -110,7 +131,7 @@ process.stdin.on('end', () => {
110
131
  const workflowContext = loadWorkflow(payload);
111
132
  const activeRun = workflowContext.workflow?.workflowId ? matchingRun(workflowContext) : null;
112
133
  const runMatches = listRunContracts(stateRoot).filter(({ contract }) =>
113
- (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass'] : ['pending', 'planned', 'running']).includes(contract.status) && commandMatches(contract, command) &&
134
+ (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass', 'binding-unresolved'] : ['pending', 'planned', 'running', 'fail', 'binding-unresolved']).includes(contract.status) && commandMatches(contract, command) &&
114
135
  (!workflowContext.workflow?.workflowId || (activeRun && contract.runId === activeRun.run.runId && contract.sessionId === sessionId))
115
136
  );
116
137
 
@@ -163,6 +184,11 @@ process.stdin.on('end', () => {
163
184
  }
164
185
  }
165
186
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
187
+ if (contract.status === 'binding-unresolved') {
188
+ console.error(`Resolve declared stage bindings before this stage can pass: ${contract.unresolvedBindings.join(', ')}`);
189
+ }
190
+ const activeStages = activeFableStages(entry.runRoot);
191
+ if (activeStages.length) console.log('[Contract Test] Active stage bindings: ' + JSON.stringify(activeStages));
166
192
  if (contract.status === 'fail') {
167
193
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);
168
194
  console.error(`Review this failed stage before relying on its output. Artifact: ${contract.outputPath || '(no path recorded)'}`);
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // Resolves a declared step/stage binding to a real, readable file. A binding
4
+ // path is availability evidence only after it resolves under the workspace or
5
+ // an installed Harness skill root; path syntax alone proves nothing.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function loadGeneratedSkills() {
10
+ const file = path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'generated-skills.js');
11
+ return fs.existsSync(file) ? require(file) : null;
12
+ }
13
+
14
+ // Source checkout and Claude plugin keep skills at the package root; the
15
+ // Codex plugin keeps them under skills/. Host-provided plugin roots win.
16
+ function packageRoots() {
17
+ const packageRoot = path.resolve(__dirname, '..', '..', '..');
18
+ const roots = [];
19
+ for (const root of [process.env.CLAUDE_PLUGIN_ROOT, process.env.PLUGIN_ROOT, packageRoot]) {
20
+ if (!root) continue;
21
+ roots.push(path.resolve(root), path.resolve(root, 'skills'));
22
+ }
23
+ return [...new Set(roots)];
24
+ }
25
+
26
+ function readableFile(file) {
27
+ try {
28
+ if (!fs.statSync(file).isFile()) return false;
29
+ fs.accessSync(file, fs.constants.R_OK);
30
+ return true;
31
+ } catch (_) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ // Registered Harness bindings and the path each one loads.
37
+ const PACKAGED_BINDING_PATHS = {
38
+ tdd: 'tdd/SKILL.md',
39
+ 'verification-loop': 'verification-loop/SKILL.md',
40
+ 'git-commit': 'git-commit/SKILL.md',
41
+ 'security-review': 'security-review/SKILL.md',
42
+ 'review-guidance': 'harness-everything/references/triage-and-tiers.md',
43
+ };
44
+ const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
45
+
46
+ // Only a recognized Harness binding may search installation roots: a
47
+ // registered id at its registered path, or a skill id naming its own
48
+ // `<id>/SKILL.md`. Any other path is a workspace reference and must never
49
+ // resolve to an unrelated file that happens to exist in the Harness package.
50
+ function isPackagedBinding(binding) {
51
+ if (!binding.path) return false;
52
+ if (PACKAGED_BINDING_PATHS[binding.id]) return PACKAGED_BINDING_PATHS[binding.id] === binding.path;
53
+ return SKILL_ID.test(binding.id || '') && binding.path === `${binding.id}/SKILL.md`;
54
+ }
55
+
56
+ // Returns { availability, file }: `available` with an absolute readable file,
57
+ // `missing` when a declared path resolves nowhere, or `unknown` when neither a
58
+ // path nor a registered generated skill names a location.
59
+ function resolveBinding(binding, workspaceRoot) {
60
+ const roots = isPackagedBinding(binding) ? [...packageRoots(), workspaceRoot] : [workspaceRoot];
61
+ if (binding.path) {
62
+ for (const root of roots.filter(Boolean)) {
63
+ const file = path.resolve(root, binding.path);
64
+ if (readableFile(file)) return { availability: 'available', file };
65
+ }
66
+ return { availability: 'missing', file: null };
67
+ }
68
+ const generated = loadGeneratedSkills()?.findGeneratedSkill(binding.id, workspaceRoot);
69
+ if (generated) {
70
+ const file = path.join(generated.dirPath, 'SKILL.md');
71
+ return readableFile(file) ? { availability: 'available', file } : { availability: 'missing', file: null };
72
+ }
73
+ return { availability: 'unknown', file: null };
74
+ }
75
+
76
+ module.exports = { resolveBinding, packageRoots, isPackagedBinding, PACKAGED_BINDING_PATHS };
@@ -102,7 +102,7 @@ function stagesForChangedPath(filePath, contractEntries) {
102
102
 
103
103
  function getActiveRunContracts(stateRoot, sessionId) {
104
104
  return listRunContracts(stateRoot).filter(({ contract }) => {
105
- if (!['pending', 'planned', 'running'].includes(contract.status)) return false;
105
+ if (!['pending', 'planned', 'running', 'fail', 'binding-unresolved'].includes(contract.status)) return false;
106
106
  return !sessionId || !contract.sessionId || contract.sessionId === sessionId;
107
107
  });
108
108
  }
@@ -370,15 +370,16 @@ function routerState(payload) {
370
370
  }
371
371
  }
372
372
 
373
- function withLock(root, fn) {
373
+ function withLock(root, fn, options = {}) {
374
374
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
375
375
  const lock = path.join(root, '.index.lock');
376
- const deadline = Date.now() + 2000;
376
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs >= 0 ? options.timeoutMs : 2000;
377
+ const deadline = Date.now() + timeoutMs;
377
378
  let fd = null;
378
379
  while (fd === null) {
379
380
  try { fd = fs.openSync(lock, 'wx'); } catch (_) {
380
381
  try { if (Date.now() - fs.statSync(lock).mtimeMs > 5000) fs.unlinkSync(lock); } catch (__) { /* raced */ }
381
- if (Date.now() > deadline) return false;
382
+ if (Date.now() >= deadline) return false;
382
383
  const until = Date.now() + 10;
383
384
  while (Date.now() < until) { /* short spin; hooks are short-lived */ }
384
385
  }
@@ -432,18 +433,88 @@ function sweep(root, now = Date.now()) {
432
433
  });
433
434
  }
434
435
 
435
- function appendRecord(root, record, text) {
436
- return withLock(root, () => {
436
+ function appendRecordDetailed(root, record, text, options = {}) {
437
+ let outcome = null;
438
+ const locked = withLock(root, () => {
439
+ const file = path.join(root, 'observations-index.json');
440
+ const index = readJson(file, null) || { schemaVersion: 1, kind: 'system-one-observations', records: [] };
441
+ if (options.dedupeById && index.records.some(existing => existing.id === record.id)) {
442
+ outcome = 'duplicate';
443
+ return;
444
+ }
445
+ if (options.dedupeByTurn) {
446
+ const match = observationTurnMatch(index.records, record);
447
+ if (match) {
448
+ outcome = match === 'ambiguous-legacy-turn' ? 'ambiguous' : 'duplicate';
449
+ return;
450
+ }
451
+ }
437
452
  // The blob is written under the lock, so a concurrent sweep cannot remove it before its record lands.
438
453
  const textDir = path.join(root, 'text');
439
454
  fs.mkdirSync(textDir, { recursive: true, mode: 0o700 });
440
- if (text) fs.writeFileSync(path.join(textDir, `${record.contentSha256}.json`), JSON.stringify(text), { mode: 0o600 });
441
- const file = path.join(root, 'observations-index.json');
442
- const index = readJson(file, null) || { schemaVersion: 1, kind: 'system-one-observations', records: [] };
455
+ if (text) fs.writeFileSync(path.join(textDir, record.contentSha256 + '.json'), JSON.stringify(text), { mode: 0o600 });
443
456
  index.records.push(record);
444
457
  sweepLocked(root, index, Date.now());
445
458
  writeJsonAtomic(file, index);
446
- });
459
+ outcome = 'appended';
460
+ }, { timeoutMs: options.lockTimeoutMs });
461
+ if (!locked) return { status: 'lock-timeout' };
462
+ return { status: outcome || 'appended' };
463
+ }
464
+
465
+ function appendRecord(root, record, text, options = {}) {
466
+ return appendRecordDetailed(root, record, text, options).status === 'appended';
467
+ }
468
+
469
+ function observationTurnMatch(records, record) {
470
+ const writer = record && record.writer || {};
471
+ const sessionId = String(writer.sessionId || '');
472
+ const turnId = String(writer.turnId || '');
473
+ if (!sessionId || !record.host) return null;
474
+ const sameSession = (Array.isArray(records) ? records : []).filter(existing =>
475
+ existing && existing.host === record.host &&
476
+ String(existing.writer && existing.writer.sessionId || '') === sessionId
477
+ );
478
+ if (!sameSession.length) return null;
479
+
480
+ if (turnId && sameSession.some(existing => String(existing.writer && existing.writer.turnId || '') === turnId)) {
481
+ return 'same-turn-id';
482
+ }
483
+
484
+ // Older live records have no turn ID. Only treat matching ordinal plus text
485
+ // as a definite duplicate; a partial match is ambiguous and the importer
486
+ // skips it instead of risking a second training row for the same turn.
487
+ const legacy = sameSession.filter(existing => existing.captureMethod !== 'session-backfill' &&
488
+ (!turnId || !String(existing.writer && existing.writer.turnId || '')));
489
+ if (!legacy.length) return null;
490
+ const sameTurn = existing => Number.isInteger(existing.turn) && existing.turn === record.turn;
491
+ const sameText = existing => Boolean(existing.contentSha256 && existing.contentSha256 === record.contentSha256);
492
+ if (legacy.some(existing => sameTurn(existing) && sameText(existing))) return 'legacy-same-turn';
493
+ if (legacy.some(existing => sameTurn(existing) || sameText(existing))) return 'ambiguous-legacy-turn';
494
+ return null;
495
+ }
496
+
497
+ function findObservationTurnMatch(root, record) {
498
+ const index = readJson(path.join(root, 'observations-index.json'), null);
499
+ return observationTurnMatch(index && index.records, record);
500
+ }
501
+
502
+ function markSessionBackfillBehaviorUnobserved(root, options = {}) {
503
+ const file = path.join(root, 'observations-index.json');
504
+ if (!fs.existsSync(file)) return 0;
505
+ let changed = 0;
506
+ const locked = withLock(root, () => {
507
+ const index = readJson(file, null);
508
+ if (!index || !Array.isArray(index.records)) return;
509
+ for (const record of index.records) {
510
+ if (record.captureMethod === 'session-backfill' && record.behavior !== null) {
511
+ record.behavior = null;
512
+ changed++;
513
+ }
514
+ }
515
+ if (changed) writeJsonAtomic(file, index);
516
+ }, { timeoutMs: options.lockTimeoutMs });
517
+ return locked ? changed : null;
447
518
  }
448
519
 
449
520
  // Tool events are immutable files, one per event, so parallel hooks never
@@ -484,6 +555,7 @@ function startTurn(payload, root) {
484
555
  session.turn++;
485
556
  session.pending = {
486
557
  turn: session.turn,
558
+ turnId: String(payload.turn_id || payload.turnId || ''),
487
559
  startedAt: new Date().toISOString(),
488
560
  prompt: redact(typeof payload.prompt === 'string' ? payload.prompt : ''),
489
561
  previous: session.lastFinal || '',
@@ -505,6 +577,7 @@ function toolTurn(payload, root, failed, codex) {
505
577
 
506
578
  function finishPending(session, payload, root, final) {
507
579
  const pending = session.pending;
580
+ const turnId = String(payload.turn_id || payload.turnId || pending.turnId || '');
508
581
  const { label, reason } = final.text === null && final.reason ? { label: null, reason: final.reason } : parseLabelLine(final.text);
509
582
  const collected = collectEvents(root, payload.session_id, pending.turn);
510
583
  const behavior = { ...collected };
@@ -531,7 +604,10 @@ function finishPending(session, payload, root, final) {
531
604
  contentSha256,
532
605
  host: detectHost(payload, { codex: final.codex || collected._codex }),
533
606
  turn: pending.turn,
534
- writer: { sessionId: String(payload.session_id || 'unknown') },
607
+ writer: {
608
+ sessionId: String(payload.session_id || 'unknown'),
609
+ ...(turnId ? { turnId } : {}),
610
+ },
535
611
  scope: { taskTerms: normalizeTerms(pending.prompt), requirementTerms: [], roles: [] },
536
612
  promptBytes: Buffer.byteLength(pending.prompt, 'utf8'),
537
613
  router: routerState(payload),
@@ -575,7 +651,7 @@ function handle(payload, env = process.env) {
575
651
  }
576
652
 
577
653
  module.exports = {
578
- INTENTS, TIERS, STRATEGIES, enabled, storeRoot, redact, parseLabelLine, classifyCommand, labelTier, contradictions,
579
- normalizeTerms, transcriptMessage, recordTool, emptyBehavior, mergeBehavior, toolFailed, handle, readJson,
654
+ INTENTS, TIERS, STRATEGIES, RETENTION_MS, PREVIOUS_BYTES, enabled, storeRoot, redact, parseLabelLine, stripLabels, tailBytes, sha, classifyCommand, labelTier, contradictions,
655
+ normalizeTerms, transcriptMessage, recordTool, emptyBehavior, mergeBehavior, toolFailed, handle, readJson, appendRecord, appendRecordDetailed, observationTurnMatch, findObservationTurnMatch, markSessionBackfillBehaviorUnobserved,
580
656
  expired, recordText, sweep,
581
657
  };