fraim 2.0.317 → 2.0.319

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.
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -7,9 +40,6 @@ exports.firstRunCommand = exports.runFirstRun = void 0;
7
40
  const commander_1 = require("commander");
8
41
  const chalk_1 = __importDefault(require("chalk"));
9
42
  const child_process_1 = require("child_process");
10
- const server_1 = require("../../first-run/server");
11
- const session_service_1 = require("../../first-run/session-service");
12
- const ports_1 = require("../../core/utils/ports");
13
43
  function openBrowser(url) {
14
44
  try {
15
45
  if (process.platform === 'win32') {
@@ -33,17 +63,22 @@ function openBrowser(url) {
33
63
  }
34
64
  }
35
65
  const runFirstRun = async (options) => {
66
+ const [{ FirstRunServer }, { FirstRunSessionService }, { findAvailablePort }] = await Promise.all([
67
+ Promise.resolve().then(() => __importStar(require('../../first-run/server'))),
68
+ Promise.resolve().then(() => __importStar(require('../../first-run/session-service'))),
69
+ Promise.resolve().then(() => __importStar(require('../../core/utils/ports'))),
70
+ ]);
36
71
  // Issue #646: the key is optional. When launched by the no-terminal macOS
37
72
  // installer (.pkg), no key is passed — the wizard prompts the user to paste it.
38
73
  const key = options.key || process.env.FRAIM_API_KEY || process.env.FRAIM_SETUP_KEY || process.env.FRAIM_INSTALL_KEY || '';
39
- const sessionService = new session_service_1.FirstRunSessionService({
74
+ const sessionService = new FirstRunSessionService({
40
75
  key,
41
76
  headless: options.headless,
42
77
  resume: options.resume,
43
78
  projectRoot: options.projectRoot,
44
79
  });
45
- const server = new server_1.FirstRunServer({ sessionService });
46
- const port = await (0, ports_1.findAvailablePort)(43120);
80
+ const server = new FirstRunServer({ sessionService });
81
+ const port = await findAvailablePort(43120);
47
82
  const url = `http://127.0.0.1:${port}/first-run/`;
48
83
  await server.start(port);
49
84
  console.log(chalk_1.default.blue('Starting FRAIM first-run...'));
@@ -52,6 +52,7 @@ const fs_1 = __importDefault(require("fs"));
52
52
  const path_1 = __importDefault(require("path"));
53
53
  const os_1 = __importDefault(require("os"));
54
54
  const child_process_1 = require("child_process");
55
+ const win32_cmd_spawn_1 = require("../../utils/win32-cmd-spawn");
55
56
  const axios_1 = __importDefault(require("axios"));
56
57
  const toml = __importStar(require("toml"));
57
58
  const ide_detector_1 = require("../../setup/ide-detector");
@@ -673,23 +674,9 @@ async function spawnAndHandshake(command, args, timeoutMs = STDIO_HANDSHAKE_TIME
673
674
  env: process.env
674
675
  };
675
676
  if (process.platform === 'win32') {
676
- // On Windows, wrap in cmd.exe so .cmd wrappers are resolved.
677
- // cmd.exe /d /s /c requires special handling when the command path contains spaces:
678
- // cmd /c ""path with spaces" arg1 arg2"
679
- // The outer pair of double-quotes is mandatory when the first token is quoted.
680
- // We must also pass windowsVerbatimArguments: true so Node's CreateProcess call
681
- // does not re-escape our already-quoted command string.
682
- const comSpec = process.env.ComSpec || 'cmd.exe';
683
- const quotedTokens = [command, ...args].map((a) => {
684
- const v = String(a);
685
- return /[\s"&|<>^]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
686
- });
687
- const innerCmd = quotedTokens.join(' ');
688
- // Wrap in outer quotes only when the command contains spaces (i.e. was itself quoted).
689
- const needsOuterQuote = /[\s"&|<>^]/.test(String(command));
690
- const cmdString = needsOuterQuote ? `"${innerCmd}"` : innerCmd;
691
- spawnCommand = comSpec;
692
- spawnArgs = ['/d', '/s', '/c', cmdString];
677
+ const built = (0, win32_cmd_spawn_1.buildCmdExeArgs)(command, args.map(String));
678
+ spawnCommand = built.command;
679
+ spawnArgs = built.args;
693
680
  spawnOptions = { ...spawnOptions, windowsVerbatimArguments: true };
694
681
  }
695
682
  else {
@@ -138,6 +138,16 @@ function runtimeFromEnv(env = process.env) {
138
138
  ? { executable: env.FRAIM_PACKAGED_CLI_EXECUTABLE, script: env.FRAIM_PACKAGED_CLI_SCRIPT }
139
139
  : null;
140
140
  }
141
+ // Regular Node.js (and ELECTRON_RUN_AS_NODE) cannot see inside Electron's
142
+ // .asar archives. For paths like …/app.asar/node_modules/…, verify the
143
+ // archive file itself rather than the internal entry.
144
+ function existsOrAsarExists(filePath) {
145
+ const normalized = filePath.replace(/\\/g, '/');
146
+ const asarSep = normalized.indexOf('.asar/');
147
+ if (asarSep === -1)
148
+ return fs_1.default.existsSync(filePath);
149
+ return fs_1.default.existsSync(filePath.slice(0, asarSep + '.asar'.length));
150
+ }
141
151
  function getPackagedFraimRuntime(env = process.env) {
142
152
  const envRuntime = runtimeFromEnv(env);
143
153
  if (envRuntime)
@@ -146,7 +156,7 @@ function getPackagedFraimRuntime(env = process.env) {
146
156
  const raw = JSON.parse(fs_1.default.readFileSync(getPackagedFraimRuntimePath(), 'utf8'));
147
157
  if (!raw.executable || !raw.script)
148
158
  return null;
149
- if (!fs_1.default.existsSync(raw.executable) || !fs_1.default.existsSync(raw.script))
159
+ if (!fs_1.default.existsSync(raw.executable) || !existsOrAsarExists(raw.script))
150
160
  return null;
151
161
  return { executable: raw.executable, script: raw.script };
152
162
  }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spawnViaCmdExe = spawnViaCmdExe;
4
+ exports.buildCmdExeArgs = buildCmdExeArgs;
5
+ const child_process_1 = require("child_process");
6
+ function quoteCmdToken(s) {
7
+ return /[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
8
+ }
9
+ /**
10
+ * Spawn a command through cmd.exe on Windows so .cmd shims resolve correctly.
11
+ * Applies outer-quote wrapping and windowsVerbatimArguments:true so paths
12
+ * containing spaces are not double-escaped by Node's CreateProcess call.
13
+ */
14
+ function spawnViaCmdExe(command, args, options) {
15
+ const comSpec = process.env.ComSpec || 'cmd.exe';
16
+ const tokens = [command, ...args].map(quoteCmdToken);
17
+ const innerCmd = tokens.join(' ');
18
+ const needsOuterQuote = /[\s"&|<>^]/.test(command);
19
+ const cmdString = needsOuterQuote ? `"${innerCmd}"` : innerCmd;
20
+ return (0, child_process_1.spawnSync)(comSpec, ['/d', '/s', '/c', cmdString], {
21
+ ...options,
22
+ windowsVerbatimArguments: true,
23
+ });
24
+ }
25
+ /**
26
+ * Build cmd.exe args for async spawn callers (e.g. mcp-connectivity-checks).
27
+ * Returns the command, args, and a verbatim flag to set windowsVerbatimArguments: true.
28
+ */
29
+ function buildCmdExeArgs(command, args) {
30
+ const tokens = [command, ...args].map(quoteCmdToken);
31
+ const innerCmd = tokens.join(' ');
32
+ const needsOuterQuote = /[\s"&|<>^]/.test(command);
33
+ return {
34
+ command: process.env.ComSpec || 'cmd.exe',
35
+ args: ['/d', '/s', '/c', needsOuterQuote ? `"${innerCmd}"` : innerCmd],
36
+ verbatim: true,
37
+ };
38
+ }
@@ -72,7 +72,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
72
72
  personaKey: 'qasm',
73
73
  bundleId: 'persona-qasm-core',
74
74
  catalogMetadata: buildCatalogMetadata('qasm', ['test-authoring', 'iterative-quality-improvement', 'ui-polish-validation']),
75
- protectedJobs: ['test-authoring', 'ui-polish-validation', 'codebase-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'performance-benchmarking'],
75
+ protectedJobs: ['test-authoring', 'ui-polish-validation', 'codebase-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'stress-test-app-under-load', 'performance-quality-assessment'],
76
76
  protectedAliases: ['qa', 'quality-assurance'],
77
77
  defaultHireMode: 'job',
78
78
  lockCopy: 'Hire QAsm to unlock QA validation for this request.'
@@ -26,7 +26,8 @@ class AIMentor {
26
26
  const phases = workflow.metadata.phases || {};
27
27
  const hasMetadata = !!phases[args.currentPhase];
28
28
  const hasMarkdown = workflow.phases.has(args.currentPhase);
29
- if (!hasMetadata && !hasMarkdown && args.currentPhase !== 'starting') {
29
+ const isRecurrenceMember = !!(0, resolve_phase_edge_1.findRecurrenceForMember)(phases, args.currentPhase);
30
+ if (!hasMetadata && !hasMarkdown && !isRecurrenceMember && args.currentPhase !== 'starting') {
30
31
  throw new Error(`Phase "${args.currentPhase}" not found in job "${args.jobName}".`);
31
32
  }
32
33
  }
@@ -133,7 +134,11 @@ class AIMentor {
133
134
  };
134
135
  }
135
136
  const isVeryFirstCall = phaseId === 'starting';
136
- const targetPhase = isVeryFirstCall ? (workflow.metadata.initialPhase || 'starting') : phaseId;
137
+ let targetPhase = isVeryFirstCall ? (workflow.metadata.initialPhase || 'starting') : phaseId;
138
+ const targetDefinition = workflow.metadata.phases?.[targetPhase];
139
+ if ((0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)) {
140
+ targetPhase = isVeryFirstCall ? targetDefinition.setup.phase : targetDefinition.recur.phase;
141
+ }
137
142
  let message = `### Current Phase: ${targetPhase}\n\n`;
138
143
  let instructions = workflow.phases.get(targetPhase);
139
144
  if (instructions) {
@@ -143,7 +148,10 @@ class AIMentor {
143
148
  else {
144
149
  message += `No specific instructions found for phase: ${targetPhase}`;
145
150
  }
146
- const phaseFlow = workflow.metadata.phases?.[targetPhase];
151
+ const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, targetPhase);
152
+ const phaseFlow = recurrenceMember
153
+ ? { onSuccess: undefined, onFailure: recurrenceMember.node[recurrenceMember.role].onFailure }
154
+ : ((0, resolve_phase_edge_1.isRecurrencePhase)(workflow.metadata.phases?.[targetPhase]) ? undefined : workflow.metadata.phases?.[targetPhase]);
147
155
  message += this.buildReportBackFooter(workflow.metadata.name, targetPhase, phaseFlow);
148
156
  if (!skipIncludes) {
149
157
  this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhase} (starting)`);
@@ -165,13 +173,43 @@ class AIMentor {
165
173
  status: 'complete'
166
174
  };
167
175
  }
168
- const phaseFlow = workflow.metadata.phases?.[phaseId];
176
+ const phases = workflow.metadata.phases;
177
+ const phaseFlow = phases?.[phaseId];
178
+ const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(phases, phaseId);
169
179
  let nextPhaseId = null;
170
- if (phaseFlow && phaseFlow.onSuccess) {
180
+ if (recurrenceMember?.role === 'setup') {
181
+ nextPhaseId = resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION;
182
+ }
183
+ else if (recurrenceMember?.role === 'recur') {
184
+ const selectedAction = evidence?.selectedReviewAction;
185
+ if (!selectedAction) {
186
+ nextPhaseId = evidence?.reviewHandoff?.reviewRequired === true
187
+ ? resolve_phase_edge_1.AWAITING_RECURRING_REVIEW
188
+ : recurrenceMember.node.recur.phase;
189
+ }
190
+ else if (selectedAction.kind === 'request_changes') {
191
+ nextPhaseId = recurrenceMember.node.recur.phase;
192
+ }
193
+ else if (selectedAction.kind === 'approve') {
194
+ const finalInvocation = (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence) === 'final-invocation';
195
+ nextPhaseId = finalInvocation
196
+ ? (evidence?.assignmentInactive === true
197
+ ? recurrenceMember.node.done.phase
198
+ : recurrenceMember.node.recur.phase)
199
+ : resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION;
200
+ }
201
+ else {
202
+ nextPhaseId = recurrenceMember.node.recur.phase;
203
+ }
204
+ }
205
+ else if (phaseFlow && !(0, resolve_phase_edge_1.isRecurrencePhase)(phaseFlow) && phaseFlow.onSuccess) {
171
206
  // Issue #1123: resolved through the shared authority so the success and
172
207
  // failure paths cannot read the discriminant differently.
173
208
  nextPhaseId = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseFlow.onSuccess, (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence));
174
209
  }
210
+ const nextDefinition = nextPhaseId ? phases?.[nextPhaseId] : undefined;
211
+ if ((0, resolve_phase_edge_1.isRecurrencePhase)(nextDefinition))
212
+ nextPhaseId = nextDefinition.setup.phase;
175
213
  let message = '';
176
214
  if (nextPhaseId === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION) {
177
215
  message += [
@@ -182,6 +220,13 @@ class AIMentor {
182
220
  '<!-- FRAIM_RECURRING_PARK -->',
183
221
  ].join('\n');
184
222
  }
223
+ else if (nextPhaseId === resolve_phase_edge_1.AWAITING_RECURRING_REVIEW) {
224
+ message += [
225
+ '### Recurring Result Awaiting Review',
226
+ '',
227
+ 'The current recurring result is waiting for manager review. Do not park or advance until an explicit review action is selected.',
228
+ ].join('\n');
229
+ }
185
230
  else if (nextPhaseId) {
186
231
  message += `Moving to the next phase: **${nextPhaseId}**.\n\n`;
187
232
  let nextInstructions = workflow.phases.get(nextPhaseId);
@@ -189,7 +234,10 @@ class AIMentor {
189
234
  nextInstructions = skipIncludes ? nextInstructions : await this.resolveIncludes(nextInstructions, workflow.path);
190
235
  message += nextInstructions;
191
236
  }
192
- const nextPhaseFlow = workflow.metadata.phases?.[nextPhaseId];
237
+ const nextRecurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(phases, nextPhaseId);
238
+ const nextPhaseFlow = nextRecurrenceMember
239
+ ? { onSuccess: undefined, onFailure: nextRecurrenceMember.node[nextRecurrenceMember.role].onFailure }
240
+ : ((0, resolve_phase_edge_1.isRecurrencePhase)(workflow.metadata.phases?.[nextPhaseId]) ? undefined : workflow.metadata.phases?.[nextPhaseId]);
193
241
  message += this.buildReportBackFooter(workflow.metadata.name, nextPhaseId, nextPhaseFlow);
194
242
  }
195
243
  else {
@@ -216,13 +264,15 @@ class AIMentor {
216
264
  };
217
265
  }
218
266
  const phaseMeta = workflow.metadata.phases?.[phaseId];
267
+ const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, phaseId);
219
268
  // Issue #1123: the failure edge may be a discriminant map. Resolving to
220
269
  // null (terminal, malformed, or a map with no `default` and no match)
221
270
  // falls back to self-retry, which is what the previous `|| phaseId` did
222
271
  // for an absent edge. The old expression could not do this: an object is
223
272
  // truthy, so it was returned as the target and `phases.get()` then missed.
224
273
  const targetPhaseId = status === 'failure'
225
- ? ((0, resolve_phase_edge_1.resolvePhaseEdge)(phaseMeta?.onFailure, (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence)) || phaseId)
274
+ ? ((0, resolve_phase_edge_1.resolvePhaseEdge)(recurrenceMember?.node[recurrenceMember.role].onFailure
275
+ ?? (!(0, resolve_phase_edge_1.isRecurrencePhase)(phaseMeta) ? phaseMeta?.onFailure : undefined), (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence)) || phaseId)
226
276
  : phaseId;
227
277
  let message = `### Current Phase: ${targetPhaseId}\n\n`;
228
278
  let instructions = workflow.phases.get(targetPhaseId);
@@ -129,6 +129,10 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
129
129
  const phaseDef = fm.phases[cursor];
130
130
  if (!phaseDef)
131
131
  break;
132
+ if ((0, resolve_phase_edge_1.isRecurrencePhase)(phaseDef)) {
133
+ ordered.splice(ordered.length - 1, 1, phaseDef.setup.phase, phaseDef.recur.phase, phaseDef.done.phase);
134
+ break;
135
+ }
132
136
  cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseDef.onSuccess, discriminant);
133
137
  }
134
138
  const labels = fm.phaseLabels || {};
@@ -143,7 +147,18 @@ function loadAllJobPhaseIds(jobId, projectPath) {
143
147
  const phases = loadJobPhasesFromSteps(stubPath);
144
148
  return new Set(phases.map((p) => p.id));
145
149
  }
146
- return new Set(Object.keys(fm.phases));
150
+ const ids = new Set();
151
+ for (const [phaseId, phase] of Object.entries(fm.phases)) {
152
+ if ((0, resolve_phase_edge_1.isRecurrencePhase)(phase)) {
153
+ ids.add(phase.setup.phase);
154
+ ids.add(phase.recur.phase);
155
+ ids.add(phase.done.phase);
156
+ }
157
+ else {
158
+ ids.add(phaseId);
159
+ }
160
+ }
161
+ return ids;
147
162
  }
148
163
  function labelForPhaseId(phaseId, jobId, projectPath) {
149
164
  const stubPath = findJobStubPath(projectPath, jobId);
@@ -43,6 +43,8 @@ exports.QUALITY_REGISTRY = {
43
43
  'ui-quality-assessment': { stage: 'ui-ux-quality', enforced: true, telemetryKind: 'score' },
44
44
  // Test Quality
45
45
  'test-quality-assessment': { stage: 'test-quality', enforced: true, telemetryKind: 'score' },
46
+ // Performance Quality
47
+ 'performance-quality-assessment': { stage: 'performance-quality', enforced: true, telemetryKind: 'score' },
46
48
  // Security
47
49
  'security-review': { stage: 'security', enforced: true, telemetryKind: 'score' },
48
50
  'production-readiness-review': { stage: 'production-readiness', enforced: true, telemetryKind: 'score' },
@@ -80,6 +82,7 @@ exports.STAGE_DISPLAY_NAMES = {
80
82
  'product-quality': 'Product Quality',
81
83
  'ui-ux-quality': 'UI/UX Quality',
82
84
  'test-quality': 'Test Quality',
85
+ 'performance-quality': 'Performance Quality',
83
86
  'security': 'Security',
84
87
  'production-readiness': 'Production Readiness',
85
88
  'fundraising': 'Fundraising',
@@ -95,6 +98,7 @@ exports.ALL_STAGE_CATEGORIES = [
95
98
  'product-quality',
96
99
  'ui-ux-quality',
97
100
  'test-quality',
101
+ 'performance-quality',
98
102
  'security',
99
103
  'production-readiness',
100
104
  'fundraising',
@@ -169,6 +173,12 @@ const QUALITY_SCORE_DIMENSIONS = {
169
173
  'testDesign',
170
174
  'reliability'
171
175
  ],
176
+ 'performance-quality-assessment': [
177
+ 'memoryEfficiency',
178
+ 'cpuEfficiency',
179
+ 'diskIO',
180
+ 'startupCost'
181
+ ],
172
182
  'security-review': [
173
183
  'findingSeverity',
174
184
  'remediationReadiness',
@@ -15,15 +15,33 @@
15
15
  * change inert for every job that authors no map.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.FEEDBACK_PHASE_ID = exports.WAITING_FOR_NEXT_INVOCATION = void 0;
18
+ exports.FEEDBACK_PHASE_ID = exports.AWAITING_RECURRING_REVIEW = exports.WAITING_FOR_NEXT_INVOCATION = void 0;
19
+ exports.isRecurrencePhase = isRecurrencePhase;
20
+ exports.findRecurrenceForMember = findRecurrenceForMember;
19
21
  exports.resolvePhaseEdge = resolvePhaseEdge;
20
22
  exports.resolveDiscriminant = resolveDiscriminant;
21
23
  exports.discriminantKeys = discriminantKeys;
22
24
  exports.derivePredecessorPhase = derivePredecessorPhase;
25
+ function isRecurrencePhase(phase) {
26
+ return !!phase && 'type' in phase && phase.type === 'recurrence';
27
+ }
28
+ function findRecurrenceForMember(phases, phaseId) {
29
+ for (const [nodeId, phase] of Object.entries(phases ?? {})) {
30
+ if (!isRecurrencePhase(phase))
31
+ continue;
32
+ for (const role of ['setup', 'recur', 'done']) {
33
+ if (phase[role].phase === phaseId)
34
+ return { nodeId, node: phase, role };
35
+ }
36
+ }
37
+ return null;
38
+ }
23
39
  /** The default discriminant, and the mandatory key on every authored map. */
24
40
  const DEFAULT_DISCRIMINANT = 'default';
25
41
  /** Reserved transition target that parks a recurring job until the scheduler invokes it again. */
26
42
  exports.WAITING_FOR_NEXT_INVOCATION = 'waiting-for-next-invocation';
43
+ /** Reserved controller target while a recurring result awaits manager review. */
44
+ exports.AWAITING_RECURRING_REVIEW = 'awaiting-recurring-review';
27
45
  /**
28
46
  * Resolve an edge to the next phase id.
29
47
  *
@@ -97,7 +115,7 @@ exports.FEEDBACK_PHASE_ID = 'address-feedback';
97
115
  function derivePredecessorPhase(phases, targetPhaseId) {
98
116
  const predecessors = [];
99
117
  for (const [phaseId, edges] of Object.entries(phases ?? {})) {
100
- if (phaseId === targetPhaseId || !edges)
118
+ if (phaseId === targetPhaseId || !edges || isRecurrencePhase(edges))
101
119
  continue;
102
120
  const edge = edges.onSuccess;
103
121
  const targets = typeof edge === 'string'
@@ -135,13 +135,19 @@ function persistShellPath(includePackagedFraimBin = false) {
135
135
  // Versioned dir first (has fresh installs, matches getManagedAgentBinDirs()
136
136
  // order), flat dir second (retains bare POSIX agent scripts as fallback).
137
137
  exportLine = portableNodeBin !== nodeRoot
138
- ? `export PATH=”$PATH:$HOME/.fraim/node/${path_1.default.basename(portableNodeBin)}:$HOME/.fraim/node${fraimBinEntry}”`
139
- : `export PATH=”$PATH:$HOME/.fraim/node${fraimBinEntry}”`;
138
+ ? `export PATH="$PATH:$HOME/.fraim/node/${path_1.default.basename(portableNodeBin)}:$HOME/.fraim/node${fraimBinEntry}"`
139
+ : `export PATH="$PATH:$HOME/.fraim/node${fraimBinEntry}"`;
140
140
  }
141
141
  else {
142
142
  exportLine = includePackagedFraimBin
143
- ? 'export PATH=”$PATH:$HOME/.fraim/bin:$HOME/.fraim/node/bin'
144
- : 'export PATH=”$PATH:$HOME/.fraim/node/bin';
143
+ ? 'export PATH="$PATH:$HOME/.fraim/bin:$HOME/.fraim/node/bin"'
144
+ : 'export PATH="$PATH:$HOME/.fraim/node/bin"';
145
+ }
146
+ // Guard against editor-autocorrect curly-quote regression (issue #1688):
147
+ // U+201C/201D/2018/2019 in exportLine would be written verbatim to shell
148
+ // profiles and break PATH resolution for all pre-existing PATH entries.
149
+ if (/[“”‘’]/.test(exportLine)) {
150
+ throw new Error(`persistShellPath: exportLine contains curly quotes. Writing this to a shell profile would corrupt PATH resolution. Check for editor autocorrect in session-service.ts. Line: ${JSON.stringify(exportLine)}`);
145
151
  }
146
152
  const stanza = `\n${marker}\n${exportLine}\n`;
147
153
  if (process.platform === 'win32') {
@@ -149,7 +155,16 @@ function persistShellPath(includePackagedFraimBin = false) {
149
155
  ? [path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'bin'), ...(0, managed_agent_paths_1.getManagedAgentBinDirs)()]
150
156
  : (0, managed_agent_paths_1.getManagedAgentBinDirs)();
151
157
  const psCmd = buildPersistShellPathWindowsCommand(bins);
152
- (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
158
+ // Issue #1692: a sandboxed test run (row detection on load, node row
159
+ // success, fraim row success all reach this function) must never write
160
+ // its temp fake-home bin dirs into the real, registry-backed Windows
161
+ // User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
162
+ // is a sandboxed test run" signal set by every test runner (see
163
+ // getUserFraimDirPath() in project-fraim-paths.ts) rather than a
164
+ // dedicated flag for this call site.
165
+ if (process.env.FRAIM_TEST_SANDBOX !== '1') {
166
+ (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
167
+ }
153
168
  // Fall through to write POSIX profile files for Git Bash (VS Code
154
169
  // integrated terminal, Claude Code, etc.) which do not inherit the
155
170
  // Windows user PATH written above.
@@ -190,7 +205,7 @@ function persistShellPath(includePackagedFraimBin = false) {
190
205
  if (!content.includes(exportLine)) {
191
206
  // Stale export line: versioned dir changed, includePackagedFraimBin
192
207
  // toggled, or platform-specific line was never written. Replace it.
193
- fs_1.default.writeFileSync(profile, content.replace(/export PATH=”[^”]*\.fraim[^”]*”/, exportLine), 'utf8');
208
+ fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
194
209
  }
195
210
  continue;
196
211
  }
@@ -545,11 +545,14 @@ function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
545
545
  * it cannot drift from the read path again.
546
546
  */
547
547
  function orgCacheDisplayPath(fileName) {
548
- const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
549
548
  const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
550
- const rel = cachePath.startsWith(fraimDir)
551
- ? cachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
552
- : cachePath.replace(/\\/g, '/');
549
+ return userFraimDisplayPathFor(cachePath);
550
+ }
551
+ function userFraimDisplayPathFor(absolutePath) {
552
+ const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
553
+ const rel = absolutePath.startsWith(fraimDir)
554
+ ? absolutePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
555
+ : absolutePath.replace(/\\/g, '/');
553
556
  return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
554
557
  }
555
558
  /**
@@ -944,7 +947,7 @@ function resolveOrgContextFile(workspaceRoot, relativePath, orgCacheEligible = t
944
947
  if ((0, fs_1.existsSync)(cachePath)) {
945
948
  return {
946
949
  present: true,
947
- displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`org/${relativePath}`)
950
+ displayPath: userFraimDisplayPathFor(cachePath)
948
951
  };
949
952
  }
950
953
  }
@@ -46,13 +46,37 @@ function buildEntryIndex(workspaceRoot, userId) {
46
46
  continue;
47
47
  }
48
48
  for (const entry of entries) {
49
+ const fileType = fileTypeOf(entry);
50
+ const attestable = {
51
+ key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
52
+ family: 'learning',
53
+ title: entry.title,
54
+ fileType,
55
+ level: entry.level,
56
+ };
49
57
  if (entry.id && !byId.has(entry.id))
50
- byId.set(entry.id, entry);
51
- const key = (0, learning_usage_store_1.normalizeEntryTitle)(entry.title);
52
- if (!byTitle.has(key))
53
- byTitle.set(key, entry);
58
+ byId.set(entry.id, attestable);
59
+ const titleKey = (0, learning_usage_store_1.normalizeEntryTitle)(entry.title);
60
+ if (!byTitle.has(titleKey))
61
+ byTitle.set(titleKey, attestable);
54
62
  }
55
63
  }
64
+ for (const rule of (0, learning_context_builder_1.collectOfferedRuleFiles)(workspaceRoot, true)) {
65
+ const attestable = {
66
+ key: rule.key,
67
+ family: 'rule',
68
+ title: rule.title,
69
+ fileType: rule.fileType,
70
+ level: rule.level,
71
+ };
72
+ if (!byId.has(rule.key))
73
+ byId.set(rule.key, attestable);
74
+ const titleKey = (0, learning_usage_store_1.normalizeEntryTitle)(rule.title);
75
+ if (!byTitle.has(titleKey))
76
+ byTitle.set(titleKey, attestable);
77
+ if (rule.id && !byId.has(rule.id))
78
+ byId.set(rule.id, attestable);
79
+ }
56
80
  return { byId, byTitle };
57
81
  }
58
82
  function fileTypeOf(entry) {
@@ -99,12 +123,11 @@ function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
99
123
  continue;
100
124
  }
101
125
  }
102
- const fileType = fileTypeOf(entry);
103
126
  inputs.push({
104
- key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
105
- family: 'learning',
127
+ key: entry.key,
128
+ family: entry.family,
106
129
  title: entry.title,
107
- fileType,
130
+ fileType: entry.fileType,
108
131
  level: entry.level,
109
132
  outcome: item.outcome,
110
133
  note: item.note,
@@ -347,7 +347,7 @@ Do not use this tool for other repositories or external project issue trackers.`
347
347
  },
348
348
  status: {
349
349
  type: 'string',
350
- description: 'Status of your work in the current phase',
350
+ description: 'Status of your work in the current phase.',
351
351
  enum: ['starting', 'complete', 'incomplete', 'failure']
352
352
  },
353
353
  findings: {
@@ -106,6 +106,7 @@ async function seedDemoDataForUser(dbService, userId) {
106
106
  { category: 'product-quality', jobName: 'codebase-quality-assessment' },
107
107
  { category: 'ui-ux-quality', jobName: 'ui-quality-assessment' },
108
108
  { category: 'test-quality', jobName: 'test-quality-assessment' },
109
+ { category: 'performance-quality', jobName: 'performance-quality-assessment' },
109
110
  { category: 'security', jobName: 'security-review' },
110
111
  { category: 'production-readiness', jobName: 'production-readiness-review' },
111
112
  { category: 'fundraising', jobName: 'fundraising-evidence-review' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.317",
3
+ "version": "2.0.319",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {