@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.24

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 (66) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/core/boss-cards/index.js +199 -199
  14. package/src/core/browser/index.js +286 -136
  15. package/src/core/capture/index.js +2930 -1201
  16. package/src/core/cv-acquisition/index.js +512 -238
  17. package/src/core/cv-capture-target/index.js +513 -299
  18. package/src/core/greet-quota/index.js +71 -71
  19. package/src/core/infinite-list/index.js +31 -31
  20. package/src/core/reporting/legacy-csv.js +12 -12
  21. package/src/core/run/detached-launcher.js +305 -0
  22. package/src/core/run/index.js +105 -52
  23. package/src/core/run/timing.js +33 -33
  24. package/src/core/run/windows-detached-worker.ps1 +106 -0
  25. package/src/core/screening/index.js +2135 -2135
  26. package/src/core/self-heal/index.js +989 -973
  27. package/src/core/self-heal/viewport.js +1505 -564
  28. package/src/detached-worker.js +99 -99
  29. package/src/domains/chat/action-journal.js +443 -0
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +1684 -401
  33. package/src/domains/chat/index.js +8 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +157 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +1801 -760
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +515 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +92 -92
  44. package/src/domains/recommend/detail.js +12 -12
  45. package/src/domains/recommend/filters.js +611 -611
  46. package/src/domains/recommend/index.js +9 -9
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -736
  49. package/src/domains/recommend/refresh.js +422 -422
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1791 -1205
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1817 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +16 -1
  64. package/src/parser.js +1296 -1296
  65. package/src/recommend-mcp.js +551 -362
  66. package/src/recommend-scheduler.js +75 -75
