fraim 2.0.270 → 2.0.271

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.
@@ -3,14 +3,40 @@
3
3
  * FRAIM Configuration Loader
4
4
  * Loads and validates workspace FRAIM config with fallback to defaults.
5
5
  */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
6
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.resetDetectedDefaultBranchCache = resetDetectedDefaultBranchCache;
7
11
  exports.normalizeFraimConfig = normalizeFraimConfig;
8
12
  exports.loadFraimConfig = loadFraimConfig;
9
13
  exports.getConfigValue = getConfigValue;
10
14
  exports.getRepositoryInfo = getRepositoryInfo;
11
15
  const fs_1 = require("fs");
16
+ const path_1 = __importDefault(require("path"));
12
17
  const types_1 = require("./types");
13
18
  const project_fraim_paths_1 = require("./utils/project-fraim-paths");
19
+ const git_utils_1 = require("./utils/git-utils");
20
+ /**
21
+ * Issue #1164: detection shells out to git, and config normalization runs on every
22
+ * load, so answers are cached. The cache is keyed by the directory inspected, not
23
+ * global: a long-lived process such as the Hub server normalizes configs for
24
+ * several projects, and a single cached answer would apply one repository's
25
+ * default branch to another. `null` is a real, cacheable result meaning "no
26
+ * default branch could be determined for this directory".
27
+ */
28
+ const detectedDefaultBranchByDir = new Map();
29
+ function detectDefaultBranchOnce(detectionCwd) {
30
+ const key = detectionCwd ? path_1.default.resolve(detectionCwd) : '';
31
+ if (!detectedDefaultBranchByDir.has(key)) {
32
+ detectedDefaultBranchByDir.set(key, (0, git_utils_1.getDefaultBranch)(detectionCwd));
33
+ }
34
+ return detectedDefaultBranchByDir.get(key) ?? null;
35
+ }
36
+ /** Test seam: lets a test exercise both detection outcomes without a real repo. */
37
+ function resetDetectedDefaultBranchCache() {
38
+ detectedDefaultBranchByDir.clear();
39
+ }
14
40
  function normalizeCustomerCommunication(config) {
15
41
  const current = config?.['customer-communication'];
16
42
  if (!current || typeof current !== 'object')
@@ -91,7 +117,7 @@ function normalizeAutomation(config) {
91
117
  }
92
118
  };
93
119
  }
