fraim-hub 2.0.339 → 2.0.340
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/registry/scripts/learning/completion-state.js +153 -0
- package/dist/src/ai-hub/desktop-main.js +46 -0
- package/dist/src/ai-hub/hosts.js +18 -0
- package/dist/src/ai-hub/server.js +67 -69
- package/dist/src/core/handoff-contracts.js +617 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +96 -19
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-store.js +86 -55
- package/package.json +4 -2
- package/public/ai-hub/review.css +378 -0
- package/public/ai-hub/script.js +763 -33
- package/public/ai-hub/styles.css +53 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.COMPLETION_END = exports.COMPLETION_START = void 0;
|
|
7
|
+
exports.validateProcessedSources = validateProcessedSources;
|
|
8
|
+
exports.decodeCompletion = decodeCompletion;
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
function validateProcessedSources(sources) {
|
|
11
|
+
if (!Array.isArray(sources) || new Set(sources.map(item => item.path)).size !== sources.length || sources.some(item => !item || !node_path_1.default.isAbsolute(item.path) || !node_path_1.default.isAbsolute(item.destination) || !['raw', 'retrospective'].includes(item.kind)
|
|
12
|
+
|| !/^[a-f0-9]{64}$/.test(item.sha256) || !/^[a-f0-9]{64}$/.test(item.resultHash) || !/^\d{4}-\d{2}-\d{2}$/.test(item.synthesizedAt)))
|
|
13
|
+
throw new Error('invalid-processed-sources');
|
|
14
|
+
}
|
|
15
|
+
exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
|
|
16
|
+
exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
|
|
17
|
+
const text = (value) => typeof value === 'string' && !!value.trim();
|
|
18
|
+
const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
19
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
const keys = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key));
|
|
21
|
+
const ownerValid = (value) => object(value) && ['Project', 'Manager', 'Org'].includes(value.level)
|
|
22
|
+
&& keys(value, ['level', 'path']) && text(value.path) && node_path_1.default.isAbsolute(value.path)
|
|
23
|
+
&& !/[\x00-\x1f]/.test(value.path) && value.path === (process.platform === 'win32' ? node_path_1.default.resolve(value.path).replace(/\\/g, '/').toLowerCase() : node_path_1.default.resolve(value.path));
|
|
24
|
+
const sameOwner = (a, b) => a.level === b.level && a.path === b.path;
|
|
25
|
+
function decodeCompletion(markdown, expected = {}) {
|
|
26
|
+
const fail = () => { throw new Error('invalid-completion-decisions'); };
|
|
27
|
+
if ((markdown.match(/<!--\s*fraim-sleep-decisions:/g) || []).length !== 1 || markdown.split(exports.COMPLETION_START).length !== 2 || markdown.split(exports.COMPLETION_END).length !== 2)
|
|
28
|
+
fail();
|
|
29
|
+
const start = markdown.indexOf(exports.COMPLETION_START) + exports.COMPLETION_START.length;
|
|
30
|
+
const end = markdown.indexOf(exports.COMPLETION_END);
|
|
31
|
+
if (end < start)
|
|
32
|
+
fail();
|
|
33
|
+
const block = markdown.slice(start, end).match(/^\s*```json\r?\n([\s\S]*?)\r?\n```\s*$/);
|
|
34
|
+
if (!block)
|
|
35
|
+
fail();
|
|
36
|
+
let state;
|
|
37
|
+
try {
|
|
38
|
+
state = JSON.parse(block[1]);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
fail();
|
|
42
|
+
}
|
|
43
|
+
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
44
|
+
|| !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
|
|
45
|
+
fail();
|
|
46
|
+
if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
|
|
47
|
+
fail();
|
|
48
|
+
if (state.retrospective !== undefined && (!keys(state.retrospective, ['relativePath', 'sha256'])
|
|
49
|
+
|| state.retrospective.relativePath !== `retrospectives/${state.runId}-sleep-postmortem.md` || !hash(state.retrospective.sha256)))
|
|
50
|
+
fail();
|
|
51
|
+
if (state.processedSources !== undefined)
|
|
52
|
+
validateProcessedSources(state.processedSources);
|
|
53
|
+
const ids = new Set();
|
|
54
|
+
for (const item of state.decisions) {
|
|
55
|
+
if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
|
|
56
|
+
|| !text(item.claimKey) || !text(item.evidenceFingerprint) || !Array.isArray(item.sourceRefs) || !item.sourceRefs.every(text) || (item.publicationRef !== undefined && !text(item.publicationRef)))
|
|
57
|
+
fail();
|
|
58
|
+
ids.add(item.actionId);
|
|
59
|
+
const decision = item.decision;
|
|
60
|
+
if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
|
|
61
|
+
|| !text(decision.reviewer) || !text(decision.reason) || !text(decision.at) || !Number.isFinite(Date.parse(decision.at))
|
|
62
|
+
|| (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition))))
|
|
63
|
+
fail();
|
|
64
|
+
if ('content' in item || 'specification' in item || 'application' in item || 'preimage' in item)
|
|
65
|
+
fail();
|
|
66
|
+
if (item.target && (!keys(item.target, ['owner', 'relativePath', 'entryId', 'learningSubject']) || !ownerValid(item.target.owner) || !text(item.target.relativePath) || /^[\\/]|^[a-zA-Z]:/.test(item.target.relativePath) || item.target.relativePath.split('/').includes('..')))
|
|
67
|
+
fail();
|
|
68
|
+
if (item.dependencies && (!Array.isArray(item.dependencies) || item.dependencies.some((dependency) => !keys(dependency, ['actionId', 'outcome']) || !text(dependency.actionId) || !['applied', 'rejected'].includes(dependency.outcome))))
|
|
69
|
+
fail();
|
|
70
|
+
}
|
|
71
|
+
for (const source of state.sourceDispositions) {
|
|
72
|
+
if (!keys(source, ['sourceRef', 'sourceHash', 'outcome', 'actionId']) || !text(source.sourceRef) || !hash(source.sourceHash) || !['applied', 'rejected'].includes(source.outcome) || !ids.has(source.actionId))
|
|
73
|
+
fail();
|
|
74
|
+
}
|
|
75
|
+
const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
|
|
76
|
+
const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
|
|
77
|
+
const dispositions = new Set();
|
|
78
|
+
for (const source of state.sourceDispositions) {
|
|
79
|
+
const action = decisions.get(source.actionId);
|
|
80
|
+
const key = JSON.stringify([source.actionId, source.sourceRef]);
|
|
81
|
+
if (dispositions.has(key) || !action.sourceRefs.includes(source.sourceRef) || source.outcome !== outcome(action))
|
|
82
|
+
fail();
|
|
83
|
+
dispositions.add(key);
|
|
84
|
+
}
|
|
85
|
+
for (const action of decisions.values()) {
|
|
86
|
+
if (action.target && !sameOwner(action.owner, action.target.owner))
|
|
87
|
+
fail();
|
|
88
|
+
if (new Set(action.sourceRefs).size !== action.sourceRefs.length || action.sourceRefs.some(ref => !dispositions.has(JSON.stringify([action.actionId, ref]))))
|
|
89
|
+
fail();
|
|
90
|
+
const dependencies = action.dependencies || [];
|
|
91
|
+
if (new Set(dependencies.map(dependency => dependency.actionId)).size !== dependencies.length)
|
|
92
|
+
fail();
|
|
93
|
+
for (const dependency of dependencies) {
|
|
94
|
+
const target = decisions.get(dependency.actionId);
|
|
95
|
+
if (!target || target.actionId === action.actionId || dependency.outcome !== outcome(target)
|
|
96
|
+
|| (action.decision.action === 'approve' && dependency.outcome !== 'applied'))
|
|
97
|
+
fail();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const checked = new Set();
|
|
101
|
+
const visiting = new Set();
|
|
102
|
+
const visit = (id) => {
|
|
103
|
+
if (visiting.has(id))
|
|
104
|
+
fail();
|
|
105
|
+
if (checked.has(id))
|
|
106
|
+
return;
|
|
107
|
+
visiting.add(id);
|
|
108
|
+
for (const dependency of decisions.get(id).dependencies || [])
|
|
109
|
+
visit(dependency.actionId);
|
|
110
|
+
visiting.delete(id);
|
|
111
|
+
checked.add(id);
|
|
112
|
+
};
|
|
113
|
+
for (const id of decisions.keys())
|
|
114
|
+
visit(id);
|
|
115
|
+
for (const item of state.cleanup) {
|
|
116
|
+
if (!keys(item, ['path', 'outcome', 'reason', 'sha256', 'location']) || !text(item.path) || !['pending', 'deleted', 'absent', 'retained'].includes(item.outcome) || (item.reason !== undefined && typeof item.reason !== 'string') || (item.sha256 !== undefined && !hash(item.sha256)))
|
|
117
|
+
fail();
|
|
118
|
+
if (item.location !== undefined) {
|
|
119
|
+
const location = item.location;
|
|
120
|
+
if (!keys(location, ['owner', 'relativePath']) || !ownerValid(location.owner)
|
|
121
|
+
|| !text(location.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(location.relativePath)
|
|
122
|
+
|| /[\\:]/.test(location.relativePath) || location.relativePath.split('/').some((part) => !part || part === '.' || part === '..'))
|
|
123
|
+
fail();
|
|
124
|
+
}
|
|
125
|
+
if (state.finalizationState === 'complete' && item.outcome === 'pending')
|
|
126
|
+
fail();
|
|
127
|
+
}
|
|
128
|
+
if (state.reconciliations !== undefined) {
|
|
129
|
+
if (!Array.isArray(state.reconciliations))
|
|
130
|
+
fail();
|
|
131
|
+
const sources = new Set();
|
|
132
|
+
for (const item of state.reconciliations) {
|
|
133
|
+
if (!keys(item, ['sourceRef', 'sourceHash', 'owner', 'relativePath', 'actionIds', 'actionBindings', 'disposition', 'rationale', 'preservedDecisions', 'reviewer'])
|
|
134
|
+
|| !text(item.sourceRef) || sources.has(item.sourceRef) || !hash(item.sourceHash) || !ownerValid(item.owner)
|
|
135
|
+
|| !text(item.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(item.relativePath)
|
|
136
|
+
|| /[\\:]/.test(item.relativePath) || item.relativePath.split('/').some((part) => !part || part === '.' || part === '..')
|
|
137
|
+
|| !['accounted', 'unrelated', 'raw-source'].includes(item.disposition) || !text(item.rationale) || !text(item.reviewer)
|
|
138
|
+
|| !Array.isArray(item.preservedDecisions) || !item.preservedDecisions.every(text)
|
|
139
|
+
|| !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
|
|
140
|
+
|| !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
|
|
141
|
+
fail();
|
|
142
|
+
sources.add(item.sourceRef);
|
|
143
|
+
for (const [index, id] of item.actionIds.entries()) {
|
|
144
|
+
const decision = decisions.get(id);
|
|
145
|
+
const binding = item.actionBindings[index];
|
|
146
|
+
if (!decision || !keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== id || binding.revision !== decision.revision || binding.digest !== decision.digest
|
|
147
|
+
|| !state.sourceDispositions.some((source) => source.actionId === id && source.sourceRef.replace(/\\/g, '/') === item.sourceRef.replace(/\\/g, '/') && source.sourceHash === item.sourceHash))
|
|
148
|
+
fail();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
@@ -468,6 +468,51 @@ function buildDesktopDebugSnapshot(runtimeId) {
|
|
|
468
468
|
gpuFeatureStatus: electron_1.app.getGPUFeatureStatus(),
|
|
469
469
|
};
|
|
470
470
|
}
|
|
471
|
+
function installEditingContextMenu(window) {
|
|
472
|
+
const contents = window.webContents;
|
|
473
|
+
contents.on('context-menu', async (_event, params) => {
|
|
474
|
+
if (!params.isEditable && !params.selectionText)
|
|
475
|
+
return;
|
|
476
|
+
// The Hub can override the OS theme. Read only that existing preference;
|
|
477
|
+
// keep native menu colors consistent without introducing a renderer bridge.
|
|
478
|
+
try {
|
|
479
|
+
const theme = await contents.executeJavaScript("document.documentElement.getAttribute('data-theme')");
|
|
480
|
+
if (theme === 'light' || theme === 'dark')
|
|
481
|
+
electron_1.nativeTheme.themeSource = theme;
|
|
482
|
+
}
|
|
483
|
+
catch { /* Navigation may remove the document; retain the native theme. */ }
|
|
484
|
+
if (window.isDestroyed() || contents.isDestroyed())
|
|
485
|
+
return;
|
|
486
|
+
const flags = params.editFlags;
|
|
487
|
+
// Chromium marks read-only fields as editable, but disables paste/cut.
|
|
488
|
+
const canChange = params.isEditable && flags.canPaste;
|
|
489
|
+
const items = [];
|
|
490
|
+
if (canChange && params.misspelledWord) {
|
|
491
|
+
for (const suggestion of params.dictionarySuggestions) {
|
|
492
|
+
items.push({ label: suggestion, click: () => contents.replaceMisspelling(suggestion) });
|
|
493
|
+
}
|
|
494
|
+
items.push({
|
|
495
|
+
label: 'Add to dictionary',
|
|
496
|
+
click: () => {
|
|
497
|
+
let added = false;
|
|
498
|
+
try {
|
|
499
|
+
added = contents.session.addWordToSpellCheckerDictionary(params.misspelledWord);
|
|
500
|
+
}
|
|
501
|
+
catch { /* An unavailable platform dictionary must not disrupt editing. */ }
|
|
502
|
+
if (!added && !window.isDestroyed()) {
|
|
503
|
+
void electron_1.dialog.showMessageBox(window, {
|
|
504
|
+
type: 'warning', title: 'Dictionary unavailable',
|
|
505
|
+
message: 'Could not add the word to the dictionary. You can continue editing your draft.',
|
|
506
|
+
buttons: ['OK'],
|
|
507
|
+
}).catch(() => { });
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
}, { type: 'separator' });
|
|
511
|
+
}
|
|
512
|
+
items.push({ role: 'cut', enabled: canChange && flags.canCut }, { role: 'copy', enabled: flags.canCopy }, { role: 'paste', enabled: canChange }, { role: 'selectAll', enabled: flags.canSelectAll });
|
|
513
|
+
electron_1.Menu.buildFromTemplate(items).popup({ window });
|
|
514
|
+
});
|
|
515
|
+
}
|
|
471
516
|
async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
|
|
472
517
|
const { width, height } = preferredWindowSize();
|
|
473
518
|
const isMac = process.platform === 'darwin';
|
|
@@ -497,6 +542,7 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
|
|
|
497
542
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
498
543
|
});
|
|
499
544
|
electron_1.Menu.setApplicationMenu(null);
|
|
545
|
+
installEditingContextMenu(mainWindow);
|
|
500
546
|
mainWindow.on('focus', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.focus'));
|
|
501
547
|
mainWindow.on('blur', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.blur'));
|
|
502
548
|
mainWindow.on('show', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.show', { elapsedSinceProcessStartMs: performance.now() }));
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.spawnHostProcess = spawnHostProcess;
|
|
|
38
38
|
exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
|
|
39
39
|
exports.checkFraimMcpAttachReadiness = checkFraimMcpAttachReadiness;
|
|
40
40
|
const crypto_1 = require("crypto");
|
|
41
|
+
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
41
42
|
const child_process_1 = require("child_process");
|
|
42
43
|
const background_command_1 = require("./background-command");
|
|
43
44
|
const process_liveness_1 = require("./process-liveness");
|
|
@@ -843,6 +844,7 @@ function extractSignalFromArgs(args) {
|
|
|
843
844
|
: typeof args.issueNumber === 'number' ? String(args.issueNumber)
|
|
844
845
|
: undefined;
|
|
845
846
|
const reviewHandoff = extractReviewHandoffFromArgs(args);
|
|
847
|
+
const reviewHandoffError = reviewHandoff ? null : extractMalformedReviewHandoffError(args);
|
|
846
848
|
const delegationLedger = extractDelegationLedgerFromArgs(args);
|
|
847
849
|
const nextJobRecommendations = extractNextJobRecommendationsFromArgs(args);
|
|
848
850
|
const deliveryActionId = readNullableString(evidenceArgs?.deliveryActionId);
|
|
@@ -858,6 +860,7 @@ function extractSignalFromArgs(args) {
|
|
|
858
860
|
jobId,
|
|
859
861
|
...(issueNumber ? { issueNumber } : {}),
|
|
860
862
|
...(reviewHandoff ? { reviewHandoff } : {}),
|
|
863
|
+
...(reviewHandoffError ? { reviewHandoffError } : {}),
|
|
861
864
|
...(delegationLedger ? { delegationLedger } : {}),
|
|
862
865
|
...(nextJobRecommendations ? { nextJobRecommendations } : {}),
|
|
863
866
|
...(deliveryActionId !== undefined ? { deliveryActionId } : {}),
|
|
@@ -1142,6 +1145,21 @@ function extractReviewHandoffFromArgs(args) {
|
|
|
1142
1145
|
}
|
|
1143
1146
|
return null;
|
|
1144
1147
|
}
|
|
1148
|
+
function extractMalformedReviewHandoffError(args) {
|
|
1149
|
+
const evidence = args.evidence;
|
|
1150
|
+
const findings = args.findings;
|
|
1151
|
+
let candidate = [args.reviewHandoff, evidence?.reviewHandoff, findings?.reviewHandoff]
|
|
1152
|
+
.find((value) => value !== undefined && value !== null);
|
|
1153
|
+
if (candidate === undefined)
|
|
1154
|
+
return null;
|
|
1155
|
+
if (typeof candidate === 'string') {
|
|
1156
|
+
try {
|
|
1157
|
+
candidate = JSON.parse(candidate);
|
|
1158
|
+
}
|
|
1159
|
+
catch { /* The shared checker reports the invalid object. */ }
|
|
1160
|
+
}
|
|
1161
|
+
return (0, handoff_contracts_1.reviewHandoffNormalizationError)(candidate);
|
|
1162
|
+
}
|
|
1145
1163
|
function readReviewHandoffCandidate(value) {
|
|
1146
1164
|
let candidate = value;
|
|
1147
1165
|
if (typeof candidate === 'string') {
|
|
@@ -45,6 +45,7 @@ exports.__setCatalogSyncSpawnForTests = __setCatalogSyncSpawnForTests;
|
|
|
45
45
|
exports.__setAgentInstallTimeoutMsForTests = __setAgentInstallTimeoutMsForTests;
|
|
46
46
|
exports.hubRunProcess = hubRunProcess;
|
|
47
47
|
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
48
|
+
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
48
49
|
const express_1 = __importDefault(require("express"));
|
|
49
50
|
const path_1 = __importDefault(require("path"));
|
|
50
51
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -55,6 +56,7 @@ const https_1 = __importDefault(require("https"));
|
|
|
55
56
|
const native_voice_input_1 = require("./native-voice-input");
|
|
56
57
|
const types_1 = require("../first-run/types");
|
|
57
58
|
const local_provider_registry_1 = require("../cli/providers/local-provider-registry");
|
|
59
|
+
const learning_domains_1 = require("../config/learning-domains");
|
|
58
60
|
const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
|
|
59
61
|
const brand_store_1 = require("../core/brand-store");
|
|
60
62
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
@@ -982,6 +984,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
982
984
|
const value = raw;
|
|
983
985
|
if (typeof value.reviewRequired !== 'boolean')
|
|
984
986
|
return null;
|
|
987
|
+
const producingJobName = typeof value.producingJobName === 'string' ? value.producingJobName.trim() : '';
|
|
985
988
|
const artifacts = Array.isArray(value.artifacts)
|
|
986
989
|
? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
|
|
987
990
|
: [];
|
|
@@ -995,6 +998,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
995
998
|
: '';
|
|
996
999
|
if (!value.reviewRequired) {
|
|
997
1000
|
return {
|
|
1001
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
998
1002
|
reviewRequired: false,
|
|
999
1003
|
reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
|
|
1000
1004
|
artifacts,
|
|
@@ -1010,6 +1014,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
1010
1014
|
if (artifacts.length > 0)
|
|
1011
1015
|
return null;
|
|
1012
1016
|
return {
|
|
1017
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
1013
1018
|
reviewRequired: true,
|
|
1014
1019
|
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
1015
1020
|
artifacts: [],
|
|
@@ -1027,6 +1032,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
1027
1032
|
const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
|
|
1028
1033
|
if (targetType === 'artifact_set' && fileArtifacts.length === artifacts.length) {
|
|
1029
1034
|
return {
|
|
1035
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
1030
1036
|
reviewRequired: true,
|
|
1031
1037
|
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
1032
1038
|
artifacts: fileArtifacts,
|
|
@@ -1615,31 +1621,7 @@ function isCodexMissingReasoningResumeText(value) {
|
|
|
1615
1621
|
return text.includes("provided without its required 'reasoning' item") ||
|
|
1616
1622
|
text.includes('provided without its required "reasoning" item');
|
|
1617
1623
|
}
|
|
1618
|
-
//
|
|
1619
|
-
// successful to the agent. Records the failure on the existing non-droppable
|
|
1620
|
-
// hostError channel (the same `run.lastHostError`/`run.events` pattern the
|
|
1621
|
-
// #1264/#1265 `detectUnrecognizedReviewHandoffCall` diagnostic uses) AND
|
|
1622
|
-
// appends a system message directly to `run.messages` — the agent- and
|
|
1623
|
-
// manager-visible thread — so the failure is visible immediately rather than
|
|
1624
|
-
// only after a terminal non-zero exit (`pushTerminalFailureToThread` only
|
|
1625
|
-
// fires then). The message names the reason and this run's identity so the
|
|
1626
|
-
// agent can recover by resubmitting.
|
|
1627
|
-
function failLoudlyOnDroppedReviewHandoff(run, reason) {
|
|
1628
|
-
const message = `FRAIM review handoff was not applied to run ${run.id} (job "${run.jobId || 'unknown'}"): ${reason}. The Hub review surface does not reflect this submission — resubmit seekMentoring from this run's active job/phase.`;
|
|
1629
|
-
run.lastHostError = message;
|
|
1630
|
-
run.events.push((0, hosts_1.createHubEvent)('stderr', boundedEventText(message)));
|
|
1631
|
-
// This runs outside recordHostEvent's own end-of-call cap, so cap here too —
|
|
1632
|
-
// an agent stuck retrying a mismatched-job submission must not grow this
|
|
1633
|
-
// array past the same bound every other event source respects.
|
|
1634
|
-
capProjectedEvents(run);
|
|
1635
|
-
run.messages.push((0, hosts_1.createHubMessage)('system', message));
|
|
1636
|
-
}
|
|
1637
|
-
// Issue #1447: applies a reviewHandoff carried on a same-job seekMentoring
|
|
1638
|
-
// signal. Pure apply/normalize — it records no diagnostics itself. Fidelity
|
|
1639
|
-
// to the submission is checked once, generically, by
|
|
1640
|
-
// verifyReviewHandoffReflectsSubmission below, regardless of which of this
|
|
1641
|
-
// function's branches (or the caller's cross-job guard) is why the stored
|
|
1642
|
-
// result might not match.
|
|
1624
|
+
// Review ownership follows the submitted deliverable, independently of tracker identity.
|
|
1643
1625
|
function applyReviewHandoffToRun(run, reviewHandoff) {
|
|
1644
1626
|
const normalizedReviewHandoff = normalizeReviewHandoff(reviewHandoff);
|
|
1645
1627
|
if (!normalizedReviewHandoff) {
|
|
@@ -1663,7 +1645,7 @@ function applyReviewHandoffToRun(run, reviewHandoff) {
|
|
|
1663
1645
|
}
|
|
1664
1646
|
// Issue #1447: a mismatched `pull_request` target, isolated for readability.
|
|
1665
1647
|
function pullRequestTargetMismatch(stored, submittedTarget) {
|
|
1666
|
-
const submittedUrl =
|
|
1648
|
+
const submittedUrl = (0, url_safety_1.safeHttpUrl)(submittedTarget?.url) || undefined;
|
|
1667
1649
|
if (stored.reviewTarget?.type !== 'pull_request' || stored.reviewTarget.url !== submittedUrl) {
|
|
1668
1650
|
return `stored reviewTarget (${JSON.stringify(stored.reviewTarget)}) does not match the submitted pull_request target (url: "${submittedUrl}")`;
|
|
1669
1651
|
}
|
|
@@ -1680,7 +1662,7 @@ function artifactSetMismatch(stored, submitted) {
|
|
|
1680
1662
|
const rawArtifacts = submitted.artifacts;
|
|
1681
1663
|
const rawArtifactList = Array.isArray(rawArtifacts) ? rawArtifacts : [];
|
|
1682
1664
|
const submittedPaths = rawArtifactList
|
|
1683
|
-
.map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path : null))
|
|
1665
|
+
.map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path.trim() : null))
|
|
1684
1666
|
.filter((path) => Boolean(path))
|
|
1685
1667
|
.sort();
|
|
1686
1668
|
const storedPaths = (stored.artifacts || [])
|
|
@@ -1693,8 +1675,7 @@ function artifactSetMismatch(stored, submitted) {
|
|
|
1693
1675
|
return null;
|
|
1694
1676
|
}
|
|
1695
1677
|
// Issue #1447: one generic post-condition instead of enumerating each way a
|
|
1696
|
-
// review handoff can fail to land.
|
|
1697
|
-
// filtering, normalization rejecting the whole payload, or a per-artifact
|
|
1678
|
+
// review handoff can fail to land. Whether normalization rejects the whole payload or a per-artifact
|
|
1698
1679
|
// normalization failure silently dropping one item while the handoff as a
|
|
1699
1680
|
// whole still normalizes — this compares what was actually submitted against
|
|
1700
1681
|
// what the run now holds and fails loudly on any structural mismatch. Only
|
|
@@ -1702,33 +1683,29 @@ function artifactSetMismatch(stored, submitted) {
|
|
|
1702
1683
|
// issue's incident describes (a stale or absent handoff masquerading as a
|
|
1703
1684
|
// successful submission); a `reviewRequired: false` payload carries no such
|
|
1704
1685
|
// promise.
|
|
1705
|
-
function verifyReviewHandoffReflectsSubmission(run, submitted,
|
|
1686
|
+
function verifyReviewHandoffReflectsSubmission(run, submitted, onFailure) {
|
|
1706
1687
|
if (!submitted || typeof submitted !== 'object' || submitted.reviewRequired !== true)
|
|
1707
1688
|
return;
|
|
1708
|
-
if (crossJobName) {
|
|
1709
|
-
failLoudlyOnDroppedReviewHandoff(run, `a review handoff was submitted under job "${crossJobName}", but this run is tracking job "${run.jobId}" — the cross-job guard did not apply it`);
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
1689
|
const stored = run.reviewHandoff;
|
|
1713
1690
|
const submittedTarget = submitted.reviewTarget;
|
|
1714
1691
|
if (!stored || stored.reviewRequired !== true) {
|
|
1715
|
-
|
|
1692
|
+
onFailure(`evidence.reviewHandoff failed validation and was rejected (submitted reviewTarget: ${JSON.stringify(submittedTarget) || 'none'})`);
|
|
1716
1693
|
return;
|
|
1717
1694
|
}
|
|
1718
|
-
const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type : undefined;
|
|
1695
|
+
const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type.trim() : undefined;
|
|
1719
1696
|
const mismatch = submittedType === 'pull_request'
|
|
1720
1697
|
? pullRequestTargetMismatch(stored, submittedTarget)
|
|
1721
1698
|
: artifactSetMismatch(stored, submitted);
|
|
1722
1699
|
if (mismatch)
|
|
1723
|
-
|
|
1700
|
+
onFailure(mismatch);
|
|
1724
1701
|
}
|
|
1725
1702
|
// Apply a parsed seekMentoring tool-use signal from the host stream to
|
|
1726
1703
|
// the run state. Returns the updated currentPhase.
|
|
1727
|
-
function applySeekMentoringSignal(run, signal) {
|
|
1704
|
+
function applySeekMentoringSignal(run, signal, onReviewFailure) {
|
|
1728
1705
|
// Issue #347: filter cross-job pollution. The agent may call
|
|
1729
1706
|
// seekMentoring for jobs OTHER than the one this run is tracking
|
|
1730
1707
|
// (e.g., consulting `organizational-learning-synthesis` mid-run).
|
|
1731
|
-
// Only
|
|
1708
|
+
// Only tracker metadata requires matching job identity.
|
|
1732
1709
|
// Issue #732: match on `jobName` — the stable job-name slug that equals
|
|
1733
1710
|
// run.jobId — NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
|
|
1734
1711
|
// job id on every call and the agent echoes that UUID into
|
|
@@ -1739,33 +1716,24 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
1739
1716
|
const targetJobId = run.jobId;
|
|
1740
1717
|
const callJobName = signal.jobName;
|
|
1741
1718
|
const isCrossJob = Boolean(callJobName && targetJobId && callJobName !== targetJobId);
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
// applyReviewHandoffToRun rejected/truncated it. Apply first (skipped
|
|
1745
|
-
// entirely for a foreign job, since that guard is still correct — a
|
|
1746
|
-
// foreign job's data must never overwrite this run's tracked state), then
|
|
1747
|
-
// verify unconditionally that the result actually reflects the submission.
|
|
1748
|
-
if (signal.reviewHandoff) {
|
|
1749
|
-
if (!isCrossJob)
|
|
1750
|
-
applyReviewHandoffToRun(run, signal.reviewHandoff);
|
|
1751
|
-
verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, isCrossJob ? callJobName : null);
|
|
1752
|
-
}
|
|
1753
|
-
else if (!isCrossJob) {
|
|
1754
|
-
// Issue #1501: No reviewHandoff in this same-job call. Clear any prior reviewHandoff so
|
|
1755
|
-
// run.reviewHandoff always reflects the most recent seekMentoring intent,
|
|
1756
|
-
// not a stale flag from an earlier submit phase.
|
|
1757
|
-
run.reviewHandoff = null;
|
|
1758
|
-
}
|
|
1759
|
-
if (isCrossJob)
|
|
1719
|
+
if (signal.reviewHandoffError) {
|
|
1720
|
+
onReviewFailure(signal.reviewHandoffError);
|
|
1760
1721
|
return;
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1722
|
+
}
|
|
1723
|
+
else if (signal.reviewHandoff) {
|
|
1724
|
+
const error = (0, handoff_contracts_1.reviewHandoffNormalizationError)(signal.reviewHandoff);
|
|
1725
|
+
if (error) {
|
|
1726
|
+
onReviewFailure(error);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
applyReviewHandoffToRun(run, { ...signal.reviewHandoff, producingJobName: callJobName });
|
|
1731
|
+
verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, onReviewFailure);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
else {
|
|
1735
|
+
// The most recent mentoring intent owns review lifetime across all jobs.
|
|
1736
|
+
run.reviewHandoff = null;
|
|
1769
1737
|
}
|
|
1770
1738
|
if (signal.deliveryActionId !== undefined) {
|
|
1771
1739
|
run.pendingDeliveryActionId = signal.deliveryActionId;
|
|
@@ -1785,6 +1753,17 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
1785
1753
|
run.pendingDeliveryActionId = null;
|
|
1786
1754
|
}
|
|
1787
1755
|
}
|
|
1756
|
+
// Delivery actions belong to the review, while tracker and issue metadata
|
|
1757
|
+
// remain scoped to the run's original job (including colliding phase names).
|
|
1758
|
+
if (isCrossJob)
|
|
1759
|
+
return;
|
|
1760
|
+
if (signal.issueNumber)
|
|
1761
|
+
run.issueNumber = signal.issueNumber;
|
|
1762
|
+
if (signal.nextJobRecommendations) {
|
|
1763
|
+
const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
|
|
1764
|
+
if (normalized)
|
|
1765
|
+
run.nextJobRecommendations = normalized;
|
|
1766
|
+
}
|
|
1788
1767
|
if (signal.delegationLedger &&
|
|
1789
1768
|
run.jobId === 'fully-delegate' &&
|
|
1790
1769
|
signal.phaseStatus === 'complete' &&
|
|
@@ -3515,6 +3494,10 @@ class AiHubServer {
|
|
|
3515
3494
|
project.droppedRecordedReason = this.droppedRecordedReason;
|
|
3516
3495
|
}
|
|
3517
3496
|
const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS);
|
|
3497
|
+
// Issue #1891: build the custom-employee index once (O(1) per job, one disk walk total)
|
|
3498
|
+
// so custom ownership wins over catalog assignment. Follows the same pattern as
|
|
3499
|
+
// buildCustomJobOwnerIndex documented in the Issue #1090 comment above.
|
|
3500
|
+
const customJobOwners = buildCustomJobOwnerIndex(normalizedProjectPath ?? '');
|
|
3518
3501
|
// Issue #566 (R7): jobs already carry `personalized` from catalog discovery
|
|
3519
3502
|
// (true for the fraim/personalized-employee layer). The Hub renders a plain
|
|
3520
3503
|
// "Personalized" marking from that flag — no author/attribution is tracked.
|
|
@@ -3522,7 +3505,7 @@ class AiHubServer {
|
|
|
3522
3505
|
.filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
|
|
3523
3506
|
.map((job) => ({
|
|
3524
3507
|
...job,
|
|
3525
|
-
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
3508
|
+
requiredPersonaKey: customJobOwners.get(job.id) ?? getProtectedPersonaForHubJob(job.id),
|
|
3526
3509
|
}));
|
|
3527
3510
|
// Issue #1247: expose internal jobs in a separate bootstrap field so next-job
|
|
3528
3511
|
// recommendation chips can resolve and launch them. They are intentionally
|
|
@@ -5128,7 +5111,9 @@ class AiHubServer {
|
|
|
5128
5111
|
// jobId (resolveHubJob would never match a UUID, leaving a freeform run
|
|
5129
5112
|
// unpromoted and then dropping its phase signals).
|
|
5130
5113
|
this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
|
|
5131
|
-
applySeekMentoringSignal(run, signal)
|
|
5114
|
+
applySeekMentoringSignal(run, signal, (reason) => {
|
|
5115
|
+
this.recordHostEvent(run, run.hostId, { hostError: 'Review handoff was not applied: ' + reason }, 'stderr');
|
|
5116
|
+
});
|
|
5132
5117
|
if (signal.phaseId === 'retrospective' && signal.phaseStatus === 'complete') {
|
|
5133
5118
|
void this.releaseBrowserConversationPage(run.conversationId || run.id);
|
|
5134
5119
|
}
|
|
@@ -5645,7 +5630,10 @@ class AiHubServer {
|
|
|
5645
5630
|
const projectPath = resolveProjectPath(req);
|
|
5646
5631
|
const entries = (0, learning_context_builder_1.readPreservedLearnings)(projectPath, userEmail, scope, level);
|
|
5647
5632
|
const threshold = (0, learning_context_builder_1.getScoreThreshold)(projectPath);
|
|
5648
|
-
|
|
5633
|
+
// Issue #1755 R9: the domain catalogue travels with the corpus so the library's
|
|
5634
|
+
// domain filter and the re-scope selector offer every valid domain, not only the
|
|
5635
|
+
// ones that happen to have an entry already.
|
|
5636
|
+
return res.json({ scope, level, entries, threshold, domains: ['global', ...learning_domains_1.LEARNING_DOMAINS] });
|
|
5649
5637
|
}
|
|
5650
5638
|
catch (error) {
|
|
5651
5639
|
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
|
|
@@ -5664,7 +5652,16 @@ class AiHubServer {
|
|
|
5664
5652
|
if (!b.category || !VALID_CATEGORIES.includes(b.category))
|
|
5665
5653
|
return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
|
|
5666
5654
|
const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
|
|
5667
|
-
|
|
5655
|
+
// Issue #1755: `domain` names the file the entry lives in today, `targetDomain`
|
|
5656
|
+
// the file it should live in after the write. Without the first, every write
|
|
5657
|
+
// resolved to the undecorated global file and a domain-scoped entry could not be
|
|
5658
|
+
// edited or deleted at all.
|
|
5659
|
+
const ref = {
|
|
5660
|
+
scope: b.scope,
|
|
5661
|
+
level,
|
|
5662
|
+
category: b.category,
|
|
5663
|
+
domain: (0, learning_context_builder_1.normalizeLearningDomain)(b.domain),
|
|
5664
|
+
};
|
|
5668
5665
|
// Issue #750: writes to a personal-scope file need a resolved identity —
|
|
5669
5666
|
// fail explicitly rather than writing to a guessed or empty-string file.
|
|
5670
5667
|
const userEmail = await this.resolveLearningIdentity(b.scope);
|
|
@@ -5677,6 +5674,7 @@ class AiHubServer {
|
|
|
5677
5674
|
severity: b.severity,
|
|
5678
5675
|
title: b.title,
|
|
5679
5676
|
body: b.body,
|
|
5677
|
+
targetDomain: b.targetDomain === undefined ? undefined : (0, learning_context_builder_1.normalizeLearningDomain)(b.targetDomain),
|
|
5680
5678
|
});
|
|
5681
5679
|
return res.json({ ok: true, path: result.path });
|
|
5682
5680
|
}
|