@@ -1,99 +1,99 @@
1
- #!/usr/bin/env node
2
- import {
3
- markBossChatDetachedWorkerFailed,
4
- runBossChatDetachedWorker
5
- } from "./chat-mcp.js";
6
- import {
7
- markBossRecruitDetachedWorkerFailed,
8
- runBossRecruitDetachedWorker
9
- } from "./recruit-mcp.js";
10
-
11
- function normalizeText(value) {
12
- return String(value || "").replace(/\s+/g, " ").trim();
13
- }
14
-
15
- function parseArgs(argv = []) {
16
- const args = {};
17
- for (let index = 0; index < argv.length; index += 1) {
18
- const item = argv[index];
19
- if (item === "--domain") {
20
- args.domain = normalizeText(argv[index + 1]).toLowerCase();
21
- index += 1;
22
- } else if (item === "--run-id") {
23
- args.runId = normalizeText(argv[index + 1]);
24
- index += 1;
25
- }
26
- }
27
- return args;
28
- }
29
-
30
- function markFailed(domain, runId, error, options = {}) {
31
- if (domain === "chat") {
32
- return markBossChatDetachedWorkerFailed(runId, error, options);
33
- }
34
- if (domain === "recruit") {
35
- return markBossRecruitDetachedWorkerFailed(runId, error, options);
36
- }
37
- return null;
38
- }
39
-
40
- function installFailureHandlers(domain, runId) {
41
- let handled = false;
42
- const failOnce = (error, options = {}) => {
43
- if (handled) return;
44
- handled = true;
45
- try {
46
- markFailed(domain, runId, error, options);
47
- } catch (markError) {
48
- console.error("[boss-recommend-mcp] failed to persist detached worker failure", markError);
49
- }
50
- };
51
-
52
- process.on("uncaughtException", (error) => {
53
- console.error("[boss-recommend-mcp] detached worker uncaught exception", error);
54
- failOnce(error, { code: "DETACHED_WORKER_UNCAUGHT_EXCEPTION" });
55
- process.exit(1);
56
- });
57
-
58
- process.on("unhandledRejection", (reason) => {
59
- console.error("[boss-recommend-mcp] detached worker unhandled rejection", reason);
60
- const error = reason instanceof Error ? reason : new Error(normalizeText(reason) || "Unhandled promise rejection");
61
- failOnce(error, { code: "DETACHED_WORKER_UNHANDLED_REJECTION" });
62
- process.exit(1);
63
- });
64
-
65
- for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
66
- process.on(signal, () => {
67
- const error = new Error(`detached ${domain} worker received ${signal}`);
68
- console.error("[boss-recommend-mcp] detached worker received signal", signal);
69
- failOnce(error, { code: "DETACHED_WORKER_SIGNAL" });
70
- const signalExitCodes = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
71
- process.exit(signalExitCodes[signal] || 1);
72
- });
73
- }
74
- }
75
-
76
- async function main() {
77
- const options = parseArgs(process.argv.slice(2));
78
- if (!options.domain || !options.runId) {
79
- console.error("[boss-recommend-mcp] detached worker requires --domain and --run-id");
80
- process.exitCode = 1;
81
- return;
82
- }
83
- installFailureHandlers(options.domain, options.runId);
84
- const result = options.domain === "chat"
85
- ? await runBossChatDetachedWorker({ runId: options.runId })
86
- : options.domain === "recruit"
87
- ? await runBossRecruitDetachedWorker({ runId: options.runId })
88
- : { ok: false, error: `Unsupported detached worker domain: ${options.domain}` };
89
- if (!result?.ok) {
90
- process.exitCode = 1;
91
- }
92
- }
93
-
94
- await main().catch((error) => {
95
- const options = parseArgs(process.argv.slice(2));
96
- console.error("[boss-recommend-mcp] detached worker failed", error);
97
- markFailed(options.domain, options.runId, error, { code: "DETACHED_WORKER_FAILED" });
98
- process.exitCode = 1;
99
- });
1
+ #!/usr/bin/env node
2
+ import {
3
+ markBossChatDetachedWorkerFailed,
4
+ runBossChatDetachedWorker
5
+ } from "./chat-mcp.js";
6
+ import {
7
+ markBossRecruitDetachedWorkerFailed,
8
+ runBossRecruitDetachedWorker
9
+ } from "./recruit-mcp.js";
10
+
11
+ function normalizeText(value) {
12
+ return String(value || "").replace(/\s+/g, " ").trim();
13
+ }
14
+
15
+ function parseArgs(argv = []) {
16
+ const args = {};
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const item = argv[index];
19
+ if (item === "--domain") {
20
+ args.domain = normalizeText(argv[index + 1]).toLowerCase();
21
+ index += 1;
22
+ } else if (item === "--run-id") {
23
+ args.runId = normalizeText(argv[index + 1]);
24
+ index += 1;
25
+ }
26
+ }
27
+ return args;
28
+ }
29
+
30
+ function markFailed(domain, runId, error, options = {}) {
31
+ if (domain === "chat") {
32
+ return markBossChatDetachedWorkerFailed(runId, error, options);
33
+ }
34
+ if (domain === "recruit") {
35
+ return markBossRecruitDetachedWorkerFailed(runId, error, options);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function installFailureHandlers(domain, runId) {
41
+ let handled = false;
42
+ const failOnce = (error, options = {}) => {
43
+ if (handled) return;
44
+ handled = true;
45
+ try {
46
+ markFailed(domain, runId, error, options);
47
+ } catch (markError) {
48
+ console.error("[boss-recommend-mcp] failed to persist detached worker failure", markError);
49
+ }
50
+ };
51
+
52
+ process.on("uncaughtException", (error) => {
53
+ console.error("[boss-recommend-mcp] detached worker uncaught exception", error);
54
+ failOnce(error, { code: "DETACHED_WORKER_UNCAUGHT_EXCEPTION" });
55
+ process.exit(1);
56
+ });
57
+
58
+ process.on("unhandledRejection", (reason) => {
59
+ console.error("[boss-recommend-mcp] detached worker unhandled rejection", reason);
60
+ const error = reason instanceof Error ? reason : new Error(normalizeText(reason) || "Unhandled promise rejection");
61
+ failOnce(error, { code: "DETACHED_WORKER_UNHANDLED_REJECTION" });
62
+ process.exit(1);
63
+ });
64
+
65
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
66
+ process.on(signal, () => {
67
+ const error = new Error(`detached ${domain} worker received ${signal}`);
68
+ console.error("[boss-recommend-mcp] detached worker received signal", signal);
69
+ failOnce(error, { code: "DETACHED_WORKER_SIGNAL" });
70
+ const signalExitCodes = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
71
+ process.exit(signalExitCodes[signal] || 1);
72
+ });
73
+ }
74
+ }
75
+
76
+ async function main() {
77
+ const options = parseArgs(process.argv.slice(2));
78
+ if (!options.domain || !options.runId) {
79
+ console.error("[boss-recommend-mcp] detached worker requires --domain and --run-id");
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ installFailureHandlers(options.domain, options.runId);
84
+ const result = options.domain === "chat"
85
+ ? await runBossChatDetachedWorker({ runId: options.runId })
86
+ : options.domain === "recruit"
87
+ ? await runBossRecruitDetachedWorker({ runId: options.runId })
88
+ : { ok: false, error: `Unsupported detached worker domain: ${options.domain}` };
89
+ if (!result?.ok) {
90
+ process.exitCode = 1;
91
+ }
92
+ }
93
+
94
+ await main().catch((error) => {
95
+ const options = parseArgs(process.argv.slice(2));
96
+ console.error("[boss-recommend-mcp] detached worker failed", error);
97
+ markFailed(options.domain, options.runId, error, { code: "DETACHED_WORKER_FAILED" });
98
+ process.exitCode = 1;
99
+ });
@@ -0,0 +1,443 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const CHAT_ACTION_JOURNAL_SCHEMA_VERSION = 1;
7
+
8
+ export const CHAT_ACTION_STATES = Object.freeze([
9
+ "pre_action",
10
+ "greeting_send_in_flight",
11
+ "greeting_confirmed",
12
+ "request_in_flight",
13
+ "request_confirmed",
14
+ "outcome_unknown"
15
+ ]);
16
+
17
+ const CHAT_ACTION_STATE_SET = new Set(CHAT_ACTION_STATES);
18
+ const INITIAL_STATE = "pre_action";
19
+ const ALLOWED_TRANSITIONS = Object.freeze({
20
+ pre_action: new Set(["greeting_send_in_flight"]),
21
+ greeting_send_in_flight: new Set(["greeting_confirmed", "outcome_unknown"]),
22
+ greeting_confirmed: new Set(["request_in_flight"]),
23
+ request_in_flight: new Set(["request_confirmed", "outcome_unknown"]),
24
+ request_confirmed: new Set(),
25
+ outcome_unknown: new Set(["greeting_confirmed", "request_confirmed"])
26
+ });
27
+
28
+ function normalizeText(value) {
29
+ return String(value ?? "").trim();
30
+ }
31
+
32
+ function normalizeGreeting(value) {
33
+ return String(value ?? "").replace(/\s+/gu, " ").trim();
34
+ }
35
+
36
+ function sha256(value) {
37
+ return crypto.createHash("sha256").update(String(value), "utf8").digest("hex");
38
+ }
39
+
40
+ function journalError(code, message, details = {}) {
41
+ const error = new Error(message);
42
+ error.code = code;
43
+ Object.assign(error, details);
44
+ return error;
45
+ }
46
+
47
+ function requireScope(scope) {
48
+ const normalized = normalizeText(scope);
49
+ if (!normalized) {
50
+ throw journalError(
51
+ "CHAT_ACTION_SCOPE_REQUIRED",
52
+ "A stable Boss chat account/profile scope is required for the action journal."
53
+ );
54
+ }
55
+ return normalized;
56
+ }
57
+
58
+ function requireCandidateId(candidateId) {
59
+ const normalized = normalizeText(candidateId);
60
+ if (!normalized) {
61
+ throw journalError(
62
+ "CHAT_ACTION_CANDIDATE_ID_REQUIRED",
63
+ "A stable Boss candidate ID is required before an outbound chat action can be journaled."
64
+ );
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ function requireState(state) {
70
+ const normalized = normalizeText(state);
71
+ if (!CHAT_ACTION_STATE_SET.has(normalized)) {
72
+ throw journalError(
73
+ "CHAT_ACTION_STATE_INVALID",
74
+ `Unsupported Boss chat action journal state: ${normalized || "<empty>"}.`,
75
+ { state: normalized || null }
76
+ );
77
+ }
78
+ return normalized;
79
+ }
80
+
81
+ function defaultBaseDir() {
82
+ const configuredHome = normalizeText(process.env.BOSS_CHAT_HOME);
83
+ const chatHome = configuredHome || path.join(os.homedir(), ".boss-recommend-mcp", "boss-chat");
84
+ return path.join(chatHome, "action-journal");
85
+ }
86
+
87
+ function nowIso(now) {
88
+ const raw = now();
89
+ const date = raw instanceof Date ? raw : new Date(raw);
90
+ if (Number.isNaN(date.getTime())) {
91
+ throw journalError(
92
+ "CHAT_ACTION_JOURNAL_CLOCK_INVALID",
93
+ `Boss chat action journal clock returned an invalid value: ${String(raw)}.`
94
+ );
95
+ }
96
+ return date.toISOString();
97
+ }
98
+
99
+ function cloneRecord(record) {
100
+ return record == null ? null : JSON.parse(JSON.stringify(record));
101
+ }
102
+
103
+ function readJsonRecord(filePath) {
104
+ if (!fs.existsSync(filePath)) return null;
105
+ try {
106
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
107
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
108
+ throw new Error("journal payload is not an object");
109
+ }
110
+ return parsed;
111
+ } catch (error) {
112
+ throw journalError(
113
+ "CHAT_ACTION_JOURNAL_CORRUPT",
114
+ `Unable to read Boss chat action journal record: ${error?.message || error}.`,
115
+ { file_path: filePath }
116
+ );
117
+ }
118
+ }
119
+
120
+ function writeJsonAtomic(filePath, payload) {
121
+ const directory = path.dirname(filePath);
122
+ fs.mkdirSync(directory, { recursive: true });
123
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
124
+ let fileHandle = null;
125
+ try {
126
+ fileHandle = fs.openSync(tempPath, "wx", 0o600);
127
+ fs.writeFileSync(fileHandle, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
128
+ fs.fsyncSync(fileHandle);
129
+ fs.closeSync(fileHandle);
130
+ fileHandle = null;
131
+ fs.renameSync(tempPath, filePath);
132
+
133
+ // Directory fsync is unsupported on some Windows/filesystem combinations.
134
+ // The record itself has already been atomically replaced, so this is best-effort.
135
+ let directoryHandle = null;
136
+ try {
137
+ directoryHandle = fs.openSync(directory, "r");
138
+ fs.fsyncSync(directoryHandle);
139
+ } catch {
140
+ // Keep the portable temp+rename guarantee when directory fsync is unavailable.
141
+ } finally {
142
+ if (directoryHandle != null) fs.closeSync(directoryHandle);
143
+ }
144
+ } finally {
145
+ if (fileHandle != null) fs.closeSync(fileHandle);
146
+ try {
147
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
148
+ } catch {
149
+ // A leftover temp file is never treated as a committed journal record.
150
+ }
151
+ }
152
+ }
153
+
154
+ function acquireRecordLock(lockPath) {
155
+ let handle = null;
156
+ try {
157
+ handle = fs.openSync(lockPath, "wx", 0o600);
158
+ fs.writeFileSync(handle, `${process.pid}\n`, "utf8");
159
+ fs.fsyncSync(handle);
160
+ return handle;
161
+ } catch (error) {
162
+ if (handle != null) {
163
+ try {
164
+ fs.closeSync(handle);
165
+ } catch {}
166
+ try {
167
+ fs.unlinkSync(lockPath);
168
+ } catch {}
169
+ }
170
+ if (error?.code === "EEXIST") {
171
+ throw journalError(
172
+ "CHAT_ACTION_JOURNAL_BUSY",
173
+ "The Boss chat action journal record is locked by another writer; outbound action must fail closed.",
174
+ { lock_path: lockPath }
175
+ );
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ function releaseRecordLock(lockPath, handle) {
182
+ try {
183
+ if (handle != null) fs.closeSync(handle);
184
+ } finally {
185
+ try {
186
+ fs.unlinkSync(lockPath);
187
+ } catch (error) {
188
+ if (error?.code !== "ENOENT") throw error;
189
+ }
190
+ }
191
+ }
192
+
193
+ function outcomeUnknownOrigin(record) {
194
+ const history = Array.isArray(record?.history) ? record.history : [];
195
+ const last = history[history.length - 1];
196
+ return last?.state === "outcome_unknown" ? last.from_state : null;
197
+ }
198
+
199
+ function isAllowedTransition(record, nextState) {
200
+ if (!record) return nextState === INITIAL_STATE;
201
+ if (record.state === nextState) return true;
202
+ if (record.state === "outcome_unknown") {
203
+ const origin = outcomeUnknownOrigin(record);
204
+ return (
205
+ (origin === "greeting_send_in_flight" && nextState === "greeting_confirmed")
206
+ || (origin === "request_in_flight" && nextState === "request_confirmed")
207
+ );
208
+ }
209
+ return ALLOWED_TRANSITIONS[record.state]?.has(nextState) === true;
210
+ }
211
+
212
+ function validateStoredRecord(record, identity, filePath) {
213
+ const valid = (
214
+ record.schema_version === CHAT_ACTION_JOURNAL_SCHEMA_VERSION
215
+ && record.action_key === identity.actionKey
216
+ && record.scope_sha256 === identity.scopeSha256
217
+ && record.candidate_id === identity.candidateId
218
+ && CHAT_ACTION_STATE_SET.has(record.state)
219
+ && typeof record.greeting_sha256 === "string"
220
+ && record.greeting_sha256.length === 64
221
+ && Array.isArray(record.history)
222
+ );
223
+ if (!valid) {
224
+ throw journalError(
225
+ "CHAT_ACTION_JOURNAL_IDENTITY_MISMATCH",
226
+ "Boss chat action journal record identity or schema does not match the requested candidate.",
227
+ { file_path: filePath }
228
+ );
229
+ }
230
+ return record;
231
+ }
232
+
233
+ function appendRunId(runIds, runId) {
234
+ const normalized = normalizeText(runId);
235
+ const current = Array.isArray(runIds) ? runIds.filter(Boolean) : [];
236
+ if (!normalized || current.includes(normalized)) return current;
237
+ return [...current, normalized];
238
+ }
239
+
240
+ const EVIDENCE_KEYS = new Set([
241
+ "action",
242
+ "active_candidate_id",
243
+ "ask_error",
244
+ "ask_ok",
245
+ "confirm_confirmed",
246
+ "confirm_error",
247
+ "greeting_baseline_count",
248
+ "greeting_evidence_readable",
249
+ "message_observed",
250
+ "request_confirmation_source",
251
+ "request_ready_state_observed",
252
+ "reason",
253
+ "request_after_count",
254
+ "request_baseline_count",
255
+ "resume_attachment_after_count",
256
+ "resume_attachment_baseline_count",
257
+ "send_method"
258
+ ]);
259
+
260
+ function sanitizeEvidence(value = {}) {
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
262
+ const result = {};
263
+ for (const [key, raw] of Object.entries(value)) {
264
+ if (!EVIDENCE_KEYS.has(key)) continue;
265
+ if (typeof raw === "boolean" || typeof raw === "number" || raw === null) {
266
+ result[key] = raw;
267
+ continue;
268
+ }
269
+ if (typeof raw === "string") result[key] = raw.slice(0, 240);
270
+ }
271
+ return result;
272
+ }
273
+
274
+ export function hashChatActionGreeting(greeting) {
275
+ const normalized = normalizeGreeting(greeting);
276
+ if (!normalized) {
277
+ throw journalError(
278
+ "CHAT_ACTION_GREETING_REQUIRED",
279
+ "A non-empty greeting is required to initialize a Boss chat outbound action journal record."
280
+ );
281
+ }
282
+ return sha256(`boss-chat-greeting-v1\u0000${normalized}`);
283
+ }
284
+
285
+ export function createChatActionIdentity({ scope, candidateId } = {}) {
286
+ const normalizedScope = requireScope(scope);
287
+ const normalizedCandidateId = requireCandidateId(candidateId);
288
+ const scopeSha256 = sha256(`boss-chat-scope-v1\u0000${normalizedScope}`);
289
+ const actionKey = sha256(
290
+ `boss-chat-request-cv-v1\u0000${normalizedScope}\u0000${normalizedCandidateId}`
291
+ );
292
+ return {
293
+ actionKey,
294
+ scopeSha256,
295
+ candidateId: normalizedCandidateId
296
+ };
297
+ }
298
+
299
+ export function createChatActionJournal({
300
+ baseDir = defaultBaseDir(),
301
+ now = () => new Date()
302
+ } = {}) {
303
+ const resolvedBaseDir = path.resolve(normalizeText(baseDir) || defaultBaseDir());
304
+ if (typeof now !== "function") {
305
+ throw journalError(
306
+ "CHAT_ACTION_JOURNAL_CLOCK_INVALID",
307
+ "Boss chat action journal now must be a function."
308
+ );
309
+ }
310
+
311
+ function entryPath(input = {}) {
312
+ const identity = createChatActionIdentity(input);
313
+ return path.join(resolvedBaseDir, `${identity.actionKey}.json`);
314
+ }
315
+
316
+ function read(input = {}) {
317
+ const identity = createChatActionIdentity(input);
318
+ const filePath = path.join(resolvedBaseDir, `${identity.actionKey}.json`);
319
+ const stored = readJsonRecord(filePath);
320
+ if (!stored) return null;
321
+ return cloneRecord(validateStoredRecord(stored, identity, filePath));
322
+ }
323
+
324
+ function transition({
325
+ scope,
326
+ candidateId,
327
+ state,
328
+ runId = "",
329
+ greeting,
330
+ evidence = {}
331
+ } = {}) {
332
+ const identity = createChatActionIdentity({ scope, candidateId });
333
+ const nextState = requireState(state);
334
+ const filePath = path.join(resolvedBaseDir, `${identity.actionKey}.json`);
335
+ const lockPath = `${filePath}.lock`;
336
+ fs.mkdirSync(resolvedBaseDir, { recursive: true });
337
+ const lockHandle = acquireRecordLock(lockPath);
338
+ try {
339
+ const stored = readJsonRecord(filePath);
340
+ const existing = stored ? validateStoredRecord(stored, identity, filePath) : null;
341
+ const suppliedGreeting = greeting == null ? "" : normalizeGreeting(greeting);
342
+ const suppliedGreetingSha256 = suppliedGreeting ? hashChatActionGreeting(suppliedGreeting) : "";
343
+
344
+ if (!existing && !suppliedGreetingSha256) {
345
+ throw journalError(
346
+ "CHAT_ACTION_GREETING_REQUIRED",
347
+ "The initial Boss chat action journal transition must include the greeting to hash."
348
+ );
349
+ }
350
+ if (
351
+ existing
352
+ && suppliedGreetingSha256
353
+ && suppliedGreetingSha256 !== existing.greeting_sha256
354
+ ) {
355
+ throw journalError(
356
+ "CHAT_ACTION_GREETING_HASH_CONFLICT",
357
+ "The greeting does not match the greeting already journaled for this candidate.",
358
+ { action_key: identity.actionKey }
359
+ );
360
+ }
361
+ if (!isAllowedTransition(existing, nextState)) {
362
+ throw journalError(
363
+ "CHAT_ACTION_TRANSITION_INVALID",
364
+ `Invalid Boss chat action journal transition: ${existing?.state || "<none>"} -> ${nextState}.`,
365
+ {
366
+ action_key: identity.actionKey,
367
+ current_state: existing?.state || null,
368
+ requested_state: nextState
369
+ }
370
+ );
371
+ }
372
+ if (existing?.state === nextState) {
373
+ return {
374
+ changed: false,
375
+ idempotent: true,
376
+ file_path: filePath,
377
+ record: cloneRecord(existing)
378
+ };
379
+ }
380
+
381
+ const at = nowIso(now);
382
+ const normalizedRunId = normalizeText(runId);
383
+ const safeEvidence = sanitizeEvidence(evidence);
384
+ const record = existing ? {
385
+ ...existing,
386
+ state: nextState,
387
+ updated_at: at,
388
+ evidence: {
389
+ ...(existing.evidence && typeof existing.evidence === "object" ? existing.evidence : {}),
390
+ ...safeEvidence
391
+ },
392
+ last_run_id: normalizedRunId || existing.last_run_id || null,
393
+ run_ids: appendRunId(existing.run_ids, normalizedRunId),
394
+ history: [
395
+ ...existing.history,
396
+ {
397
+ from_state: existing.state,
398
+ state: nextState,
399
+ at,
400
+ run_id: normalizedRunId || null,
401
+ evidence: safeEvidence
402
+ }
403
+ ]
404
+ } : {
405
+ schema_version: CHAT_ACTION_JOURNAL_SCHEMA_VERSION,
406
+ action_key: identity.actionKey,
407
+ scope_sha256: identity.scopeSha256,
408
+ candidate_id: identity.candidateId,
409
+ state: nextState,
410
+ greeting_sha256: suppliedGreetingSha256,
411
+ evidence: safeEvidence,
412
+ created_at: at,
413
+ updated_at: at,
414
+ first_run_id: normalizedRunId || null,
415
+ last_run_id: normalizedRunId || null,
416
+ run_ids: appendRunId([], normalizedRunId),
417
+ history: [{
418
+ from_state: null,
419
+ state: nextState,
420
+ at,
421
+ run_id: normalizedRunId || null,
422
+ evidence: safeEvidence
423
+ }]
424
+ };
425
+ writeJsonAtomic(filePath, record);
426
+ return {
427
+ changed: true,
428
+ idempotent: false,
429
+ file_path: filePath,
430
+ record: cloneRecord(record)
431
+ };
432
+ } finally {
433
+ releaseRecordLock(lockPath, lockHandle);
434
+ }
435
+ }
436
+
437
+ return Object.freeze({
438
+ base_dir: resolvedBaseDir,
439
+ entryPath,
440
+ read,
441
+ transition
442
+ });
443
+ }