94
- function normalizeFraimConfig(config) {
120
+ function normalizeFraimConfig(config, options = {}) {
95
121
  // Handle backward compatibility and migration
96
122
  const mergedConfig = {
97
123
  project: {
@@ -106,7 +132,9 @@ function normalizeFraimConfig(config) {
106
132
  provider: config.git.provider || 'github',
107
133
  owner: config.git.repoOwner,
108
134
  name: config.git.repoName,
109
- defaultBranch: config.git.defaultBranch || 'main'
135
+ // Issue #1164: no `|| 'main'`. An undeclared default branch is
136
+ // resolved from git below, or left absent.
137
+ ...(config.git.defaultBranch ? { defaultBranch: config.git.defaultBranch } : {})
110
138
  } : {})
111
139
  },
112
140
  customizations: {
@@ -114,6 +142,19 @@ function normalizeFraimConfig(config) {
114
142
  ...(config.customizations || {})
115
143
  }
116
144
  };
145
+ // Issue #1164: an undeclared default branch is resolved from the repository
146
+ // itself rather than guessed. When detection fails the field stays absent, so
147
+ // a consumer renders a visible placeholder instead of silently targeting a
148
+ // branch that may not exist.
149
+ if (mergedConfig.repository && !mergedConfig.repository.defaultBranch) {
150
+ const detected = detectDefaultBranchOnce(options.detectionCwd);
151
+ if (detected) {
152
+ mergedConfig.repository.defaultBranch = detected;
153
+ }
154
+ else {
155
+ delete mergedConfig.repository.defaultBranch;
156
+ }
157
+ }
117
158
  if (config.customizations?.postCleanupHook || config.customizations?.cleanupCommand) {
118
159
  if (!mergedConfig.customizations)
119
160
  mergedConfig.customizations = {};
@@ -158,7 +199,11 @@ function loadFraimConfig(configPath = (0, project_fraim_paths_1.getWorkspaceConf
158
199
  try {
159
200
  const configContent = (0, fs_1.readFileSync)(configPath, 'utf-8');
160
201
  const config = JSON.parse(configContent);
161
- const mergedConfig = normalizeFraimConfig(config);
202
+ // Issue #1164: detection must inspect the workspace this config belongs to,
203
+ // not whatever directory the process happens to be running in. The config
204
+ // lives at <workspaceRoot>/fraim/config.json.
205
+ const workspaceRoot = path_1.default.dirname(path_1.default.dirname(path_1.default.resolve(configPath)));
206
+ const mergedConfig = normalizeFraimConfig(config, { detectionCwd: workspaceRoot });
162
207
  console.log(`Loaded FRAIM config from ${displayPath}`);
163
208
  if (config.git && !config.repository) {
164
209
  console.warn('Deprecated: "git" config detected. Consider migrating to "repository" config.');
@@ -213,6 +213,20 @@ exports.FRAIM_CONFIG_SCHEMA = {
213
213
  },
214
214
  "globalPath": {
215
215
  "kind": "string"
216
+ },
217
+ "usage": {
218
+ "kind": "object",
219
+ "properties": {
220
+ "firingLogLimit": {
221
+ "kind": "number"
222
+ },
223
+ "offerLogLimit": {
224
+ "kind": "number"
225
+ },
226
+ "retirementOfferThreshold": {
227
+ "kind": "number"
228
+ }
229
+ }
216
230
  }
217
231
  }
218
232
  },
@@ -646,6 +660,10 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
646
660
  "learning.lastSynthesisDate",
647
661
  "learning.scoreThreshold",
648
662
  "learning.globalPath",
663
+ "learning.usage",
664
+ "learning.usage.firingLogLimit",
665
+ "learning.usage.offerLogLimit",
666
+ "learning.usage.retirementOfferThreshold",
649
667
  "customer-communication",
650
668
  "customer-communication.productName",
651
669
  "customer-communication.productUrl",
@@ -18,7 +18,9 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.isSubmitPhase = isSubmitPhase;
20
20
  exports.isRetrospectivePhase = isRetrospectivePhase;
21
+ exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
21
22
  exports.isDelegationGraphPhase = isDelegationGraphPhase;
23
+ exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
22
24
  exports.validateReviewHandoff = validateReviewHandoff;
23
25
  exports.validateNextJobRecommendations = validateNextJobRecommendations;
24
26
  exports.validateDelegationLedger = validateDelegationLedger;
@@ -43,6 +45,13 @@ function isSubmitPhase(currentPhase, status) {
43
45
  function isRetrospectivePhase(currentPhase, status) {
44
46
  return currentPhase === 'retrospective' && status === 'complete';
45
47
  }
48
+ /**
49
+ * Returns true when a seekMentoring call is address-feedback completing.
50
+ * Completing address-feedback requires manager approval (evidence.approved: true).
51
+ */
52
+ function isAddressFeedbackCompletion(currentPhase, status) {
53
+ return currentPhase === 'address-feedback' && status === 'complete';
54
+ }
46
55
  /**
47
56
  * Returns true when a seekMentoring call is the delegation graph phase.
48
57
  * This matches the existing Hub test fixture for the delegation board.
@@ -53,6 +62,20 @@ function isDelegationGraphPhase(currentPhase) {
53
62
  // ---------------------------------------------------------------------------
54
63
  // Individual validators
55
64
  // ---------------------------------------------------------------------------
65
+ /**
66
+ * Validates that evidence.approved is true for address-feedback completion.
67
+ * Returns null if valid, or an array of error strings.
68
+ */
69
+ function validateAddressFeedbackApproval(evidence) {
70
+ if (evidence.approved !== true) {
71
+ return [
72
+ `evidence.approved must be true when completing address-feedback. ` +
73
+ `Set evidence.approved: true only after the manager has explicitly approved the work. ` +
74
+ `If the manager has not yet approved, set status: "failure" to re-enter the review loop.`,
75
+ ];
76
+ }
77
+ return null;
78
+ }
56
79
  /**
57
80
  * Validates evidence.reviewHandoff. Returns null if valid, or an array of
58
81
  * human-readable error strings describing what is wrong.
@@ -213,11 +236,21 @@ function validateHandoffContracts(args) {
213
236
  if (errors)
214
237
  return errors;
215
238
  }
239
+ if (isAddressFeedbackCompletion(phase, status)) {
240
+ const errors = validateAddressFeedbackApproval(evidence);
241
+ if (errors)
242
+ return errors;
243
+ }
216
244
  return [];
217
245
  }
218
246
  // ---------------------------------------------------------------------------
219
247
  // Rejection message builder
220
248
  // ---------------------------------------------------------------------------
249
+ const ADDRESS_FEEDBACK_APPROVAL_SCHEMA = `\`\`\`javascript
250
+ evidence: {
251
+ approved: true // Set only after the manager explicitly approves via the Hub review action
252
+ }
253
+ \`\`\``;
221
254
  const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
222
255
  evidence: {
223
256
  reviewHandoff: {
@@ -257,6 +290,7 @@ evidence: {
257
290
  }
258
291
  \`\`\``;
259
292
  const FIELD_SCHEMAS = {
293
+ approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
260
294
  reviewHandoff: REVIEW_HANDOFF_SCHEMA,
261
295
  nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
262
296
  delegationLedger: DELEGATION_LEDGER_SCHEMA,
@@ -280,6 +314,8 @@ function buildHandoffRejectionMessage(currentPhase, field, errors) {
280
314
  '',
281
315
  schemaHint,
282
316
  '',
283
- `The job is **not** marked complete. Add the \`evidence.${field}\` object and resubmit this phase.`,
317
+ field === 'approved'
318
+ ? 'The job is **not** marked complete. Set `evidence.approved: true` (only after explicit manager approval) and resubmit.'
319
+ : `The job is **not** marked complete. Add the \`evidence.${field}\` object and resubmit this phase.`,
284
320
  ].join('\n');
285
321
  }
@@ -9,6 +9,7 @@ exports.labelForPhaseId = labelForPhaseId;
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const project_fraim_paths_1 = require("./utils/project-fraim-paths");
12
+ const resolve_phase_edge_1 = require("./resolve-phase-edge");
12
13
  const EMPLOYEE_JOB_LAYERS = [
13
14
  { segments: ['ai-employee', 'jobs'] },
14
15
  { segments: ['personalized-employee', 'jobs'] },
@@ -99,19 +100,6 @@ function findJobStubPath(projectPath, jobId) {
99
100
  }
100
101
  return null;
101
102
  }
102
- function nextPhase(edge, discriminant) {
103
- if (edge == null)
104
- return null;
105
- if (typeof edge === 'string')
106
- return edge;
107
- if (typeof edge === 'object') {
108
- if (typeof edge[discriminant] === 'string')
109
- return edge[discriminant];
110
- if (typeof edge.default === 'string')
111
- return edge.default;
112
- }
113
- return null;
114
- }
115
103
  function loadJobPhasesFromSteps(filePath) {
116
104
  const raw = fs_1.default.readFileSync(filePath, 'utf8');
117
105
  const stepsMatch = raw.match(/## Steps\r?\n([\s\S]*?)(?:\r?\n## |\r?\n---|$)/);
@@ -141,7 +129,7 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
141
129
  const phaseDef = fm.phases[cursor];
142
130
  if (!phaseDef)
143
131
  break;
144
- cursor = nextPhase(phaseDef.onSuccess, discriminant);
132
+ cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseDef.onSuccess, discriminant);
145
133
  }
146
134
  const labels = fm.phaseLabels || {};
147
135
  return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * Single authority for resolving a job phase transition (issue #1123).
4
+ *
5
+ * A phase edge (`onSuccess` or `onFailure`) is either a phase id, a discriminant
6
+ * map, or terminal. Before this module the identical resolver existed twice, in
7
+ * `src/core/job-phases.ts` and `src/ai-hub/catalog.ts`, and the discriminant
8
+ * precedence lived a third time inline in `src/core/ai-mentor.ts`. Three copies of
9
+ * one rule is how the success and failure paths drift apart, which is exactly the
10
+ * class of defect this issue is about.
11
+ *
12
+ * Both edges accept a map. A map must declare `default`; an unset or unrecognised
13
+ * discriminant resolves to it, so a job can never route somewhere unexpected. A
14
+ * string edge never consults a discriminant at all, which is what makes this
15
+ * change inert for every job that authors no map.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.resolvePhaseEdge = resolvePhaseEdge;
19
+ exports.resolveDiscriminant = resolveDiscriminant;
20
+ exports.discriminantKeys = discriminantKeys;
21
+ /** The default discriminant, and the mandatory key on every authored map. */
22
+ const DEFAULT_DISCRIMINANT = 'default';
23
+ /**
24
+ * Resolve an edge to the next phase id.
25
+ *
26
+ * Returns `null` when the edge is terminal, when a map resolves to nothing, or
27
+ * when the edge is malformed. Callers decide what a `null` means for them:
28
+ * `loadJobPhases` stops walking, `ai-mentor` falls back to self-retry.
29
+ */
30
+ function resolvePhaseEdge(edge, discriminant) {
31
+ if (edge == null)
32
+ return null;
33
+ if (typeof edge === 'string')
34
+ return edge;
35
+ if (typeof edge === 'object') {
36
+ if (typeof edge[discriminant] === 'string')
37
+ return edge[discriminant];
38
+ if (typeof edge[DEFAULT_DISCRIMINANT] === 'string')
39
+ return edge[DEFAULT_DISCRIMINANT];
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * The discriminant an agent supplied on a `seekMentoring` call.
45
+ *
46
+ * `phaseOutcome` is the general form and `issueType` the original narrower one;
47
+ * both are accepted from either `findings` or `evidence` so an agent following
48
+ * older phase text still routes correctly. This precedence is deliberately shared
49
+ * by the success and failure paths: if they read the discriminant differently,
50
+ * the same call would route two ways depending on its status.
51
+ */
52
+ function resolveDiscriminant(findings, evidence) {
53
+ const read = (source, key) => {
54
+ if (!source || typeof source !== 'object')
55
+ return null;
56
+ const value = source[key];
57
+ return typeof value === 'string' && value.length > 0 ? value : null;
58
+ };
59
+ return (read(findings, 'phaseOutcome')
60
+ ?? read(findings, 'issueType')
61
+ ?? read(evidence, 'issueType')
62
+ ?? read(evidence, 'phaseOutcome')
63
+ ?? DEFAULT_DISCRIMINANT);
64
+ }
65
+ /**
66
+ * The discriminant keys a map edge offers, excluding `default`.
67
+ *
68
+ * Used to tell the agent which outcomes a phase accepts. A routing map nobody
69
+ * knows how to trigger is decorative, which is the failure recorded as #1135.
70
+ */
71
+ function discriminantKeys(edge) {
72
+ if (!edge || typeof edge !== 'object')
73
+ return [];
74
+ return Object.keys(edge).filter((key) => key !== DEFAULT_DISCRIMINANT);
75
+ }
@@ -17,7 +17,13 @@ exports.DEFAULT_FRAIM_CONFIG = {
17
17
  provider: 'github',
18
18
  owner: '',
19
19
  name: '',
20
- defaultBranch: 'main'
20
+ // Issue #1164: deliberately no defaultBranch. `owner` and `name` default to
21
+ // the empty string, which is obviously missing and fails loudly downstream.
22
+ // A default branch name does not have that property: `'main'` is
23
+ // indistinguishable from a real answer, so it silently targeted pull
24
+ // requests at a branch this repository does not have. An absent value is
25
+ // resolved from git in `normalizeFraimConfig`, and left absent when it
26
+ // cannot be determined.
21
27
  },
22
28
  customizations: {}
23
29
  };
@@ -77,6 +77,9 @@ function getPort() {
77
77
  * Determines the database name based on the git branch
78
78
  */
79
79
  function determineDatabaseName() {
80
+ if (process.env.MONGODB_DB_NAME) {
81
+ return process.env.MONGODB_DB_NAME;
82
+ }
80
83
  try {
81
84
  const branchName = process.env.FRAIM_BRANCH || process.env.FRAIM_BRANCH_NAME || (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD').toString().trim();
82
85
  const issueMatch = branchName.match(/issue-(\d+)/i) || branchName.match(/(\d+)-/);
@@ -87,7 +90,7 @@ function determineDatabaseName() {
87
90
  catch (e) {
88
91
  // Silently fail
89
92
  }
90
- return process.env.MONGODB_DB_NAME || (process.env.NODE_ENV === 'production' ? 'fraim_prod' : 'fraim_dev');
93
+ return process.env.NODE_ENV === 'production' ? 'fraim_prod' : 'fraim_dev';
91
94
  }
92
95
  /**
93
96
  * Gets the current git branch name
@@ -114,14 +117,28 @@ function determineSchema(branchName) {
114
117
  return 'prod';
115
118
  }
116
119
  /**
117
- * Gets the default branch name from git remote
120
+ * Gets the default branch name from the git remote, or `null` when it cannot be
121
+ * determined.
122
+ *
123
+ * Issue #1164: this deliberately reports failure instead of guessing. A branch
124
+ * name is used to target pull requests, so a wrong-but-plausible answer is worse
125
+ * than no answer: it opens a PR against a branch that may not exist. Two earlier
126
+ * fallbacks are removed for that reason:
127
+ *
128
+ * - falling back to the *current* branch, which reports whatever feature branch
129
+ * happens to be checked out as though it were the repository default;
130
+ * - falling back to the literal `'main'`, which is indistinguishable from a real
131
+ * detection result.
132
+ *
133
+ * @param cwd Directory to inspect. Defaults to the current working directory.
134
+ * Explicit so callers and tests can scope detection to a known repo.
118
135
  */
119
- function getDefaultBranch() {
136
+ function getDefaultBranch(cwd) {
120
137
  try {
121
- // Try to get the default branch from remote HEAD
122
138
  const remoteHead = (0, child_process_1.execSync)('git symbolic-ref refs/remotes/origin/HEAD', {
123
139
  timeout: 2000, // 2 second timeout
124
- stdio: 'pipe'
140
+ stdio: 'pipe',
141
+ ...(cwd ? { cwd } : {})
125
142
  }).toString().trim();
126
143
  const match = remoteHead.match(/refs\/remotes\/origin\/(.+)$/);
127
144
  if (match) {
@@ -129,16 +146,9 @@ function getDefaultBranch() {
129
146
  }
130
147
  }
131
148
  catch (e) {
132
- // If that fails, try to get it from the current branch
133
- try {
134
- return getCurrentGitBranch();
135
- }
136
- catch (e2) {
137
- // Fall back to common defaults
138
- }
149
+ // No remote HEAD to read: a bare local repo, no origin, or not a repo at all.
139
150
  }
140
- // Default fallback
141
- return 'main';
151
+ return null;
142
152
  }
143
153
  /**
144
154
  * Sanitizes a repository identifier by stripping local paths and normalizing to standard HTTPS URLs.
@@ -144,9 +144,24 @@ function getUserFraimDisplayPath(relativePath = '') {
144
144
  /**
145
145
  * Get the user-level FRAIM directory (~/.fraim/).
146
146
  * Can be overridden with FRAIM_USER_DIR env var for testing.
147
+ *
148
+ * Issue #1159: inside a sandboxed test run the silent fallback to the real home
149
+ * is a data-loss bug, not a convenience. A test suite that reached `~/.fraim`
150
+ * booted `AiHubServer` instances against the developer's live conversation
151
+ * store and parked four in-flight runs as "Waiting on you". When the runner has
152
+ * marked the run sandboxed, an unset `FRAIM_USER_DIR` means isolation was
153
+ * broken, so fail here rather than corrupt real state.
147
154
  */
148
155
  function getUserFraimDirPath() {
149
- return process.env.FRAIM_USER_DIR || (0, path_1.join)(os_1.default.homedir(), '.fraim');
156
+ const override = process.env.FRAIM_USER_DIR;
157
+ if (override)
158
+ return override;
159
+ if (process.env.FRAIM_TEST_SANDBOX === '1') {
160
+ throw new Error('FRAIM_USER_DIR is unset inside a sandboxed test run (FRAIM_TEST_SANDBOX=1). '
161
+ + 'Refusing to fall back to the real ~/.fraim. Restore FRAIM_USER_DIR to the run sandbox '
162
+ + 'before touching the machine-level FRAIM home.');
163
+ }
164
+ return (0, path_1.join)(os_1.default.homedir(), '.fraim');
150
165
  }
151
166
  function getUserFraimLearningsDir() {
152
167
  return (0, path_1.join)(getUserFraimDirPath(), 'personalized-employee', 'learnings');
@@ -17,6 +17,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
17
17
  'evidence',
18
18
  'feedback',
19
19
  'cleanup_manifests',
20
+ // Issue #1103 R18: the usage record is personal-bearing behavioural metadata, so
21
+ // it needs a storage-limitation category like every other retained artifact.
22
+ // Unlike the others it is one JSON file rather than a directory of files, so the
23
+ // value bounds how long an individual offer or firing record keeps its detail
24
+ // (applied by learning-usage-store.pruneUsageStore) rather than when a file is
25
+ // deleted. Aggregate counters are never pruned; deleting them would misreport
26
+ // history rather than minimise it.
27
+ 'learning_usage',
20
28
  ];
21
29
  function readWorkspaceConfig(workspaceRoot) {
22
30
  try {