fraim 2.0.340 → 2.0.342

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.
@@ -57,9 +57,17 @@ function decodeCompletion(markdown, expected = {}) {
57
57
  fail();
58
58
  ids.add(item.actionId);
59
59
  const decision = item.decision;
60
- if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
60
+ // Issue #1857 R6 and D2: `basis`/`predecessorId` say who reached the decision,
61
+ // `kind` says whether a rejection refused the claim or was superseded by a
62
+ // rule. All three are optional so evidence written before this still decodes,
63
+ // and each is constrained here on the same terms `RunRecord.decide` uses.
64
+ if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'basis', 'predecessorId', 'kind', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
61
65
  || !text(decision.reviewer) || !text(decision.reason) || !text(decision.at) || !Number.isFinite(Date.parse(decision.at))
62
- || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition))))
66
+ || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition)))
67
+ || (decision.basis !== undefined && !['manager', 'inherited'].includes(decision.basis))
68
+ || (decision.basis === 'inherited' && !text(decision.predecessorId))
69
+ || (decision.predecessorId !== undefined && decision.basis !== 'inherited')
70
+ || (decision.kind !== undefined && (decision.action !== 'reject' || !['refused', 'superseded'].includes(decision.kind))))
63
71
  fail();
64
72
  if ('content' in item || 'specification' in item || 'application' in item || 'preimage' in item)
65
73
  fail();
@@ -69,7 +69,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
69
69
  Use this process only when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job. For ordinary requests, answer or work normally; do not scan FRAIM stubs first.
70
70
 
71
71
  2. **If the user did not specify a FRAIM job or topic after activation**:
72
- If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
72
+ If local FRAIM job stubs are present in the workspace, read that jobs directory's \`INDEX.md\` first (it lists every job's name, one-line intent, and category) and match the request against it; open an individual stub only to confirm a shortlisted candidate, or fall back to scanning the stubs directly if no \`INDEX.md\` is present. Also check \`fraim/personalized-employee/jobs/\` (and its own \`INDEX.md\`, if present) for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
75
  If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
@@ -72,7 +72,7 @@ This repository uses FRAIM.
72
72
  - Use FRAIM when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job.
73
73
  - For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.
74
74
  - If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly.
75
- - When FRAIM routing is active but the job is not exact, scan the job stubs under \`${employeeJobsPath}/\` and \`${managerJobsPath}/\` to identify the most appropriate job. Read stub filenames and their Intent/Outcome sections before using catalog tools.
75
+ - When FRAIM routing is active but the job is not exact, read \`${employeeJobsPath}/INDEX.md\` and \`${managerJobsPath}/INDEX.md\` first (each lists every job's name, one-line intent, and category) to identify the most appropriate job; open an individual job stub only to confirm a shortlisted candidate. If an \`INDEX.md\` is missing or looks stale, fall back to scanning the job stubs under those directories directly. Also check \`${personalizedRootPath}/jobs/INDEX.md\` (or its stubs, if no index is present) for local overrides or repo-specific jobs.
76
76
  - Once you identify the relevant job, call \`get_fraim_job({ job: "<job-name>" })\` to get the full phased instructions.
77
77
  - If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification.
78
78
  - For deeper capability detail, call \`get_fraim_file({ path: "skills/<category>/<skill-name>.md" })\` or \`get_fraim_file({ path: "rules/<category>/<rule-name>.md" })\`.
@@ -99,7 +99,7 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
99
99
  - Use FRAIM when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job.
100
100
  - For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.
101
101
  - If the user names an exact FRAIM job, fetch that full job directly with FRAIM MCP tools.
102
- - When FRAIM routing is active but the job is not exact, use local stubs to identify which job to invoke before fetching full content with FRAIM MCP tools.
102
+ - When FRAIM routing is active but the job is not exact, read the local jobs directory's \`INDEX.md\` first to identify which job to invoke (falling back to the individual stub files if no index is present) before fetching full content with FRAIM MCP tools.
103
103
  - If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification.
104
104
  - **Job stubs are for discovery only.** Never execute a job from stub content - always call \`get_fraim_job({ job: "<job-name>" })\` first.
105
105
  - ${ide_invocation_surfaces_1.FRAIM_MCP_UNAVAILABLE_MANAGER_GUIDANCE}
@@ -108,13 +108,13 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
108
108
 
109
109
  This directory is the repository-visible FRAIM surface.
110
110
 
111
- - \`ai-employee/jobs/\`: employee job stubs
112
- - \`ai-manager/jobs/\`: manager job stubs
113
- - \`ai-employee/skills/\`: skill stubs
111
+ - \`ai-employee/jobs/\`: employee job stubs (+ \`INDEX.md\`)
112
+ - \`ai-manager/jobs/\`: manager job stubs (+ \`INDEX.md\`)
113
+ - \`ai-employee/skills/\`: skill stubs (+ \`INDEX.md\`)
114
114
  - \`ai-employee/rules/\`: rule stubs
115
115
  - \`personalized-employee/\`: repo-specific overrides and learnings
116
116
 
117
- When FRAIM routing is active and no exact FRAIM job is named, use the stubs here to discover which FRAIM job, skill, or rule is relevant, then load the full content through FRAIM MCP tools. If an exact FRAIM job is named, load it directly. For ordinary requests, do not scan this catalog first.
117
+ When FRAIM routing is active and no exact FRAIM job is named, read the \`INDEX.md\` in the relevant directory first to discover which FRAIM job, skill, or rule is relevant (falling back to scanning the individual stubs if no index is present), then load the full content through FRAIM MCP tools. If an exact FRAIM job is named, load it directly. For ordinary requests, do not scan this catalog first.
118
118
  `;
