fraim 2.0.305 → 2.0.307
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/src/cli/commands/sync.js +15 -3
- package/dist/src/cli/doctor/checks/scripts-checks.js +133 -0
- package/dist/src/cli/setup/user-level-sync.js +1 -0
- package/dist/src/cli/utils/remote-sync.js +34 -14
- package/dist/src/config/persona-capability-bundles.js +2 -2
- package/dist/src/core/handoff-contracts.js +135 -3
- package/dist/src/local-mcp-server/stdio-server.js +90 -1
- package/dist/src/services/mcp-service.js +42 -0
- package/package.json +1 -1
|
@@ -293,8 +293,11 @@ const runSync = async (options) => {
|
|
|
293
293
|
});
|
|
294
294
|
if (result.success) {
|
|
295
295
|
// Local-dev sync bypasses Layer 1 (machine-level sync) below, but scripts
|
|
296
|
-
// still need to land in ~/.fraim/
|
|
296
|
+
// and docs still need to land in ~/.fraim/ or the reported counts are a lie.
|
|
297
297
|
await (0, remote_sync_1.syncScriptsToUserDir)(localRegistryFiles);
|
|
298
|
+
await (0, remote_sync_1.syncDocsToUserDir)(localRegistryFiles);
|
|
299
|
+
const { ensureUserLevelDependencies: ensureLocalUserLevelDependencies } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
|
|
300
|
+
ensureLocalUserLevelDependencies();
|
|
298
301
|
console.log(chalk_1.default.green(`Successfully synced ${result.employeeJobsSynced} ai-employee jobs, ${result.managerJobsSynced} ai-manager jobs, ${result.skillsSynced} skills, ${result.rulesSynced} rules, ${result.scriptsSynced} scripts, and ${result.docsSynced} docs from local server`));
|
|
299
302
|
const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
|
|
300
303
|
removeLegacyVersionFromConfig(fraimDir);
|
|
@@ -354,6 +357,12 @@ const runSync = async (options) => {
|
|
|
354
357
|
if (scriptsSynced > 0) {
|
|
355
358
|
console.log(chalk_1.default.green(` Synced ${scriptsSynced} scripts to ~/.fraim/scripts/`));
|
|
356
359
|
}
|
|
360
|
+
// 1b2. Sync docs to ~/.fraim/docs/ (Issue #1508: machine-level docs, e.g.
|
|
361
|
+
// TROUBLESHOOTING.md, must exist on disk the same way scripts do)
|
|
362
|
+
const docsSynced = await (0, remote_sync_1.syncDocsToUserDir)(registryFiles);
|
|
363
|
+
if (docsSynced > 0) {
|
|
364
|
+
console.log(chalk_1.default.green(` Synced ${docsSynced} docs to ~/.fraim/docs/`));
|
|
365
|
+
}
|
|
357
366
|
// 1c. Refresh org home
|
|
358
367
|
await refreshOrgCache(remoteUrl, apiKey);
|
|
359
368
|
// 1d. Refresh manager home
|
|
@@ -361,9 +370,12 @@ const runSync = async (options) => {
|
|
|
361
370
|
// 1e. Refresh MCP proxy launcher
|
|
362
371
|
const { ensureFraimMcpLatestLauncher } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
|
|
363
372
|
ensureFraimMcpLatestLauncher();
|
|
364
|
-
// 1f. Ensure user-level directories
|
|
365
|
-
|
|
373
|
+
// 1f. Ensure user-level directories and runtime dependencies (Issue #1508:
|
|
374
|
+
// synced scripts like the DOCX review layer require npm deps such as
|
|
375
|
+
// adm-zip; a plain sync must install them, not just create directories)
|
|
376
|
+
const { ensureUserLevelDirectories, ensureUserLevelDependencies } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
|
|
366
377
|
ensureUserLevelDirectories();
|
|
378
|
+
ensureUserLevelDependencies();
|
|
367
379
|
// 1g. Write sync metadata
|
|
368
380
|
writeSyncMetadata('remote', remoteUrl);
|
|
369
381
|
console.log(chalk_1.default.green('✅ Machine-level sync complete.'));
|
|
@@ -12,8 +12,73 @@ exports.getScriptsChecks = getScriptsChecks;
|
|
|
12
12
|
const fs_1 = __importDefault(require("fs"));
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
14
|
const os_1 = __importDefault(require("os"));
|
|
15
|
+
const module_1 = __importDefault(require("module"));
|
|
15
16
|
const child_process_1 = require("child_process");
|
|
16
17
|
const SCRIPTS_DIR = path_1.default.join(os_1.default.homedir(), '.fraim', 'scripts');
|
|
18
|
+
// Conservative static-require extractor: only matches literal
|
|
19
|
+
// require('name') / require("name") calls, not dynamic requires or imports.
|
|
20
|
+
const STATIC_REQUIRE_PATTERN = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
21
|
+
/**
|
|
22
|
+
* Recursively list .js/.cjs files under a directory.
|
|
23
|
+
*/
|
|
24
|
+
function listNodeScripts(dir) {
|
|
25
|
+
const results = [];
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
// Skip vendored dependency trees. A script that ships its own bundled
|
|
37
|
+
// library (e.g. ~/.fraim/scripts/lib/node_modules/@scope/pkg/...) is
|
|
38
|
+
// not itself a FRAIM script requiring dependency resolution — it IS a
|
|
39
|
+
// resolved dependency. Recursing into it produces false-positive
|
|
40
|
+
// warnings about that library's own devDependency-only files (e.g. a
|
|
41
|
+
// benchmark/test file requiring a module never meant to be installed
|
|
42
|
+
// under ~/.fraim/node_modules). Confirmed against a real machine: see
|
|
43
|
+
// issue #1508 manual validation.
|
|
44
|
+
if (entry.name === 'node_modules')
|
|
45
|
+
continue;
|
|
46
|
+
results.push(...listNodeScripts(fullPath));
|
|
47
|
+
}
|
|
48
|
+
else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
|
|
49
|
+
results.push(fullPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extract external (non-relative, non-absolute, non-builtin) module names
|
|
56
|
+
* statically required by a script, using its own directory for resolution.
|
|
57
|
+
*/
|
|
58
|
+
function extractExternalRequires(scriptPath) {
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = fs_1.default.readFileSync(scriptPath, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const modules = new Set();
|
|
67
|
+
let match;
|
|
68
|
+
STATIC_REQUIRE_PATTERN.lastIndex = 0;
|
|
69
|
+
while ((match = STATIC_REQUIRE_PATTERN.exec(content)) !== null) {
|
|
70
|
+
const moduleName = match[1];
|
|
71
|
+
if (moduleName.startsWith('.') ||
|
|
72
|
+
moduleName.startsWith('/') ||
|
|
73
|
+
/^[a-zA-Z]:[\\/]/.test(moduleName) ||
|
|
74
|
+
moduleName.startsWith('node:') ||
|
|
75
|
+
module_1.default.builtinModules.includes(moduleName)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
modules.add(moduleName);
|
|
79
|
+
}
|
|
80
|
+
return Array.from(modules);
|
|
81
|
+
}
|
|
17
82
|
/**
|
|
18
83
|
* Check if scripts directory exists
|
|
19
84
|
*/
|
|
@@ -211,6 +276,73 @@ function checkPythonAvailability() {
|
|
|
211
276
|
}
|
|
212
277
|
};
|
|
213
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Check that synced Node scripts can resolve their external runtime
|
|
281
|
+
* dependencies (e.g. `require('adm-zip')`) from their own directory.
|
|
282
|
+
*
|
|
283
|
+
* Distinguishes delivery (the script file exists) from executability (the
|
|
284
|
+
* script's require statements actually resolve). Issue #1508: `doctor`
|
|
285
|
+
* reported the DOCX review scripts healthy while all six threw
|
|
286
|
+
* `Cannot find module 'adm-zip'` at require time.
|
|
287
|
+
*
|
|
288
|
+
* Does not execute the scripts — only statically parses `require(...)`
|
|
289
|
+
* calls and resolves them the way Node would from the script's directory.
|
|
290
|
+
*/
|
|
291
|
+
function checkNodeScriptDependencies() {
|
|
292
|
+
return {
|
|
293
|
+
name: 'Node script dependencies available',
|
|
294
|
+
category: 'scripts',
|
|
295
|
+
critical: false,
|
|
296
|
+
run: async () => {
|
|
297
|
+
if (!fs_1.default.existsSync(SCRIPTS_DIR)) {
|
|
298
|
+
return {
|
|
299
|
+
status: 'passed',
|
|
300
|
+
message: 'No scripts synced — Node dependency check skipped'
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const scripts = listNodeScripts(SCRIPTS_DIR);
|
|
304
|
+
if (scripts.length === 0) {
|
|
305
|
+
return {
|
|
306
|
+
status: 'passed',
|
|
307
|
+
message: 'No Node scripts synced — Node dependency check skipped'
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const unresolved = {};
|
|
311
|
+
for (const scriptPath of scripts) {
|
|
312
|
+
const externalModules = extractExternalRequires(scriptPath);
|
|
313
|
+
const relativeScriptPath = path_1.default.relative(SCRIPTS_DIR, scriptPath);
|
|
314
|
+
for (const moduleName of externalModules) {
|
|
315
|
+
try {
|
|
316
|
+
require.resolve(moduleName, { paths: [path_1.default.dirname(scriptPath)] });
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
if (!unresolved[moduleName])
|
|
320
|
+
unresolved[moduleName] = [];
|
|
321
|
+
unresolved[moduleName].push(relativeScriptPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const unresolvedModules = Object.keys(unresolved);
|
|
326
|
+
if (unresolvedModules.length === 0) {
|
|
327
|
+
return {
|
|
328
|
+
status: 'passed',
|
|
329
|
+
message: `All Node script dependencies resolve (${scripts.length} scripts checked)`,
|
|
330
|
+
details: { scriptCount: scripts.length }
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const summary = unresolvedModules
|
|
334
|
+
.map((moduleName) => `${moduleName} (${unresolved[moduleName].join(', ')})`)
|
|
335
|
+
.join('; ');
|
|
336
|
+
return {
|
|
337
|
+
status: 'warning',
|
|
338
|
+
message: `Unresolved Node script dependencies: ${summary}`,
|
|
339
|
+
suggestion: 'Run fraim sync to install user-level runtime dependencies.',
|
|
340
|
+
command: 'fraim sync',
|
|
341
|
+
details: { unresolved }
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
214
346
|
/**
|
|
215
347
|
* Get all scripts checks
|
|
216
348
|
*/
|
|
@@ -219,6 +351,7 @@ function getScriptsChecks() {
|
|
|
219
351
|
checkScriptsDirectoryExists(),
|
|
220
352
|
checkScriptsSynced(),
|
|
221
353
|
checkScriptsExecutable(),
|
|
354
|
+
checkNodeScriptDependencies(),
|
|
222
355
|
checkPythonAvailability()
|
|
223
356
|
];
|
|
224
357
|
}
|
|
@@ -69,6 +69,7 @@ const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
|
69
69
|
*/
|
|
70
70
|
const USER_LEVEL_RUNTIME_DEPS = {
|
|
71
71
|
'node-edge-tts': '*', // used by scripts/author-audio.js
|
|
72
|
+
'adm-zip': '^0.6.0', // used by scripts/communication/*.js (DOCX review layer) — matches root/packages/* manifests
|
|
72
73
|
};
|
|
73
74
|
/**
|
|
74
75
|
* Ensure the user-level FRAIM directory structure exists.
|
|
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
14
14
|
exports.SYNCED_CONTENT_BANNER_MARKER = void 0;
|
|
15
15
|
exports.fetchRegistryFiles = fetchRegistryFiles;
|
|
16
16
|
exports.syncScriptsToUserDir = syncScriptsToUserDir;
|
|
17
|
+
exports.syncDocsToUserDir = syncDocsToUserDir;
|
|
17
18
|
exports.syncFromRemote = syncFromRemote;
|
|
18
19
|
const axios_1 = __importDefault(require("axios"));
|
|
19
20
|
const fs_1 = require("fs");
|
|
@@ -188,32 +189,51 @@ async function fetchRegistryFiles(remoteUrl, apiKey) {
|
|
|
188
189
|
return response.data.files || [];
|
|
189
190
|
}
|
|
190
191
|
/**
|
|
191
|
-
* Sync
|
|
192
|
-
*
|
|
192
|
+
* Sync a registry file subset (filtered by `type`) into a machine-level
|
|
193
|
+
* `~/.fraim/<subdir>/` directory: clean that directory, then write each
|
|
194
|
+
* matching file. Shared by syncScriptsToUserDir and syncDocsToUserDir so the
|
|
195
|
+
* clean-then-write and path-safety logic exists in exactly one place.
|
|
193
196
|
*/
|
|
194
|
-
async function
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
+
async function syncTypedFilesToUserDir(files, type, subdir, label) {
|
|
198
|
+
const matchingFiles = files.filter(f => f.type === type);
|
|
199
|
+
if (matchingFiles.length === 0)
|
|
197
200
|
return 0;
|
|
198
201
|
const userDir = (0, script_sync_utils_1.getUserFraimDir)();
|
|
199
|
-
const
|
|
200
|
-
if (!(0, fs_1.existsSync)(
|
|
201
|
-
(0, fs_1.mkdirSync)(
|
|
202
|
+
const targetDir = (0, path_1.join)(userDir, subdir);
|
|
203
|
+
if (!(0, fs_1.existsSync)(targetDir)) {
|
|
204
|
+
(0, fs_1.mkdirSync)(targetDir, { recursive: true });
|
|
202
205
|
}
|
|
203
|
-
cleanDirectory(
|
|
204
|
-
if ((0, path_1.resolve)(candidatePath) !== (0, path_1.resolve)(
|
|
205
|
-
assertPathInsideDirectory(
|
|
206
|
+
cleanDirectory(targetDir, (candidatePath) => {
|
|
207
|
+
if ((0, path_1.resolve)(candidatePath) !== (0, path_1.resolve)(targetDir)) {
|
|
208
|
+
assertPathInsideDirectory(targetDir, candidatePath, `${label} directory`);
|
|
206
209
|
}
|
|
207
210
|
});
|
|
208
|
-
for (const file of
|
|
209
|
-
const { filePath } = resolveUserRegistryFile(
|
|
211
|
+
for (const file of matchingFiles) {
|
|
212
|
+
const { filePath } = resolveUserRegistryFile(targetDir, file.path, `${label} file`);
|
|
210
213
|
const fileDir = (0, path_1.dirname)(filePath);
|
|
211
214
|
if (!(0, fs_1.existsSync)(fileDir)) {
|
|
212
215
|
(0, fs_1.mkdirSync)(fileDir, { recursive: true });
|
|
213
216
|
}
|
|
214
217
|
(0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
|
|
215
218
|
}
|
|
216
|
-
return
|
|
219
|
+
return matchingFiles.length;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Sync script files to the user-level ~/.fraim/scripts/ directory.
|
|
223
|
+
* Extracted from syncFromRemote so it can run without a project root.
|
|
224
|
+
*/
|
|
225
|
+
async function syncScriptsToUserDir(files) {
|
|
226
|
+
return syncTypedFilesToUserDir(files, 'script', 'scripts', 'script');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Sync docs files to the user-level ~/.fraim/docs/ directory.
|
|
230
|
+
* Mirrors syncScriptsToUserDir so machine-level docs (e.g. TROUBLESHOOTING.md)
|
|
231
|
+
* are materialized on disk the same way machine-level scripts are.
|
|
232
|
+
* Issue #1508: agent guidance promises ~/.fraim/docs/TROUBLESHOOTING.md exists
|
|
233
|
+
* after sync, but only project-level fraim/docs/ was ever written.
|
|
234
|
+
*/
|
|
235
|
+
async function syncDocsToUserDir(files) {
|
|
236
|
+
return syncTypedFilesToUserDir(files, 'docs', 'docs', 'docs');
|
|
217
237
|
}
|
|
218
238
|
/**
|
|
219
239
|
* Sync jobs and scripts from remote FRAIM server
|
|
@@ -179,8 +179,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
179
179
|
sam: {
|
|
180
180
|
personaKey: 'sam',
|
|
181
181
|
bundleId: 'persona-sam-core',
|
|
182
|
-
catalogMetadata: buildCatalogMetadata('sam', ['crm-pipeline-review', 'outbound-sales-strategy', 'discovery-
|
|
183
|
-
protectedJobs: ['crm-pipeline-review', 'outbound-sales-strategy', 'discovery-
|
|
182
|
+
catalogMetadata: buildCatalogMetadata('sam', ['crm-pipeline-review', 'outbound-sales-strategy', 'sales-discovery-preparation']),
|
|
183
|
+
protectedJobs: ['crm-pipeline-review', 'outbound-sales-strategy', 'sales-discovery-preparation', 'deal-strategy', 'proposal-development', 'account-strategy', 'sales-coaching'],
|
|
184
184
|
protectedAliases: ['sales', 'sales-manager', 'account-manager', 'pipeline'],
|
|
185
185
|
defaultHireMode: 'job',
|
|
186
186
|
lockCopy: 'Hire SAM to unlock full-cycle sales work for this request.'
|
|
@@ -17,11 +17,16 @@
|
|
|
17
17
|
* after the existing quality enforcement block.
|
|
18
18
|
*/
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.PHASE_START_WARN_PHASES = void 0;
|
|
20
21
|
exports.isSubmitPhase = isSubmitPhase;
|
|
21
22
|
exports.isRetrospectivePhase = isRetrospectivePhase;
|
|
22
23
|
exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
|
|
23
24
|
exports.isDelegationGraphPhase = isDelegationGraphPhase;
|
|
24
25
|
exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
|
|
26
|
+
exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
|
|
27
|
+
exports.hasPhaseStarted = hasPhaseStarted;
|
|
28
|
+
exports.upsertPhaseStarted = upsertPhaseStarted;
|
|
29
|
+
exports.buildPhaseStartWarning = buildPhaseStartWarning;
|
|
25
30
|
exports.validateReviewHandoff = validateReviewHandoff;
|
|
26
31
|
exports.validateNextJobRecommendations = validateNextJobRecommendations;
|
|
27
32
|
exports.validateDelegationLedger = validateDelegationLedger;
|
|
@@ -96,6 +101,123 @@ function validateAddressFeedbackApproval(evidence) {
|
|
|
96
101
|
}
|
|
97
102
|
return null;
|
|
98
103
|
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Issue #1590 — shape enforcement for review-action completeness
|
|
106
|
+
//
|
|
107
|
+
// #916/#1463 (above) validate that a reviewHandoff payload is internally
|
|
108
|
+
// well-formed (an enum'd reviewTarget.type, pull_request <=> empty artifacts,
|
|
109
|
+
// well-shaped artifact_set entries). It does not check whether the claimed
|
|
110
|
+
// pull_request target's reviewActions are actually complete enough to be
|
|
111
|
+
// useful given the repository state it describes — a structurally valid
|
|
112
|
+
// reviewActions array can still leave out the specific provider action the
|
|
113
|
+
// manager needs (issue #1541: a PR handoff shipped only the generic
|
|
114
|
+
// approve/request_changes pair, with no merge action of any kind). This
|
|
115
|
+
// validator is pure: the proxy (src/local-mcp-server/stdio-server.ts) does
|
|
116
|
+
// the I/O (git branch state) and passes plain data in.
|
|
117
|
+
//
|
|
118
|
+
// (Choosing artifact_set vs. pull_request for the review target itself is
|
|
119
|
+
// not mechanized: registry/delivery/submit.md's own Contract rules already
|
|
120
|
+
// state that rule in prose, and the fix for a model missing it is a
|
|
121
|
+
// tighter, more direct instruction plus eval coverage proving compliance,
|
|
122
|
+
// not a second code path re-deriving the same fact from job content.)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** A composed `deliveryActionId` accepts a bare id or a `<domain>+<id>` suffix (registry/providers/delivery-pr.json). */
|
|
125
|
+
function deliveryActionIdMatches(actions, id) {
|
|
126
|
+
return actions.some((a) => {
|
|
127
|
+
const deliveryActionId = a.deliveryActionId;
|
|
128
|
+
return typeof deliveryActionId === 'string'
|
|
129
|
+
&& (deliveryActionId === id || deliveryActionId.endsWith(`+${id}`));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Review-action-completeness.
|
|
134
|
+
*
|
|
135
|
+
* Only applies to a `pull_request` reviewTarget with a configured repository
|
|
136
|
+
* (repo: null means no repository is configured, e.g. a non-repo-backed
|
|
137
|
+
* job — gate does not apply). A self-consistency check against the proxy's
|
|
138
|
+
* own locally-observed git branch state (`detectRepoInfo()`), not a live
|
|
139
|
+
* GitHub/GitLab API call: it does not confirm the PR is actually open on the
|
|
140
|
+
* provider, only that the claimed pull_request target is backed by the
|
|
141
|
+
* provider actions local branch state implies it should carry.
|
|
142
|
+
*
|
|
143
|
+
* - currentBranch === defaultBranch: requires a `push_default_branch`
|
|
144
|
+
* deliveryActionId (bare or composed `<domain>+push_default_branch`).
|
|
145
|
+
* - Otherwise (feature branch, i.e. an open-PR review claimed): requires
|
|
146
|
+
* both `merge_pr` and `merge_pr_work_completion` (bare or composed).
|
|
147
|
+
*/
|
|
148
|
+
function validateReviewActionCompleteness(reviewTarget, reviewActions, repo) {
|
|
149
|
+
if (!reviewTarget || reviewTarget.type !== 'pull_request')
|
|
150
|
+
return null;
|
|
151
|
+
if (!repo)
|
|
152
|
+
return null;
|
|
153
|
+
const actions = Array.isArray(reviewActions) ? reviewActions : [];
|
|
154
|
+
const errors = [];
|
|
155
|
+
if (repo.currentBranch === repo.defaultBranch) {
|
|
156
|
+
if (!deliveryActionIdMatches(actions, 'push_default_branch')) {
|
|
157
|
+
errors.push(`evidence.reviewHandoff.reviewActions must include an approve_push_default_branch action (an ` +
|
|
158
|
+
`approve_delivery entry whose deliveryActionId is "push_default_branch", or ends with ` +
|
|
159
|
+
`"+push_default_branch") — the current branch is the default branch ("${repo.defaultBranch}"), so ` +
|
|
160
|
+
`the manager needs a one-click way to push it.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
if (!deliveryActionIdMatches(actions, 'merge_pr')) {
|
|
165
|
+
errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr action (an approve_delivery ` +
|
|
166
|
+
`entry whose deliveryActionId is "merge_pr", or ends with "+merge_pr") — an open pull request is ` +
|
|
167
|
+
`the claimed review target, so the manager needs a one-click way to merge it.`);
|
|
168
|
+
}
|
|
169
|
+
if (!deliveryActionIdMatches(actions, 'merge_pr_work_completion')) {
|
|
170
|
+
errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr_work_completion action (an ` +
|
|
171
|
+
`approve_delivery entry whose deliveryActionId is "merge_pr_work_completion", or ends with ` +
|
|
172
|
+
`"+merge_pr_work_completion") — the manager also needs a one-click way to merge the PR and ` +
|
|
173
|
+
`complete the issue.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return errors.length > 0 ? errors : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* True when `marker` records a "starting" timestamp for `phaseId`. Fails
|
|
180
|
+
* open (false, never throws) for a missing file (marker: undefined), a
|
|
181
|
+
* missing key, or a malformed shape — this is a warn-only signal, not a
|
|
182
|
+
* required artifact.
|
|
183
|
+
*/
|
|
184
|
+
function hasPhaseStarted(marker, phaseId) {
|
|
185
|
+
if (!marker || typeof marker !== 'object' || Array.isArray(marker))
|
|
186
|
+
return false;
|
|
187
|
+
const startedPhases = marker.startedPhases;
|
|
188
|
+
if (!startedPhases || typeof startedPhases !== 'object' || Array.isArray(startedPhases))
|
|
189
|
+
return false;
|
|
190
|
+
const value = startedPhases[phaseId];
|
|
191
|
+
return typeof value === 'string' && value.length > 0;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Returns a new marker with `phaseId` upserted to `timestamp`, preserving
|
|
195
|
+
* every other phase already recorded. Tolerates a missing or malformed
|
|
196
|
+
* existing marker by starting from an empty `startedPhases`.
|
|
197
|
+
*/
|
|
198
|
+
function upsertPhaseStarted(marker, phaseId, timestamp) {
|
|
199
|
+
const existing = marker && typeof marker === 'object' && !Array.isArray(marker)
|
|
200
|
+
? marker.startedPhases
|
|
201
|
+
: undefined;
|
|
202
|
+
const startedPhases = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
203
|
+
? { ...existing }
|
|
204
|
+
: {};
|
|
205
|
+
startedPhases[phaseId] = timestamp;
|
|
206
|
+
return { startedPhases };
|
|
207
|
+
}
|
|
208
|
+
/** The single phase this gate covers today (RFC: scoped, not universal — see Risk Assessment). */
|
|
209
|
+
exports.PHASE_START_WARN_PHASES = ['address-feedback'];
|
|
210
|
+
/**
|
|
211
|
+
* Builds the warning prepended to an otherwise-valid address-feedback
|
|
212
|
+
* response when this run never called seekMentoring(status: "starting") for
|
|
213
|
+
* that phase. Warn-only, never a rejection: a lost marker (disk cleanup, a
|
|
214
|
+
* first-ever run before this shipped) must not strand a legitimate resume.
|
|
215
|
+
*/
|
|
216
|
+
function buildPhaseStartWarning(phaseId) {
|
|
217
|
+
return (`⚠️ **Phase-start notice**: this run did not call \`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` ` +
|
|
218
|
+
`before this call. If you resumed after context compaction, re-read \`registry/delivery/address-feedback.md\` now — ` +
|
|
219
|
+
`Step 4 (write the standalone feedback file) is easy to skip when acting from remembered context.\n\n`);
|
|
220
|
+
}
|
|
99
221
|
function isAbsoluteHttpUrl(url) {
|
|
100
222
|
if (typeof url !== 'string' || !url)
|
|
101
223
|
return false;
|
|
@@ -354,9 +476,19 @@ function validateHandoffContracts(args) {
|
|
|
354
476
|
return ['nextJobRecommendations was found in findings but must be nested under evidence. Move nextJobRecommendations out of findings and into the top-level evidence object.'];
|
|
355
477
|
}
|
|
356
478
|
if (isSubmitPhase(phase, status, args.phases)) {
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
return
|
|
479
|
+
const structuralErrors = validateReviewHandoff(evidence.reviewHandoff);
|
|
480
|
+
if (structuralErrors)
|
|
481
|
+
return structuralErrors;
|
|
482
|
+
// Issue #1590: a structurally valid reviewHandoff can still leave out
|
|
483
|
+
// the specific provider action the claimed repository state requires
|
|
484
|
+
// (issue #1541). Shares reviewHandoff's FRAIM_HANDOFF_ENFORCEMENT_MODE
|
|
485
|
+
// escape hatch (see stdio-server.ts). No-ops when repo is absent.
|
|
486
|
+
const reviewHandoff = evidence.reviewHandoff;
|
|
487
|
+
const reviewTarget = reviewHandoff?.reviewTarget;
|
|
488
|
+
const reviewActions = reviewHandoff?.reviewActions;
|
|
489
|
+
const actionCompletenessErrors = validateReviewActionCompleteness(reviewTarget, reviewActions, args.repo ?? null);
|
|
490
|
+
if (actionCompletenessErrors)
|
|
491
|
+
return actionCompletenessErrors;
|
|
360
492
|
}
|
|
361
493
|
if (isRetrospectivePhase(phase, status)) {
|
|
362
494
|
const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
|
|
@@ -1057,6 +1057,58 @@ class FraimLocalMCPServer {
|
|
|
1057
1057
|
return null;
|
|
1058
1058
|
return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
|
|
1059
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Issue #1590, Gate 4 — path to the per-run phase-start marker.
|
|
1062
|
+
* ~/.fraim/run-state/{jobId}.json, keyed by the jobId seekMentoring already
|
|
1063
|
+
* carries on every call, so no new identifier or collision risk across the
|
|
1064
|
+
* user's concurrent multi-issue fleet.
|
|
1065
|
+
*/
|
|
1066
|
+
getRunStateMarkerPath(jobId) {
|
|
1067
|
+
const homeDir = this.getHomeDir();
|
|
1068
|
+
if (!homeDir)
|
|
1069
|
+
return null;
|
|
1070
|
+
return (0, path_1.join)(homeDir, '.fraim', 'run-state', `${jobId}.json`);
|
|
1071
|
+
}
|
|
1072
|
+
/** Reads and parses the run-state marker, tolerating a missing/corrupt file (warn-only signal, never throws). */
|
|
1073
|
+
readRunStateMarker(jobId) {
|
|
1074
|
+
try {
|
|
1075
|
+
const markerPath = this.getRunStateMarkerPath(jobId);
|
|
1076
|
+
if (!markerPath || !(0, fs_1.existsSync)(markerPath))
|
|
1077
|
+
return undefined;
|
|
1078
|
+
return JSON.parse((0, fs_1.readFileSync)(markerPath, 'utf8'));
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
this.log(`⚠️ [phase-start] failed to read run-state marker for jobId=${jobId}: ${error.message}`);
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/** Upserts one phase's started timestamp into the run-state marker. Best-effort: a write failure only degrades Gate 4 to always-warn for this run. */
|
|
1086
|
+
writeRunStateMarker(jobId, phaseId, timestamp) {
|
|
1087
|
+
try {
|
|
1088
|
+
const markerPath = this.getRunStateMarkerPath(jobId);
|
|
1089
|
+
if (!markerPath)
|
|
1090
|
+
return;
|
|
1091
|
+
const existing = this.readRunStateMarker(jobId);
|
|
1092
|
+
const updated = (0, handoff_contracts_1.upsertPhaseStarted)(existing, phaseId, timestamp);
|
|
1093
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(markerPath), { recursive: true });
|
|
1094
|
+
(0, fs_1.writeFileSync)(markerPath, JSON.stringify(updated), 'utf8');
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
this.log(`⚠️ [phase-start] failed to write run-state marker for jobId=${jobId}: ${error.message}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/** Deletes the run-state marker at job completion so the directory does not grow unbounded across a user's history of completed jobs. */
|
|
1101
|
+
deleteRunStateMarker(jobId) {
|
|
1102
|
+
try {
|
|
1103
|
+
const markerPath = this.getRunStateMarkerPath(jobId);
|
|
1104
|
+
if (markerPath && (0, fs_1.existsSync)(markerPath)) {
|
|
1105
|
+
(0, fs_1.unlinkSync)(markerPath);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
this.log(`⚠️ [phase-start] failed to delete run-state marker for jobId=${jobId}: ${error.message}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1060
1112
|
readCachedTemplateFile(filename) {
|
|
1061
1113
|
try {
|
|
1062
1114
|
const cachePath = this.getProviderCachePath(filename);
|
|
@@ -2238,6 +2290,29 @@ class FraimLocalMCPServer {
|
|
|
2238
2290
|
nextPhase: tutoringResponse.nextPhase,
|
|
2239
2291
|
jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
|
|
2240
2292
|
});
|
|
2293
|
+
// Phase-start marker (Issue #1590 — warn-only).
|
|
2294
|
+
//
|
|
2295
|
+
// Detects a resumed address-feedback turn that never called
|
|
2296
|
+
// seekMentoring(status: "starting") for this run: the mentoring
|
|
2297
|
+
// response cache above is in-memory, keyed by request.id, and does
|
|
2298
|
+
// not survive the process restart a Hub resume performs, so a
|
|
2299
|
+
// durable per-jobId marker on disk is the only signal that
|
|
2300
|
+
// survives it. Scoped to address-feedback only (PHASE_START_WARN_PHASES);
|
|
2301
|
+
// never rejects — a lost marker (disk cleanup, first-ever run
|
|
2302
|
+
// before this shipped) must not strand a legitimate resume.
|
|
2303
|
+
let phaseStartWarning = '';
|
|
2304
|
+
if (args.jobId && handoff_contracts_1.PHASE_START_WARN_PHASES.includes(args.currentPhase)) {
|
|
2305
|
+
if (args.status === 'starting') {
|
|
2306
|
+
this.writeRunStateMarker(args.jobId, args.currentPhase, new Date().toISOString());
|
|
2307
|
+
}
|
|
2308
|
+
else if (args.status === 'complete' || args.status === 'failure') {
|
|
2309
|
+
const marker = this.readRunStateMarker(args.jobId);
|
|
2310
|
+
if (!(0, handoff_contracts_1.hasPhaseStarted)(marker, args.currentPhase)) {
|
|
2311
|
+
this.log(`⚠️ [phase-start] ${args.currentPhase} entered without a starting call this run: jobId=${args.jobId}`);
|
|
2312
|
+
phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2241
2316
|
// Quality enforcement (Issue #251).
|
|
2242
2317
|
//
|
|
2243
2318
|
// The local proxy owns seekMentoring for personalized-job support.
|
|
@@ -2293,6 +2368,15 @@ class FraimLocalMCPServer {
|
|
|
2293
2368
|
// The Hub cannot render review bars, next-job chips, or the
|
|
2294
2369
|
// delegation board when these fields are absent.
|
|
2295
2370
|
const handoffPhaseMap = await mentor.getJobPhaseMap(args.jobName);
|
|
2371
|
+
// Issue #1590: locally-observed git branch state for the
|
|
2372
|
+
// review-action-completeness check. A repository is "configured"
|
|
2373
|
+
// for this purpose only when a defaultBranch reference point
|
|
2374
|
+
// exists; without it the check cannot classify default-vs-feature
|
|
2375
|
+
// branch, so it must not apply (repo: null).
|
|
2376
|
+
const handoffRepoInfo = this.detectRepoInfo();
|
|
2377
|
+
const handoffRepoState = handoffRepoInfo?.defaultBranch
|
|
2378
|
+
? { currentBranch: handoffRepoInfo.branch || '', defaultBranch: handoffRepoInfo.defaultBranch }
|
|
2379
|
+
: null;
|
|
2296
2380
|
const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
|
|
2297
2381
|
jobName: args.jobName,
|
|
2298
2382
|
currentPhase: args.currentPhase,
|
|
@@ -2300,6 +2384,7 @@ class FraimLocalMCPServer {
|
|
|
2300
2384
|
evidence: args.evidence,
|
|
2301
2385
|
findings: args.findings,
|
|
2302
2386
|
phases: handoffPhaseMap,
|
|
2387
|
+
repo: handoffRepoState,
|
|
2303
2388
|
});
|
|
2304
2389
|
handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
|
|
2305
2390
|
if (handoffErrors.length > 0) {
|
|
@@ -2367,7 +2452,7 @@ class FraimLocalMCPServer {
|
|
|
2367
2452
|
const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
|
|
2368
2453
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2369
2454
|
}
|
|
2370
|
-
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2455
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, phaseStartWarning + tutoringResponse.message);
|
|
2371
2456
|
}
|
|
2372
2457
|
catch (error) {
|
|
2373
2458
|
this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
|
|
@@ -2763,6 +2848,10 @@ class FraimLocalMCPServer {
|
|
|
2763
2848
|
catch (err) {
|
|
2764
2849
|
this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
|
|
2765
2850
|
}
|
|
2851
|
+
// Issue #1590, Gate 4: clean up the per-run phase-start marker so
|
|
2852
|
+
// ~/.fraim/run-state/ does not grow unbounded across a user's
|
|
2853
|
+
// history of completed jobs.
|
|
2854
|
+
this.deleteRunStateMarker(args.jobId);
|
|
2766
2855
|
}
|
|
2767
2856
|
}
|
|
2768
2857
|
catch (error) {
|
|
@@ -45,6 +45,7 @@ const semver = __importStar(require("semver"));
|
|
|
45
45
|
const compat_1 = require("../config/compat");
|
|
46
46
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
47
47
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
48
|
+
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
48
49
|
const feature_flags_1 = require("../config/feature-flags");
|
|
49
50
|
const persona_entitlement_service_1 = require("./persona-entitlement-service");
|
|
50
51
|
exports.DEFAULT_LAUNCH_PHRASE_MAPPINGS = {
|
|
@@ -706,6 +707,47 @@ class McpService {
|
|
|
706
707
|
evidence: args.evidence,
|
|
707
708
|
findings: args.findings
|
|
708
709
|
});
|
|
710
|
+
// Handoff contract enforcement (Issue #916/#1157/#1276) fall-through
|
|
711
|
+
// safety net, matching the quality-enforcement note just below.
|
|
712
|
+
//
|
|
713
|
+
// The local MCP proxy (stdio-server.ts) is the primary enforcement
|
|
714
|
+
// point for reviewHandoff/nextJobRecommendations/delegationLedger/
|
|
715
|
+
// approved, but that entire check lives inside one try/catch around
|
|
716
|
+
// the local seekMentoring handler: any exception there falls through
|
|
717
|
+
// to this remote path with no re-check, and any client that calls
|
|
718
|
+
// this endpoint directly (bypassing the local proxy) never goes
|
|
719
|
+
// through the local check at all (issue #1590 investigation). Only
|
|
720
|
+
// the structural/shape gates run here, not the git-branch-state Gate
|
|
721
|
+
// 2 check — this service has no local repository to inspect, and
|
|
722
|
+
// that gate already no-ops when `repo` is absent.
|
|
723
|
+
const handoffPhaseMap = await this.aiMentor.getJobPhaseMap(args.jobName);
|
|
724
|
+
const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
|
|
725
|
+
jobName: args.jobName,
|
|
726
|
+
currentPhase: args.currentPhase,
|
|
727
|
+
status: args.status,
|
|
728
|
+
evidence: args.evidence,
|
|
729
|
+
findings: args.findings,
|
|
730
|
+
phases: handoffPhaseMap
|
|
731
|
+
});
|
|
732
|
+
if (handoffErrors.length > 0) {
|
|
733
|
+
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
734
|
+
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
735
|
+
: handoffErrors[0].includes('evidence.approved') ? 'approved'
|
|
736
|
+
: 'delegationLedger';
|
|
737
|
+
// Same content-scoped applicability as the local proxy: a job whose
|
|
738
|
+
// submission phase never promises evidence.reviewHandoff is not
|
|
739
|
+
// gated on it here either.
|
|
740
|
+
const reviewHandoffApplies = missingField !== 'reviewHandoff'
|
|
741
|
+
|| await this.aiMentor.phasePromisesReviewHandoff(args.jobName, args.currentPhase);
|
|
742
|
+
const enforcementMode = missingField === 'reviewHandoff'
|
|
743
|
+
? (process.env.FRAIM_HANDOFF_ENFORCEMENT_MODE || 'enforce')
|
|
744
|
+
: 'enforce';
|
|
745
|
+
if (reviewHandoffApplies && enforcementMode === 'enforce') {
|
|
746
|
+
return {
|
|
747
|
+
content: [{ type: 'text', text: (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors) }]
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
709
751
|
// Quality enforcement (Issue #251): quality-producing jobs MUST emit
|
|
710
752
|
// a valid `evidence.quality` object on their final completion call.
|
|
711
753
|
//
|