fraim 2.0.352 → 2.0.353

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.
@@ -64,7 +64,7 @@ const launcherSource = `#!/usr/bin/env node
64
64
  const fs = require('fs');
65
65
  const os = require('os');
66
66
  const path = require('path');
67
- const { spawnSync } = require('child_process');
67
+ const { spawnSync, spawn } = require('child_process');
68
68
 
69
69
  const LAUNCHER_VERSION = ${LAUNCHER_VERSION};
70
70
  const STALENESS_WINDOW_MS = ${STALENESS_WINDOW_MS};
@@ -75,11 +75,15 @@ const USER_DIR = process.env.FRAIM_USER_DIR || path.join(os.homedir(), '.fraim')
75
75
  const STATE_DIR = path.join(USER_DIR, 'mcp-launcher');
76
76
  const STATE_PATH = path.join(STATE_DIR, 'latest.json');
77
77
  const CACHE_ROOT = path.join(USER_DIR, 'npm-cache', 'mcp');
78
+ const REFRESH_LOCK = path.join(STATE_DIR, 'refresh.lock');
79
+ const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
80
+ const launchStartedAt = Date.now();
78
81
 
79
82
  const commandName = (name) => process.platform === 'win32' ? name + '.cmd' : name;
80
83
  const npmCommand = commandName('npm');
81
84
  const npxCommand = commandName('npx');
82
85
  const rawArgs = process.argv.slice(2);
86
+ const refreshMode = rawArgs[0] === '--refresh-cache';
83
87
  const cliMode = rawArgs[0] === '--fraim-cli';
84
88
  const cliArgs = cliMode ? rawArgs.slice(1) : (rawArgs.length > 0 ? rawArgs : ['mcp']);
85
89
 
@@ -172,6 +176,15 @@ function resolvePackage() {
172
176
  return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
173
177
  }
174
178
 
179
+ if (isUsableState(cached)) {
180
+ const cachedDir = path.join(CACHE_ROOT, 'fraim-' + cached.version);
181
+ if (installedEntry(cached, cachedDir)) {
182
+ // The installed package is immediately usable. Refresh in a separate
183
+ // process so an npm outage cannot postpone this MCP connection.
184
+ return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true, refresh: true };
185
+ }
186
+ }
187
+
175
188
  const fraimVersion = resolveLatest('fraim');
176
189
  if (fraimVersion) {
177
190
  const state = { packageName: 'fraim', binName: 'fraim', version: fraimVersion, fromCache: false };
@@ -219,19 +232,71 @@ function pruneCaches(keepDir, nowMs) {
219
232
  } catch (error) { /* nothing to prune */ }
220
233
  }
221
234
 
222
- function runPackage(plan, cacheDir) {
235
+ function runPackage(plan, cacheDir, args = cliArgs) {
223
236
  ensureDir(cacheDir);
224
- const env = { ...process.env, npm_config_cache: cacheDir };
225
- const result = runCommand(npxCommand, ['--yes', '--package', plan.packageName + '@' + plan.version, plan.binName, ...cliArgs], {
237
+ const env = { ...process.env, npm_config_cache: cacheDir, FRAIM_MCP_LAUNCH_STARTED_AT: String(launchStartedAt) };
238
+ const result = runCommand(npxCommand, ['--yes', '--package', plan.packageName + '@' + plan.version, plan.binName, ...args], {
226
239
  stdio: 'inherit',
227
240
  env
228
241
  });
229
242
  return typeof result.status === 'number' ? result.status : 1;
230
243
  }
231
244
 
245
+ function installedEntry(plan, cacheDir) {
246
+ try {
247
+ const npxDir = path.join(cacheDir, '_npx');
248
+ for (const hash of fs.readdirSync(npxDir)) {
249
+ try {
250
+ const packageDir = path.join(npxDir, hash, 'node_modules', plan.packageName);
251
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
252
+ const entry = path.join(packageDir, 'bin', 'fraim.js');
253
+ if (manifest.name === plan.packageName && manifest.version === plan.version && fs.statSync(entry).isFile()) {
254
+ return entry;
255
+ }
256
+ } catch (error) { /* try another installed hash */ }
257
+ }
258
+ } catch (error) { /* no complete installed package */ }
259
+ return null;
260
+ }
261
+
262
+ function startBackgroundRefresh() {
263
+ try {
264
+ ensureDir(STATE_DIR);
265
+ try {
266
+ const age = Date.now() - fs.statSync(REFRESH_LOCK).mtimeMs;
267
+ if (age < REFRESH_LOCK_STALE_MS) return;
268
+ fs.rmSync(REFRESH_LOCK, { force: true });
269
+ } catch (error) { /* no lock */ }
270
+ const lock = fs.openSync(REFRESH_LOCK, 'wx');
271
+ fs.closeSync(lock);
272
+ const child = spawn(process.execPath, [__filename, '--refresh-cache'], {
273
+ detached: true, stdio: 'ignore', windowsHide: true, env: process.env
274
+ });
275
+ child.on('error', () => { try { fs.rmSync(REFRESH_LOCK, { force: true }); } catch (error) { /* best effort */ } });
276
+ child.unref();
277
+ } catch (error) { /* another launcher owns refresh */ }
278
+ }
279
+
280
+ if (refreshMode) {
281
+ try {
282
+ const latest = resolveLatest('fraim');
283
+ if (latest) {
284
+ const next = { packageName: 'fraim', binName: 'fraim', version: latest, fromCache: false };
285
+ const nextCache = path.join(CACHE_ROOT, 'fraim-' + latest);
286
+ if (installedEntry(next, nextCache) || (runPackage(next, nextCache, ['--version']) === 0 && installedEntry(next, nextCache))) {
287
+ writeState(next);
288
+ }
289
+ }
290
+ } finally {
291
+ try { fs.rmSync(REFRESH_LOCK, { force: true }); } catch (error) { /* best effort */ }
292
+ }
293
+ process.exit(0);
294
+ }
295
+
232
296
  const plan = resolvePackage();
233
297
  const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
234
298
  pruneCaches(versionCacheDir, Date.now());
299
+ if (plan.refresh) startBackgroundRefresh();
235
300
 
236
301
  /**
237
302
  * True when npx materialized the package into this cache. A non-zero exit from a package
@@ -248,10 +313,19 @@ function packageMaterialized(cacheDir) {
248
313
  }
249
314
  }
250
315
 
251
- let status = runPackage(plan, versionCacheDir);
316
+ const directEntry = installedEntry(plan, versionCacheDir);
317
+ let status;
318
+ if (directEntry) {
319
+ const result = spawnSync(process.execPath, [directEntry, ...cliArgs], {
320
+ stdio: 'inherit', env: { ...process.env, FRAIM_MCP_LAUNCH_STARTED_AT: String(launchStartedAt) }, windowsHide: true
321
+ });
322
+ status = typeof result.status === 'number' ? result.status : 1;
323
+ } else {
324
+ status = runPackage(plan, versionCacheDir);
325
+ }
252
326
 
253
327
  // Retry ONLY when npx could not materialize the package: the corrupt-cache case this exists for.
254
- if (status !== 0 && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
328
+ if (status !== 0 && !directEntry && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
255
329
  const retryCache = path.join(CACHE_ROOT, 'retry-' + Date.now() + '-' + process.pid);
256
330
  console.error('[fraim-mcp-launcher] Package execution failed; retrying once with a fresh npm cache.');
257
331
  status = runPackage(plan, retryCache);
@@ -287,8 +361,9 @@ function getFraimMcpLauncherStatePath() {
287
361
  * MCP server spawn then uses immediately instead of waiting out the window.
288
362
  *
289
363
  * The semantics are "the version you just ran is the version recorded", which is why the caller
290
- * passes its own version rather than this function consulting the registry. A sync that ran an
291
- * older package must not claim to have recorded a newer one.
364
+ * passes its own version rather than this function consulting the registry. Automatic catalog
365
+ * sync is not a version selection: its running package may be older than the background updater's
366
+ * selection. It must leave this record alone regardless of which operation finishes first.
292
367
  *
293
368
  * The record's shape is duplicated from the generated launcher's `writeState`, deliberately and
294
369
  * unavoidably: that script executes standalone from `~/.fraim/bin` with only Node built-ins and
@@ -296,7 +371,9 @@ function getFraimMcpLauncherStatePath() {
296
371
  * `test-1354-mcp-launcher-freshness-window.ts` asserts that a record written here is accepted by
297
372
  * the launcher rather than merely that the fields look alike.
298
373
  */
299
- function recordFraimMcpLauncherVersion(version) {
374
+ function recordFraimMcpLauncherVersion(version, source = 'explicit-sync') {
375
+ if (source === 'automatic-sync')
376
+ return false;
300
377
  if (!/^\d+\.\d+\.\d+/.test(version))
301
378
  return false;
302
379
  const statePath = getFraimMcpLauncherStatePath();
@@ -60,6 +60,52 @@ const path_1 = __importDefault(require("path"));
60
60
  const child_process_1 = require("child_process");
61
61
  const chalk_1 = __importDefault(require("chalk"));
62
62
  const script_sync_utils_1 = require("../utils/script-sync-utils");
63
+ /** Keep optional installation responsive and owned by the process that started it. */
64
+ function installRuntimeDependencies(baseDir, env) {
65
+ return new Promise((resolve, reject) => {
66
+ const child = (0, child_process_1.spawn)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
67
+ cwd: baseDir,
68
+ env,
69
+ shell: true,
70
+ windowsHide: true,
71
+ // A private POSIX process group lets exit cleanup terminate npm and its
72
+ // descendants without asynchronous process enumeration after parent exit.
73
+ detached: process.platform !== 'win32',
74
+ stdio: ['ignore', 'ignore', 'pipe'],
75
+ });
76
+ let stderr = '';
77
+ child.stderr?.on('data', data => { stderr = (stderr + data.toString()).slice(-65536); });
78
+ const terminateInstaller = () => {
79
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null)
80
+ return;
81
+ try {
82
+ if (process.platform === 'win32') {
83
+ (0, child_process_1.execFileSync)('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
84
+ windowsHide: true, stdio: 'ignore', timeout: 5000,
85
+ });
86
+ }
87
+ else {
88
+ process.kill(-child.pid, 'SIGKILL');
89
+ }
90
+ }
91
+ catch { /* the owned child may have finished while shutdown began */ }
92
+ };
93
+ // STDIO closes with process.exit(), which does not wait for child handles.
94
+ // Terminate only this live install; ordinary CLI callers await completion.
95
+ process.once('exit', terminateInstaller);
96
+ child.once('error', error => {
97
+ process.removeListener('exit', terminateInstaller);
98
+ reject(error);
99
+ });
100
+ child.once('close', code => {
101
+ process.removeListener('exit', terminateInstaller);
102
+ if (code === 0)
103
+ resolve();
104
+ else
105
+ reject(Object.assign(new Error(`npm install exited with code ${code}`), { stderr }));
106
+ });
107
+ });
108
+ }
63
109
  /**
64
110
  * Curated list of npm runtime dependencies that user-level scripts in
65
111
  * ~/.fraim/scripts/ require at runtime. Keep this list narrow — only add deps
@@ -101,7 +147,7 @@ function ensureUserLevelDirectories(userFraimDir) {
101
147
  * errors — setup must not break because a downstream script's dep couldn't
102
148
  * be installed.
103
149
  */
104
- function ensureUserLevelDependencies(userFraimDir) {
150
+ async function ensureUserLevelDependencies(userFraimDir) {
105
151
  const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
106
152
  const pkgPath = path_1.default.join(baseDir, 'package.json');
107
153
  const nodeModulesDir = path_1.default.join(baseDir, 'node_modules');
@@ -133,11 +179,9 @@ function ensureUserLevelDependencies(userFraimDir) {
133
179
  }
134
180
  console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
135
181
  try {
136
- (0, child_process_1.execSync)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
137
- cwd: baseDir,
138
- stdio: 'pipe',
139
- env: childEnv,
140
- });
182
+ // Catalog preparation also runs inside the MCP process. Await installation
183
+ // without blocking its event loop so local discovery can continue serving.
184
+ await installRuntimeDependencies(baseDir, childEnv);
141
185
  console.log(chalk_1.default.green(`✅ Installed: ${missing.join(', ')}`));
142
186
  }
143
187
  catch (error) {
@@ -158,7 +202,7 @@ async function syncUserLevelArtifacts(userFraimDir) {
158
202
  console.log(chalk_1.default.blue('📦 Setting up user-level FRAIM directory...'));
159
203
  console.log(chalk_1.default.gray(` Target: ${baseDir}`));
160
204
  ensureUserLevelDirectories(baseDir);
161
- ensureUserLevelDependencies(baseDir);
205
+ await ensureUserLevelDependencies(baseDir);
162
206
  // Issue #1201 (R4.2): repair global per-IDE FRAIM discovery artifacts
163
207
  // (e.g. ~/.codex/skills/fraim/SKILL.md) on every `npx fraim sync --global`
164
208
  // run, not only on `fraim add-ide` and first-run onboarding — closes the
@@ -680,10 +680,13 @@ const FIELD_SCHEMAS = {
680
680
  ' "runScope": "manager" | "project" | "org",',
681
681
  ' "proposals": [{',
682
682
  ' "proposalId": "<id the journal records against>",',
683
- ' "claimKey": "<claim identity>", "family": "<learning family>", "revision": <int>,',
683
+ ' "claimKey": "<claim identity>", "revision": <int>,',
684
+ ' "type": "Job" | "Phase" | "Skill" | "Context" | "Rule" | "Learning" | "Template" | "Script" | "Config",',
684
685
  ' "changeType": "new" | "update" | "cold",',
685
686
  ' "title": "<reader title>", "takeaway": "<reader takeaway>", "example": "<optional>",',
686
- ' "suggestedLevel": "manager" | "project" | "org", "recurrences": <int>,',
687
+ ' "suggestedLevel": "manager" | "project" | "org",',
688
+ ' // A Learning row adds its family and occurrence history:',
689
+ ' "family": "<learning family>", "recurrences": <int>,',
687
690
  ' "recurrencesAtLastReview": <int|null>, "domain": "<domain|null>",',
688
691
  ' "reopening": { "rejectedAt": "<date>", "reason": "<manager words>", "countAtRejection": <int> }',
689
692
  ' }],',
@@ -691,6 +694,9 @@ const FIELD_SCHEMAS = {
691
694
  ' "kind": "refused" | "superseded", "reason": "...", "newSourceCount": <int> }]',
692
695
  '}',
693
696
  'Trace, sourceRefs and scores are audit metadata and must not be sent.',
697
+ 'Types come from registry/rules/fraim/artifact-boundaries.md. A non-Learning row',
698
+ 'omits family, recurrences, recurrencesAtLastReview, domain and reopening: a',
699
+ 'capability change has no occurrence history and no learning family.',
694
700
  ].join(String.fromCharCode(10)),
695
701
  approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
696
702
  reviewHandoff: REVIEW_HANDOFF_SCHEMA,
@@ -32,7 +32,7 @@
32
32
  * from a payload that reached the Hub by some other path.
33
33
  */
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.MAX_SUPPRESSED_PER_SET = exports.MAX_PROPOSALS_PER_SET = exports.PROPOSAL_CHANGE_TYPES = exports.SUPPRESSION_KINDS = exports.PROPOSAL_RUN_SCOPES = void 0;
35
+ exports.MAX_SUPPRESSED_PER_SET = exports.MAX_PROPOSALS_PER_SET = exports.LEARNING_ARTIFACT_TYPE = exports.PROPOSAL_ARTIFACT_TYPES = exports.PROPOSAL_CHANGE_TYPES = exports.SUPPRESSION_KINDS = exports.PROPOSAL_RUN_SCOPES = void 0;
36
36
  exports.rankProposals = rankProposals;
37
37
  exports.validateProposalSet = validateProposalSet;
38
38
  /** Scopes a run can be asked for. Decides which corpus an approval lands in. */
@@ -42,6 +42,35 @@ exports.PROPOSAL_RUN_SCOPES = ['manager', 'project', 'org'];
42
42
  exports.SUPPRESSION_KINDS = ['refused', 'superseded'];
43
43
  /** What kind of change a proposal makes to the corpus. */
44
44
  exports.PROPOSAL_CHANGE_TYPES = ['new', 'update', 'cold'];
45
+ /**
46
+ * What kind of artifact a row proposes changing (Issue #2079).
47
+ *
48
+ * A learning run proposes more than one kind of thing. A lesson is a claim that
49
+ * has recurred, and the manager decides whether it is worth remembering. A
50
+ * capability change is a permanent change to a job, skill or rule, and the
51
+ * manager decides whether to make it. The run journal has always known the
52
+ * difference: every action it records is typed `kind: 'learning' | 'capability'`.
53
+ * This wire shape is where that fact used to be lost, so a job edit arrived with
54
+ * an occurrence count of 1 beside a lesson at 14 and a heading reading
55
+ * "New lesson".
56
+ *
57
+ * The vocabulary is not invented here. `registry/rules/fraim/artifact-boundaries.md`
58
+ * already defines these nine, each with the one-line responsibility that tells a
59
+ * reader what the type means, and instructs that the definition is kept there
60
+ * rather than copied into each job. `classify-instruction-placement` returns a
61
+ * family from exactly this set. Learning is one of the nine, so every row in a
62
+ * batch names its type from one list instead of lessons being described by
63
+ * `family` while everything else was described by a parallel vocabulary.
64
+ *
65
+ * This list carries the names only. The responsibility wording stays in the rule
66
+ * file, and `scripts/validate-artifact-type-contract.ts` fails the build if the
67
+ * two ever disagree.
68
+ */
69
+ exports.PROPOSAL_ARTIFACT_TYPES = [
70
+ 'Job', 'Phase', 'Skill', 'Context', 'Rule', 'Learning', 'Template', 'Script', 'Config',
71
+ ];
72
+ /** The one type whose row carries recurrence evidence. */
73
+ exports.LEARNING_ARTIFACT_TYPE = 'Learning';
45
74
  /**
46
75
  * Maximum proposals in one set. Derived from the measured envelope above, not
47
76
  * chosen: at roughly 700 bytes per proposal this keeps a full set inside twice
@@ -63,13 +92,28 @@ const isText = (v) => typeof v === 'string' && v.trim().length > 0;
63
92
  * boundary is cheaper than auditing every render site later.
64
93
  */
65
94
  const FORBIDDEN_PROPOSAL_FIELDS = ['trace', 'sourceRefs', 'sourceHashes', 'content', 'score'];
95
+ /**
96
+ * Fields only a Learning row carries (Issue #2079).
97
+ *
98
+ * `recurrences` and `recurrencesAtLastReview` are the accumulated evidence
99
+ * behind a claim. `domain` is its subject matter, which
100
+ * `registry/rules/fraim/artifact-boundaries.md` keeps separate from reach.
101
+ * `family` is which family file it lands in, and it is the slot a capability
102
+ * change was being forced into because there was nowhere else to say what the
103
+ * row was. None of the four means anything for a capability change, and the
104
+ * count in particular invited the strength comparison that made a permanent job
105
+ * edit read as the weakest row in the batch. `reopening` goes with them: it
106
+ * quotes an earlier rejection and renders "it was at N then and is at M now",
107
+ * and M is the count this row does not have.
108
+ */
109
+ const LEARNING_ONLY_FIELDS = ['family', 'recurrences', 'recurrencesAtLastReview', 'domain', 'reopening'];
66
110
  function validateEntry(entry, index, errors) {
67
111
  const at = `evidence.proposalSet.proposals[${index}]`;
68
112
  if (!isObject(entry)) {
69
113
  errors.push(`${at} must be an object`);
70
114
  return;
71
115
  }
72
- for (const field of ['proposalId', 'claimKey', 'family']) {
116
+ for (const field of ['proposalId', 'claimKey']) {
73
117
  if (!isText(entry[field]))
74
118
  errors.push(`${at}.${field} must be a non-empty string`);
75
119
  }
@@ -101,32 +145,57 @@ function validateEntry(entry, index, errors) {
101
145
  if (!exports.PROPOSAL_CHANGE_TYPES.includes(entry.changeType)) {
102
146
  errors.push(`${at}.changeType must be one of ${exports.PROPOSAL_CHANGE_TYPES.join(', ')}`);
103
147
  }
104
- if (!Number.isInteger(entry.recurrences) || entry.recurrences < 0) {
105
- errors.push(`${at}.recurrences must be a non-negative integer`);
148
+ if (!exports.PROPOSAL_ARTIFACT_TYPES.includes(entry.type)) {
149
+ errors.push(`${at}.type must be one of ${exports.PROPOSAL_ARTIFACT_TYPES.join(', ')}, as defined in ` +
150
+ 'registry/rules/fraim/artifact-boundaries.md; the row names what kind of artifact changes');
106
151
  }
107
152
  for (const field of FORBIDDEN_PROPOSAL_FIELDS) {
108
153
  if (entry[field] !== undefined) {
109
154
  errors.push(`${at}.${field} is audit metadata and must not be sent to the review surface`);
110
155
  }
111
156
  }
112
- if (entry.reopening !== undefined) {
113
- const r = entry.reopening;
114
- const ra = `${at}.reopening`;
115
- if (!isObject(r)) {
116
- errors.push(`${ra} must be an object`);
117
- }
118
- else {
119
- if (!isText(r.rejectedAt))
120
- errors.push(`${ra}.rejectedAt must be a non-empty string`);
121
- if (!isText(r.reason)) {
122
- errors.push(`${ra}.reason must be a non-empty string; a reopening quotes the original decision`);
123
- }
124
- if (!Number.isInteger(r.countAtRejection) || r.countAtRejection < 0) {
125
- errors.push(`${ra}.countAtRejection must be a non-negative integer`);
126
- }
157
+ // Issue #2079. `type` decides which row this is, so each kind's fields are
158
+ // checked on its own terms instead of a capability change having to satisfy
159
+ // a Learning's.
160
+ if (entry.type === exports.LEARNING_ARTIFACT_TYPE)
161
+ validateLearningEntry(entry, at, errors);
162
+ else if (exports.PROPOSAL_ARTIFACT_TYPES.includes(entry.type))
163
+ validateCapabilityEntry(entry, at, errors);
164
+ }
165
+ /** A capability change: none of the evidence a claim accumulates. */
166
+ function validateCapabilityEntry(entry, at, errors) {
167
+ for (const field of LEARNING_ONLY_FIELDS) {
168
+ if (entry[field] !== undefined) {
169
+ errors.push(`${at}.${field} belongs to a Learning row; a ${String(entry.type)} change has no ` +
170
+ 'occurrence history, learning family or subject-matter domain to weigh');
127
171
  }
128
172
  }
129
173
  }
174
+ /** A claim: its family, occurrence history, and the rejection it may return from. */
175
+ function validateLearningEntry(entry, at, errors) {
176
+ if (!isText(entry.family)) {
177
+ errors.push(`${at}.family must be a non-empty string on a Learning row; it names the family file the entry lands in`);
178
+ }
179
+ if (!Number.isInteger(entry.recurrences) || entry.recurrences < 0) {
180
+ errors.push(`${at}.recurrences must be a non-negative integer`);
181
+ }
182
+ if (entry.reopening === undefined)
183
+ return;
184
+ const r = entry.reopening;
185
+ const ra = `${at}.reopening`;
186
+ if (!isObject(r)) {
187
+ errors.push(`${ra} must be an object`);
188
+ return;
189
+ }
190
+ if (!isText(r.rejectedAt))
191
+ errors.push(`${ra}.rejectedAt must be a non-empty string`);
192
+ if (!isText(r.reason)) {
193
+ errors.push(`${ra}.reason must be a non-empty string; a reopening quotes the original decision`);
194
+ }
195
+ if (!Number.isInteger(r.countAtRejection) || r.countAtRejection < 0) {
196
+ errors.push(`${ra}.countAtRejection must be a non-negative integer`);
197
+ }
198
+ }
130
199
  function validateSuppressed(entry, index, errors) {
131
200
  const at = `evidence.proposalSet.suppressed[${index}]`;
132
201
  if (!isObject(entry)) {
@@ -171,10 +240,25 @@ function validateSuppressed(entry, index, errors) {
171
240
  *
172
241
  * Applied on the Hub's own read rather than trusted from the agent: the order the
173
242
  * manager sees should not depend on how a model happened to sort its output.
243
+ *
244
+ * Issue #2079 adds the partition above that. A capability change has no
245
+ * occurrence count, so ranking it against a Learning by one is the same category
246
+ * error the row made: `b.recurrences - a.recurrences` on a missing count is NaN,
247
+ * which is falsy, so the comparison fell straight through to the id tie-break
248
+ * and a permanent job edit landed wherever its id happened to put it.
249
+ *
250
+ * Capability rows lead as their own group, because a change to how the employee
251
+ * works from now on is decided before a batch of remembered lessons. Inside that
252
+ * group there is nothing to rank by and no claim that there is: every evidence
253
+ * comparison is zero and the order falls to `proposalId`, which is stable rather
254
+ * than meaningful. Learning rows keep the evidence ordering above.
174
255
  */
175
256
  function rankProposals(proposals) {
176
- const growth = (p) => typeof p.recurrencesAtLastReview === 'number' ? p.recurrences - p.recurrencesAtLastReview : 0;
177
- return [...proposals].sort((a, b) => b.recurrences - a.recurrences
257
+ const group = (p) => (p.type === exports.LEARNING_ARTIFACT_TYPE ? 1 : 0);
258
+ const count = (p) => (typeof p.recurrences === 'number' ? p.recurrences : 0);
259
+ const growth = (p) => typeof p.recurrencesAtLastReview === 'number' ? count(p) - p.recurrencesAtLastReview : 0;
260
+ return [...proposals].sort((a, b) => group(a) - group(b)
261
+ || count(b) - count(a)
178
262
  || growth(b) - growth(a)
179
263
  || a.proposalId.localeCompare(b.proposalId));
180
264
  }
@@ -9,11 +9,11 @@ function createPortConflictError(port) {
9
9
  return error;
10
10
  }
11
11
  async function startListening(app, port, onListening) {
12
- await new Promise((resolve, reject) => {
12
+ return new Promise((resolve, reject) => {
13
13
  const server = app.listen(port);
14
14
  server.once('listening', () => {
15
15
  onListening();
16
- resolve();
16
+ resolve(server);
17
17
  });
18
18
  server.once('error', (error) => {
19
19
  if (error?.code === 'EADDRINUSE') {
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sameLicenseScope = sameLicenseScope;
4
+ exports.hasUsableLicense = hasUsableLicense;
5
+ exports.effectiveLicenseExpiry = effectiveLicenseExpiry;
6
+ exports.subscriptionAccessPatch = subscriptionAccessPatch;
7
+ exports.selectCurrentLicense = selectCurrentLicense;
8
+ function sameLicenseScope(a, b) {
9
+ return a.userId === b.userId && a.orgId === b.orgId
10
+ && (a.workspaceId || null) === (b.workspaceId || null);
11
+ }
12
+ function hasUsableLicense(key, now = Date.now()) {
13
+ const expiry = effectiveLicenseExpiry(key);
14
+ return key.isActive !== false && !key.revokedAt
15
+ && (key.status === 'active' || (!key.status && key.isActive === true))
16
+ && (expiry === null || expiry.getTime() > now);
17
+ }
18
+ /** No automatic grace. Explicit support grants are separate from payment proof. */
19
+ function effectiveLicenseExpiry(key) {
20
+ if (!key.stripeSubscriptionId)
21
+ return key.expiresAt ? new Date(key.expiresAt) : null;
22
+ if (key.manualAccessOverride?.until === null)
23
+ return null;
24
+ let paid = key.paidThrough ? new Date(key.paidThrough).getTime() : 0;
25
+ if (key.stripeSubscriptionEndedAt)
26
+ paid = Math.min(paid, new Date(key.stripeSubscriptionEndedAt).getTime());
27
+ // A finite pre-migration deadline is retained until reconciliation classifies it.
28
+ const legacy = !key.billingReconciledAt && key.expiresAt ? new Date(key.expiresAt).getTime() : 0;
29
+ const manual = key.manualAccessOverride?.until ? new Date(key.manualAccessOverride.until).getTime() : 0;
30
+ return new Date(Math.max(paid, manual, legacy));
31
+ }
32
+ function subscriptionAccessPatch(key, now = new Date()) {
33
+ const expiresAt = effectiveLicenseExpiry(key);
34
+ const usable = expiresAt === null || expiresAt.getTime() > now.getTime();
35
+ const failed = ['past_due', 'unpaid', 'incomplete', 'paused'].includes(key.subscriptionStatus || '');
36
+ return {
37
+ expiresAt, status: usable ? 'active' : failed ? 'suspended' : 'expired',
38
+ suspendedAt: !usable && failed ? key.suspendedAt || now : null,
39
+ suspensionReason: !usable && failed ? 'payment_failed' : null
40
+ };
41
+ }
42
+ /** Paid lifecycle takes precedence over trials, including after suspension or cancellation. */
43
+ function selectCurrentLicense(records, activeOnly = false) {
44
+ const now = Date.now();
45
+ const selected = records.filter(record => record.isActive !== false && !record.revokedAt).sort((a, b) => Number(b.tier === 'paid-subscription') - Number(a.tier === 'paid-subscription')
46
+ || Number(hasUsableLicense(b, now)) - Number(hasUsableLicense(a, now))
47
+ || Number(a.accessScope === 'persona') - Number(b.accessScope === 'persona')
48
+ || (new Date(b.createdAt).getTime() || 0) - (new Date(a.createdAt).getTime() || 0)
49
+ || a.key.localeCompare(b.key))[0] || null;
50
+ return selected && (!activeOnly || hasUsableLicense(selected, now)) ? selected : null;
51
+ }