119
119
  const vscodePrompt = `# FRAIM
120
120
 
@@ -23,6 +23,7 @@ const path_1 = require("path");
23
23
  const chalk_1 = __importDefault(require("chalk"));
24
24
  const script_sync_utils_1 = require("./script-sync-utils");
25
25
  const fraim_gitignore_1 = require("./fraim-gitignore");
26
+ const capability_index_1 = require("../../core/utils/capability-index");
26
27
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
27
28
  const LOCK_SYNCED_CONTENT_ENV = 'FRAIM_LOCK_SYNCED_CONTENT';
28
29
  exports.SYNCED_CONTENT_BANNER_MARKER = '<!-- FRAIM_SYNC_MANAGED_CONTENT -->';
@@ -268,6 +269,7 @@ async function syncRoleJobsToUserDir(roleFiles, role) {
268
269
  }
269
270
  (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
270
271
  }
272
+ (0, capability_index_1.writeCapabilityIndex)(targetDir, 'job');
271
273
  return roleFiles.length;
272
274
  }
273
275
  /**
@@ -353,6 +355,7 @@ async function syncFromRemote(options) {
353
355
  (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
354
356
  console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-employee/jobs/${relativePath}`)}`));
355
357
  }
358
+ (0, capability_index_1.writeCapabilityIndex)(employeeJobsDir, 'job');
356
359
  // Sync ai-manager job stubs to fraim/ai-manager/jobs/
357
360
  const managerJobsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-manager', 'jobs');
358
361
  cleanWorkspaceDirectory(options.projectRoot, managerJobsDir, 'manager job directory', assertWorkspacePath, 'ai-manager', 'jobs');
@@ -370,6 +373,7 @@ async function syncFromRemote(options) {
370
373
  (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
371
374
  console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-manager/jobs/${relativePath}`)}`));
372
375
  }
376
+ (0, capability_index_1.writeCapabilityIndex)(managerJobsDir, 'job');
373
377
  // Sync skill STUBS to fraim/ai-employee/skills/
374
378
  const skillFiles = files.filter(f => f.type === 'skill');
375
379
  const skillsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'skills');
@@ -387,6 +391,7 @@ async function syncFromRemote(options) {
387
391
  (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
388
392
  console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-employee/skills/${relativePath}`)} (stub)`));
389
393
  }
394
+ (0, capability_index_1.writeCapabilityIndex)(skillsDir, 'skill');
390
395
  // Sync rule STUBS to fraim/ai-employee/rules/
391
396
  const ruleFiles = files.filter(f => f.type === 'rule');
392
397
  const rulesDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'rules');
@@ -17,13 +17,15 @@
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;
21
20
  exports.isSubmitPhase = isSubmitPhase;
22
21
  exports.isRetrospectivePhase = isRetrospectivePhase;
23
22
  exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
24
23
  exports.isDelegationGraphPhase = isDelegationGraphPhase;
25
24
  exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
26
25
  exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
26
+ exports.recordPhaseStart = recordPhaseStart;
27
+ exports.inspectPhaseCompletion = inspectPhaseCompletion;
28
+ exports.commitPhaseCompletion = commitPhaseCompletion;
27
29
  exports.hasPhaseStarted = hasPhaseStarted;
28
30
  exports.upsertPhaseStarted = upsertPhaseStarted;
29
31
  exports.buildPhaseStartWarning = buildPhaseStartWarning;
@@ -176,11 +178,105 @@ function validateReviewActionCompleteness(reviewTarget, reviewActions, repo) {
176
178
  }
177
179
  return errors.length > 0 ? errors : null;
178
180
  }
181
+ function isPhaseStartInstance(value) {
182
+ if (!value || typeof value !== 'object' || Array.isArray(value))
183
+ return false;
184
+ const instance = value;
185
+ return typeof instance.instanceId === 'string'
186
+ && typeof instance.sessionId === 'string'
187
+ && typeof instance.jobName === 'string'
188
+ && typeof instance.phaseId === 'string'
189
+ && typeof instance.startedAt === 'string';
190
+ }
191
+ function phaseContextMatches(instance, context) {
192
+ if (instance.sessionId !== context.sessionId
193
+ || instance.jobName !== context.jobName
194
+ || instance.phaseId !== context.phaseId
195
+ || instance.resumeId !== context.resumeId) {
196
+ return false;
197
+ }
198
+ // A start without jobId is intentionally matchable to a later identified
199
+ // completion. When both calls identify a job, the identifiers must agree.
200
+ return !instance.jobId || instance.jobId === context.jobId;
201
+ }
202
+ function phaseInstancesFrom(marker) {
203
+ if (!marker || typeof marker !== 'object' || Array.isArray(marker))
204
+ return [];
205
+ const values = marker.phaseInstances;
206
+ return Array.isArray(values) ? values.filter(isPhaseStartInstance).map((value) => ({ ...value })) : [];
207
+ }
208
+ function nextPhaseInstanceId(instances, context) {
209
+ let ordinal = instances.length + 1;
210
+ const prefix = [context.sessionId, context.jobName, context.phaseId];
211
+ while (instances.some((instance) => instance.instanceId === JSON.stringify([...prefix, ordinal])))
212
+ ordinal++;
213
+ return JSON.stringify([...prefix, ordinal]);
214
+ }
215
+ /** Start a phase instance, refreshing an active start when instructions are requested again. */
216
+ function recordPhaseStart(marker, context) {
217
+ const instances = phaseInstancesFrom(marker);
218
+ // A repeated start refreshes the current instance; it must not leave an
219
+ // older unconsumed start available for a later re-entry.
220
+ const active = instances.find((instance) => !instance.completedAt
221
+ && phaseContextMatches(instance, context));
222
+ if (active) {
223
+ active.startedAt = context.timestamp;
224
+ if (context.jobId)
225
+ active.jobId = context.jobId;
226
+ return { phaseInstances: instances };
227
+ }
228
+ const instanceId = nextPhaseInstanceId(instances, context);
229
+ instances.push({
230
+ sessionId: context.sessionId,
231
+ jobName: context.jobName,
232
+ phaseId: context.phaseId,
233
+ ...(context.jobId ? { jobId: context.jobId } : {}),
234
+ ...(context.resumeId ? { resumeId: context.resumeId } : {}),
235
+ instanceId,
236
+ startedAt: context.timestamp,
237
+ });
238
+ return { phaseInstances: instances };
239
+ }
179
240
  /**
180
- * True when `marker` records a "starting" timestamp for `phaseId`. Fails
181
- * open (false, never throws) for a missing file (marker: undefined), a
182
- * missing key, or a malformed shape — this is a warn-only signal, not a
183
- * required artifact.
241
+ * Inspect a completion without mutating state. The caller must commit only
242
+ * after every other completion contract accepts the call, so rejected calls
243
+ * do not consume the valid starting instance.
244
+ */
245
+ function inspectPhaseCompletion(marker, context) {
246
+ const instances = phaseInstancesFrom(marker);
247
+ const candidates = instances.filter((instance) => !instance.completedAt && phaseContextMatches(instance, context));
248
+ if (candidates.length === 1)
249
+ return { missingStart: false, instanceId: candidates[0].instanceId };
250
+ return { missingStart: true };
251
+ }
252
+ /** Commit an accepted completion, consuming its matched start or recording a flagged synthetic instance. */
253
+ function commitPhaseCompletion(marker, inspection, context) {
254
+ const instances = phaseInstancesFrom(marker);
255
+ if (inspection.instanceId) {
256
+ const matched = instances.find((instance) => instance.instanceId === inspection.instanceId);
257
+ if (matched) {
258
+ matched.completedAt = context.timestamp;
259
+ if (context.jobId)
260
+ matched.jobId = context.jobId;
261
+ return { phaseInstances: instances };
262
+ }
263
+ }
264
+ instances.push({
265
+ sessionId: context.sessionId,
266
+ jobName: context.jobName,
267
+ phaseId: context.phaseId,
268
+ ...(context.jobId ? { jobId: context.jobId } : {}),
269
+ ...(context.resumeId ? { resumeId: context.resumeId } : {}),
270
+ instanceId: nextPhaseInstanceId(instances, context),
271
+ startedAt: context.timestamp,
272
+ completedAt: context.timestamp,
273
+ missingStart: true,
274
+ });
275
+ return { phaseInstances: instances };
276
+ }
277
+ /**
278
+ * Legacy marker helpers retained for consumers that still inspect the old
279
+ * address-feedback shape. New tracking uses the instance-aware helpers above.
184
280
  */
185
281
  function hasPhaseStarted(marker, phaseId) {
186
282
  if (!marker || typeof marker !== 'object' || Array.isArray(marker))
@@ -191,11 +287,6 @@ function hasPhaseStarted(marker, phaseId) {
191
287
  const value = startedPhases[phaseId];
192
288
  return typeof value === 'string' && value.length > 0;
193
289
  }
194
- /**
195
- * Returns a new marker with `phaseId` upserted to `timestamp`, preserving
196
- * every other phase already recorded. Tolerates a missing or malformed
197
- * existing marker by starting from an empty `startedPhases`.
198
- */
199
290
  function upsertPhaseStarted(marker, phaseId, timestamp) {
200
291
  const existing = marker && typeof marker === 'object' && !Array.isArray(marker)
201
292
  ? marker.startedPhases
@@ -206,18 +297,11 @@ function upsertPhaseStarted(marker, phaseId, timestamp) {
206
297
  startedPhases[phaseId] = timestamp;
207
298
  return { startedPhases };
208
299
  }
209
- /** The single phase this gate covers today (RFC: scoped, not universal — see Risk Assessment). */
210
- exports.PHASE_START_WARN_PHASES = ['address-feedback'];
211
- /**
212
- * Builds the warning prepended to an otherwise-valid address-feedback
213
- * response when this run never called seekMentoring(status: "starting") for
214
- * that phase. Warn-only, never a rejection: a lost marker (disk cleanup, a
215
- * first-ever run before this shipped) must not strand a legitimate resume.
216
- */
300
+ /** Builds the warn-only notice for any phase completed without an on-arrival start. */
217
301
  function buildPhaseStartWarning(phaseId) {
218
- return (`⚠️ **Phase-start notice**: this run did not call \`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` ` +
219
- `before this call. If you resumed after context compaction, re-read \`registry/delivery/address-feedback.md\` now — ` +
220
- `Step 4 (write the standalone feedback file) is easy to skip when acting from remembered context.\n\n`);
302
+ return (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
303
+ `\`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` call in this session. ` +
304
+ `The missing call was flagged, but this completion remains accepted.\n\n`);
221
305
  }
222
306
  function isAbsoluteHttpUrl(url) {
223
307
  if (typeof url !== 'string' || !url)
@@ -586,6 +670,23 @@ evidence: {
586
670
  }
587
671
  \`\`\``;
588
672
  const FIELD_SCHEMAS = {
673
+ proposalSet: [
674
+ '{',
675
+ ' "runScope": "manager" | "project" | "org",',
676
+ ' "proposals": [{',
677
+ ' "proposalId": "<id the journal records against>",',
678
+ ' "claimKey": "<claim identity>", "family": "<learning family>", "revision": <int>,',
679
+ ' "changeType": "new" | "update" | "cold",',
680
+ ' "title": "<reader title>", "takeaway": "<reader takeaway>", "example": "<optional>",',
681
+ ' "suggestedLevel": "manager" | "project" | "org", "recurrences": <int>,',
682
+ ' "recurrencesAtLastReview": <int|null>, "domain": "<domain|null>",',
683
+ ' "reopening": { "rejectedAt": "<date>", "reason": "<manager words>", "countAtRejection": <int> }',
684
+ ' }],',
685
+ ' "suppressed": [{ "claimKey": "...", "family": "...", "rejectedAt": "<date>",',
686
+ ' "kind": "refused" | "superseded", "reason": "...", "newSourceCount": <int> }]',
687
+ '}',
688
+ 'Trace, sourceRefs and scores are audit metadata and must not be sent.',
689
+ ].join(String.fromCharCode(10)),
589
690
  approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
590
691
  reviewHandoff: REVIEW_HANDOFF_SCHEMA,
591
692
  nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ /**
3
+ * Proposal Set Contract Validation (Issue #1857)
4
+ *
5
+ * The Hub renders the manager's learning review batch from this payload. Before
6
+ * this contract it rendered from prose: `tfParsePendingProposals` scanned an
7
+ * employee turn for a `## Pending Review` heading and `#### [P-LEVEL]` lines that
8
+ * no job or skill was ever instructed to emit, so the review surface #1876
9
+ * shipped rendered nothing for a real run.
10
+ *
11
+ * Two findings from the design spikes shape what this file checks.
12
+ *
13
+ * First, failing loudly is the behavior under test. A wire-shaped proposal whose
14
+ * field names did not match what the card reads rendered a card with its body
15
+ * silently missing and threw nothing. A contract that rejects the payload and
16
+ * names the field is recoverable; one that renders a degraded card is not.
17
+ *
18
+ * Second, the size caps are measured rather than chosen. Across 4,779 recorded
19
+ * `seekMentoring` calls in the raw host archives, the largest evidence object this
20
+ * system has ever carried is 6,044 bytes (median 506, p95 1,859). A proposal costs
21
+ * roughly 700 bytes once its 80-word reader budget and identity fields are counted,
22
+ * so a proposal set is the largest payload this channel will carry and the bound
23
+ * has to be explicit rather than discovered in production.
24
+ *
25
+ * Lives in src/core so the local MCP proxy, the Hub and the evals import the same
26
+ * validator, per architecture 4.2.1 — the same pattern as `handoff-contracts.ts`,
27
+ * `quality-evidence.ts` and `test-evidence-contract.ts`.
28
+ *
29
+ * Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring handler.
30
+ * The Hub keeps its own normalizer as well; the two do different jobs. This one
31
+ * tells the agent what to fix in one round. The normalizer protects the render
32
+ * from a payload that reached the Hub by some other path.
33
+ */
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;
36
+ exports.rankProposals = rankProposals;
37
+ exports.validateProposalSet = validateProposalSet;
38
+ /** Scopes a run can be asked for. Decides which corpus an approval lands in. */
39
+ exports.PROPOSAL_RUN_SCOPES = ['manager', 'project', 'org'];
40
+ /** How a claim came to be suppressed, so R7's report does not describe a
41
+ * claim the manager promoted to a rule as one they refused. */
42
+ exports.SUPPRESSION_KINDS = ['refused', 'superseded'];
43
+ /** What kind of change a proposal makes to the corpus. */
44
+ exports.PROPOSAL_CHANGE_TYPES = ['new', 'update', 'cold'];
45
+ /**
46
+ * Maximum proposals in one set. Derived from the measured envelope above, not
47
+ * chosen: at roughly 700 bytes per proposal this keeps a full set inside twice
48
+ * the largest evidence payload the channel has ever carried. Raising it should
49
+ * follow a re-measurement, which `test-1857-proposal-set-contract.ts` pins.
50
+ */
51
+ exports.MAX_PROPOSALS_PER_SET = 12;
52
+ /** Maximum suppressed entries reported in one batch. Each is a single line. */
53
+ exports.MAX_SUPPRESSED_PER_SET = 25;
54
+ /** Reader-field length bound. The authoring skill already budgets 80 words across
55
+ * title and the four reader values; this is the byte-level backstop. */
56
+ const MAX_READER_FIELD = 600;
57
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
58
+ const isText = (v) => typeof v === 'string' && v.trim().length > 0;
59
+ /**
60
+ * Fields that are audit metadata rather than review material. R10 is an invariant
61
+ * about influence, not storage: the manager's card is not a place for a score, a
62
+ * source path or an originating job to arrive by accident. Rejecting them at the
63
+ * boundary is cheaper than auditing every render site later.
64
+ */
65
+ const FORBIDDEN_PROPOSAL_FIELDS = ['trace', 'sourceRefs', 'sourceHashes', 'content', 'score'];
66
+ function validateEntry(entry, index, errors) {
67
+ const at = `evidence.proposalSet.proposals[${index}]`;
68
+ if (!isObject(entry)) {
69
+ errors.push(`${at} must be an object`);
70
+ return;
71
+ }
72
+ for (const field of ['proposalId', 'claimKey', 'family']) {
73
+ if (!isText(entry[field]))
74
+ errors.push(`${at}.${field} must be a non-empty string`);
75
+ }
76
+ if (!Number.isInteger(entry.revision) || entry.revision < 1) {
77
+ errors.push(`${at}.revision must be a positive integer`);
78
+ }
79
+ for (const field of ['title', 'takeaway']) {
80
+ if (!isText(entry[field])) {
81
+ errors.push(`${at}.${field} must be a non-empty string; the card renders it`);
82
+ }
83
+ else if (entry[field].length > MAX_READER_FIELD) {
84
+ errors.push(`${at}.${field} exceeds ${MAX_READER_FIELD} characters`);
85
+ }
86
+ }
87
+ if (entry.example !== undefined && typeof entry.example !== 'string') {
88
+ errors.push(`${at}.example must be a string when present`);
89
+ }
90
+ if (entry.suggestionReason !== undefined) {
91
+ if (typeof entry.suggestionReason !== 'string') {
92
+ errors.push(`${at}.suggestionReason must be a string when present`);
93
+ }
94
+ else if (entry.suggestionReason.length > MAX_READER_FIELD) {
95
+ errors.push(`${at}.suggestionReason exceeds ${MAX_READER_FIELD} characters`);
96
+ }
97
+ }
98
+ if (!exports.PROPOSAL_RUN_SCOPES.includes(entry.suggestedLevel)) {
99
+ errors.push(`${at}.suggestedLevel must be one of ${exports.PROPOSAL_RUN_SCOPES.join(', ')}`);
100
+ }
101
+ if (!exports.PROPOSAL_CHANGE_TYPES.includes(entry.changeType)) {
102
+ errors.push(`${at}.changeType must be one of ${exports.PROPOSAL_CHANGE_TYPES.join(', ')}`);
103
+ }
104
+ if (!Number.isInteger(entry.recurrences) || entry.recurrences < 0) {
105
+ errors.push(`${at}.recurrences must be a non-negative integer`);
106
+ }
107
+ for (const field of FORBIDDEN_PROPOSAL_FIELDS) {
108
+ if (entry[field] !== undefined) {
109
+ errors.push(`${at}.${field} is audit metadata and must not be sent to the review surface`);
110
+ }
111
+ }
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
+ }
127
+ }
128
+ }
129
+ }
130
+ function validateSuppressed(entry, index, errors) {
131
+ const at = `evidence.proposalSet.suppressed[${index}]`;
132
+ if (!isObject(entry)) {
133
+ errors.push(`${at} must be an object`);
134
+ return;
135
+ }
136
+ for (const field of ['claimKey', 'family', 'rejectedAt', 'reason']) {
137
+ if (!isText(entry[field]))
138
+ errors.push(`${at}.${field} must be a non-empty string`);
139
+ }
140
+ if (!exports.SUPPRESSION_KINDS.includes(entry.kind)) {
141
+ errors.push(`${at}.kind must be one of ${exports.SUPPRESSION_KINDS.join(', ')}`);
142
+ }
143
+ if (!Number.isInteger(entry.newSourceCount) || entry.newSourceCount < 0) {
144
+ errors.push(`${at}.newSourceCount must be a non-negative integer`);
145
+ }
146
+ }
147
+ /**
148
+ * Validates `evidence.proposalSet`. Returns null when valid or absent, or an array
149
+ * of messages each naming the exact field, so the agent fixes it in one round
150
+ * rather than guessing.
151
+ *
152
+ * Absent is valid: a review phase with nothing to review emits no set. An empty
153
+ * `proposals` array is also valid and means the same thing, which keeps a run that
154
+ * suppressed everything able to report its suppressions.
155
+ */
156
+ /**
157
+ * Order a batch by accumulated evidence strength (Issue #1857 R3).
158
+ *
159
+ * An undecided proposal is not a chore that expires; it comes back, and it comes
160
+ * back stronger or it does not. A flat list makes every row look equally
161
+ * deserving of the manager's attention, which is how a claim with eleven
162
+ * occurrences ends up below one with two because it happened to be authored
163
+ * later. Ordering is the whole reason "leave it for now" is a position rather
164
+ * than a deferral: the next batch shows the manager what changed.
165
+ *
166
+ * Recurrences is the primary key because it is the accumulated evidence itself.
167
+ * Growth since the last review breaks ties, so between two claims at the same
168
+ * count the one that is still happening leads. `proposalId` breaks the remainder,
169
+ * so the same batch always renders in the same order and a manager re-opening a
170
+ * run does not find the rows rearranged.
171
+ *
172
+ * Applied on the Hub's own read rather than trusted from the agent: the order the
173
+ * manager sees should not depend on how a model happened to sort its output.
174
+ */
175
+ 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
178
+ || growth(b) - growth(a)
179
+ || a.proposalId.localeCompare(b.proposalId));
180
+ }
181
+ function validateProposalSet(raw) {
182
+ if (raw === undefined || raw === null)
183
+ return null;
184
+ const errors = [];
185
+ if (!isObject(raw)) {
186
+ return ['evidence.proposalSet must be an object'];
187
+ }
188
+ if (!exports.PROPOSAL_RUN_SCOPES.includes(raw.runScope)) {
189
+ errors.push(`evidence.proposalSet.runScope must be one of ${exports.PROPOSAL_RUN_SCOPES.join(', ')}`);
190
+ }
191
+ if (!Array.isArray(raw.proposals)) {
192
+ errors.push('evidence.proposalSet.proposals must be an array');
193
+ }
194
+ else if (raw.proposals.length > exports.MAX_PROPOSALS_PER_SET) {
195
+ errors.push(`evidence.proposalSet.proposals holds ${raw.proposals.length} entries, past the ` +
196
+ `${exports.MAX_PROPOSALS_PER_SET} a single batch may carry; present the strongest first and ` +
197
+ 'report the remainder so the manager knows the batch was split');
198
+ }
199
+ else {
200
+ raw.proposals.forEach((entry, index) => validateEntry(entry, index, errors));
201
+ }
202
+ if (raw.suppressed !== undefined) {
203
+ if (!Array.isArray(raw.suppressed)) {
204
+ errors.push('evidence.proposalSet.suppressed must be an array when present');
205
+ }
206
+ else if (raw.suppressed.length > exports.MAX_SUPPRESSED_PER_SET) {
207
+ errors.push(`evidence.proposalSet.suppressed exceeds ${exports.MAX_SUPPRESSED_PER_SET} entries`);
208
+ }
209
+ else {
210
+ raw.suppressed.forEach((entry, index) => validateSuppressed(entry, index, errors));
211
+ }
212
+ }
213
+ return errors.length > 0 ? errors : null;
214
+ }
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeRunStateKey = safeRunStateKey;
4
+ const crypto_1 = require("crypto");
5
+ /** UUIDs retain their documented filenames; other IDs cannot become paths or device names. */
6
+ function safeRunStateKey(id) {
7
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
8
+ ? id : `id-${(0, crypto_1.createHash)('sha256').update(id).digest('hex')}`;
9
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CAPABILITY_INDEX_FILENAME = void 0;
7
+ exports.buildCapabilityIndexContent = buildCapabilityIndexContent;
8
+ exports.writeCapabilityIndex = writeCapabilityIndex;
9
+ exports.refreshPersonalizedJobsIndex = refreshPersonalizedJobsIndex;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const stub_generator_1 = require("./stub-generator");
13
+ exports.CAPABILITY_INDEX_FILENAME = 'INDEX.md';
14
+ const CAPABILITY_INDEX_MARKER = '<!-- FRAIM_DISCOVERY_INDEX -->';
15
+ const MAX_SUMMARY_LENGTH = 140;
16
+ function oneLine(text, maxLength = MAX_SUMMARY_LENGTH) {
17
+ const collapsed = text.replace(/\s+/g, ' ').trim();
18
+ if (collapsed.length <= maxLength)
19
+ return collapsed;
20
+ return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
21
+ }
22
+ function fetchHintFor(kind, entry) {
23
+ return kind === 'job'
24
+ ? `get_fraim_job({ job: "${entry.name}" })`
25
+ : `get_fraim_file({ path: "skills/${entry.category}/${entry.name}.md" })`;
26
+ }
27
+ /**
28
+ * Builds the compact index markdown for one stub layer (all job stubs, or all
29
+ * skill stubs, under a single jobs/ or skills/ root). Pure function so the
30
+ * content can be unit-tested without touching the filesystem.
31
+ */
32
+ function buildCapabilityIndexContent(kind, entries) {
33
+ const noun = kind === 'job' ? 'Job' : 'Skill';
34
+ const sorted = [...entries].sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
35
+ const categorized = new Map();
36
+ for (const entry of sorted) {
37
+ const list = categorized.get(entry.category) ?? [];
38
+ list.push(entry);
39
+ categorized.set(entry.category, list);
40
+ }
41
+ const lines = [
42
+ CAPABILITY_INDEX_MARKER,
43
+ `# FRAIM ${noun} Index`,
44
+ '',
45
+ `Compact discovery index for this layer's ${kind} stubs — read this file first instead of opening every individual stub.`,
46
+ ];
47
+ for (const [category, categoryEntries] of categorized) {
48
+ lines.push('', `## ${category}`);
49
+ for (const entry of categoryEntries) {
50
+ lines.push(`- **${entry.name}**: ${entry.summary} (${fetchHintFor(kind, entry)})`);
51
+ }
52
+ }
53
+ lines.push('', '---', '', '> [!IMPORTANT]', `> This index is for discovery only. Once you have a candidate ${kind}, fetch its full content before acting on it — never execute from the one-line summary above.`);
54
+ return `${lines.join('\n')}\n`;
55
+ }
56
+ function readCategoryEntries(kind, rootDir) {
57
+ const entries = [];
58
+ const categoryDirents = fs_1.default.existsSync(rootDir)
59
+ ? fs_1.default.readdirSync(rootDir, { withFileTypes: true }).filter((d) => d.isDirectory())
60
+ : [];
61
+ for (const categoryDirent of categoryDirents) {
62
+ const categoryDir = path_1.default.join(rootDir, categoryDirent.name);
63
+ const fileNames = fs_1.default
64
+ .readdirSync(categoryDir, { withFileTypes: true })
65
+ .filter((d) => d.isFile() && d.name.endsWith('.md'))
66
+ .map((d) => d.name);
67
+ for (const fileName of fileNames) {
68
+ const filePath = path_1.default.join(categoryDir, fileName);
69
+ const content = fs_1.default.readFileSync(filePath, 'utf8');
70
+ const name = fileName.replace(/\.md$/, '');
71
+ const summary = kind === 'job'
72
+ ? oneLine((0, stub_generator_1.parseRegistryJob)(content).intent)
73
+ : oneLine((0, stub_generator_1.parseRegistrySkill)(content).skillInput);
74
+ entries.push({ category: categoryDirent.name, name, summary });
75
+ }
76
+ }
77
+ return entries;
78
+ }
79
+ /**
80
+ * Scans a jobs/ or skills/ root (one file per {category}/{name}.md, mirroring
81
+ * the layout every stub layer already uses) and (re)writes a compact INDEX.md
82
+ * at that root. Safe to call on a root with no category subfolders yet (writes
83
+ * nothing, since there is nothing to index).
84
+ */
85
+ function writeCapabilityIndex(rootDir, kind) {
86
+ if (!fs_1.default.existsSync(rootDir))
87
+ return;
88
+ const entries = readCategoryEntries(kind, rootDir);
89
+ if (entries.length === 0)
90
+ return;
91
+ const indexPath = path_1.default.join(rootDir, exports.CAPABILITY_INDEX_FILENAME);
92
+ fs_1.default.writeFileSync(indexPath, buildCapabilityIndexContent(kind, entries), 'utf8');
93
+ }
94
+ /**
95
+ * Refreshes the job-stub index for the one layer `fraim sync` never writes:
96
+ * `fraim/personalized-employee/jobs/` and `.../manager-jobs/`. That layer is
97
+ * populated by agent writes (e.g. the evolve-employee job), not by sync, so
98
+ * there is no deterministic write path to hook — callers invoke this directly
99
+ * after writing or changing a personalized job overlay file.
100
+ */
101
+ function refreshPersonalizedJobsIndex(projectRoot) {
102
+ writeCapabilityIndex(path_1.default.join(projectRoot, 'fraim', 'personalized-employee', 'jobs'), 'job');
103
+ writeCapabilityIndex(path_1.default.join(projectRoot, 'fraim', 'personalized-employee', 'manager-jobs'), 'job');
104
+ }