fraim-hub 2.0.344 → 2.0.345

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.
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
13
  const conversation_store_lock_1 = require("./conversation-store-lock");
14
+ const proposal_set_contract_1 = require("../core/proposal-set-contract");
14
15
  const atomic_json_file_1 = require("./atomic-json-file");
15
16
  const conversation_search_projection_1 = require("./conversation-search-projection");
16
17
  // Issue #708: reserved, project-independent bucket keys for non-project scopes.
@@ -169,6 +170,45 @@ function conversationContinuityCompatible(left, right) {
169
170
  return false;
170
171
  return true;
171
172
  }
173
+ // Acknowledged review snapshots are append-only presentation history. Merge under
174
+ // the bucket lock so delayed browser patches and richer run projections cannot erase them.
175
+ function mergeLearningReviewTurns(...sources) {
176
+ const turns = [];
177
+ const seen = new Set();
178
+ for (const source of sources) {
179
+ if (!Array.isArray(source))
180
+ continue;
181
+ for (const turn of source) {
182
+ if (!turn || !Number.isInteger(turn.messageCount) || turn.messageCount < 0
183
+ || !turn.proposalSet || (0, proposal_set_contract_1.validateProposalSet)(turn.proposalSet)
184
+ || !Array.isArray(turn.proposalSet.proposals) || !turn.proposalSet.proposals.length
185
+ || !Array.isArray(turn.decisions) || turn.decisions.length !== turn.proposalSet.proposals.length)
186
+ continue;
187
+ const proposals = turn.proposalSet.proposals;
188
+ if (!proposals.every((p, i) => {
189
+ const d = turn.decisions[i];
190
+ return d && d.proposalId === p.proposalId && d.revision === p.revision
191
+ && ['approve', 'reject'].includes(d.action) && ['project', 'manager', 'org'].includes(d.level)
192
+ && typeof d.reason === 'string';
193
+ }))
194
+ continue;
195
+ const indices = proposals.flatMap((p, i) => {
196
+ const key = JSON.stringify([p.proposalId, p.revision]);
197
+ if (seen.has(key))
198
+ return [];
199
+ seen.add(key);
200
+ return [i];
201
+ });
202
+ if (indices.length)
203
+ turns.push({
204
+ messageCount: turn.messageCount,
205
+ proposalSet: { ...turn.proposalSet, proposals: indices.map((i) => proposals[i]) },
206
+ decisions: indices.map((i) => turn.decisions[i]),
207
+ });
208
+ }
209
+ }
210
+ return turns;
211
+ }
172
212
  // Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
173
213
  // richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
174
214
  function placeConversationInBucket(bucket, conv) {
@@ -196,6 +236,9 @@ function placeConversationInBucket(bucket, conv) {
196
236
  && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
197
237
  bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
198
238
  }
239
+ const history = mergeLearningReviewTurns(existing.learningReviewTurns, conv.learningReviewTurns);
240
+ if (history.length)
241
+ bucket.conversations[idx].learningReviewTurns = history;
199
242
  }
200
243
  function stableConversationCreatedAt(existing, incoming) {
201
244
  const existingValue = existing?.createdAt;
@@ -651,8 +694,18 @@ class AiHubConversationStore {
651
694
  }
652
695
  catch { /* ignore */ }
653
696
  }
654
- for (const conv of state.conversations)
697
+ for (const conv of state.conversations) {
698
+ try {
699
+ const prior = JSON.parse(fs_1.default.readFileSync(this.convFilePath(bucketDir, conv.id), 'utf8')).conversation;
700
+ if (conversationContinuityCompatible(prior, conv)) {
701
+ const history = mergeLearningReviewTurns(prior?.learningReviewTurns, conv.learningReviewTurns);
702
+ if (history.length)
703
+ conv.learningReviewTurns = history;
704
+ }
705
+ }
706
+ catch { /* no prior copy */ }
655
707
  this.writeConvFile(bucketDir, bucketKey, conv);
708
+ }
656
709
  this.writeIndex(bucketDir, bucketKey, state.activeId, state.conversations);
657
710
  // Issue #1065 (GDPR Art. 17 / C1): deletion must reach every copy. The whole-bucket
658
711
  // rewrite prunes conversation shards that are no longer wanted; prune the matching
@@ -903,12 +956,19 @@ class AiHubConversationStore {
903
956
  this.writeConvFile(bucketDir, key, candidate);
904
957
  return this.reindexAfterUpsert(bucketDir, key, candidate, undefined);
905
958
  }
959
+ const history = mergeLearningReviewTurns(existing.learningReviewTurns, patch.learningReviewTurns);
906
960
  if (patch.lastUpdatedAt !== undefined && timestampValue(patch.lastUpdatedAt) < timestampValue(existing.lastUpdatedAt)) {
961
+ if (JSON.stringify(history) !== JSON.stringify(mergeLearningReviewTurns(existing.learningReviewTurns))) {
962
+ existing.learningReviewTurns = history;
963
+ this.writeConvFile(bucketDir, key, existing);
964
+ return this.reindexAfterUpsert(bucketDir, key, existing, undefined);
965
+ }
907
966
  return this.stateFromIndex(bucketDir, key);
908
967
  }
909
968
  const merged = normalizeConversation(key, {
910
969
  ...existing,
911
970
  ...patch,
971
+ ...(history.length ? { learningReviewTurns: history } : {}),
912
972
  id: existing.id,
913
973
  projectPath: key,
914
974
  agentName: patch.agentName || existing.agentName,
@@ -22,7 +22,7 @@ function safePart(value) {
22
22
  return Boolean(value) && value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value)
23
23
  && value.trim() === value && !/[. ]$/.test(value);
24
24
  }
25
- /** Explicit paths keep parsing pure; otherwise resolve the owning configuration. Callers guard physical source containment. */
25
+ /** Resolve only within the supplied owner. Callers guard physical source containment. */
26
26
  function parseLearningSourceRecord(content, filePath, homeRoot, contentRoot, configuredPaths) {
27
27
  const invalid = (reason) => ({ status: 'invalid', reason });
28
28
  const normalized = content.replace(/^\uFEFF/, '');
@@ -49,7 +49,7 @@ function parseLearningSourceRecord(content, filePath, homeRoot, contentRoot, con
49
49
  for (const line of fm[1].split(/\r?\n/)) {
50
50
  const match = /^([A-Za-z][A-Za-z0-9]*):[ \t]*(.*)$/.exec(line);
51
51
  if (!match) {
52
- if (/^\s*["']?(artifactKind|author|date|job|runId|usageSourceRef|synthesized)["']?\s*:/.test(line))
52
+ if (/^\s*["']?(artifactKind|author|date|job|runId|sourceId|usageSourceRef|synthesized)["']?\s*:/.test(line))
53
53
  malformedField = true;
54
54
  continue;
55
55
  }
@@ -76,7 +76,7 @@ function parseLearningSourceRecord(content, filePath, homeRoot, contentRoot, con
76
76
  return invalid('Unsupported metadata key spelling');
77
77
  if (duplicates.size)
78
78
  return invalid(`Duplicate metadata: ${[...duplicates].join(', ')}`);
79
- for (const field of ['author', 'date', 'job', 'runId', 'usageSourceRef', 'synthesized']) {
79
+ for (const field of ['author', 'date', 'job', 'runId', 'sourceId', 'usageSourceRef', 'synthesized']) {
80
80
  if (!Object.prototype.hasOwnProperty.call(fields, field))
81
81
  return invalid(`Missing metadata: ${field}`);
82
82
  }
@@ -110,13 +110,22 @@ function parseLearningSourceRecord(content, filePath, homeRoot, contentRoot, con
110
110
  return invalid('Source is outside its configured learning location');
111
111
  if (state === 'archived' && !fields.synthesized)
112
112
  return invalid('Archive has no successful processing date');
113
- if (!node_path_1.default.isAbsolute(fields.usageSourceRef) || /[\x00-\x1f]/.test(fields.usageSourceRef)
114
- || fields.usageSourceRef.includes('\\')
115
- || fields.usageSourceRef !== node_path_1.default.resolve(fields.usageSourceRef).replace(/\\/g, '/')
116
- || normalizeLearningSourcePath(fields.usageSourceRef) !== normalizeLearningSourcePath(pendingPath)) {
117
- return invalid('usageSourceRef must be the normalized absolute original pending path');
113
+ const reference = fields.usageSourceRef;
114
+ const relative = node_path_1.default.relative(homeRoot, pendingPath).replace(/\\/g, '/');
115
+ if (!relative || relative.split('/').some(part => !part || part === '.' || part === '..') || node_path_1.default.isAbsolute(relative))
116
+ return invalid('Source escapes its owning root');
117
+ if (!reference || /[\x00-\x1f]/.test(reference) || reference.includes('\\'))
118
+ return invalid('Invalid usageSourceRef');
119
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(fields.sourceId)) {
120
+ return invalid('Sources require an immutable sourceId UUID');
121
+ }
122
+ if (node_path_1.default.posix.isAbsolute(reference) || node_path_1.default.win32.isAbsolute(reference) || reference.includes(':')
123
+ || reference.split('/').some(part => !part || part === '.' || part === '..') || reference !== relative) {
124
+ return invalid('usageSourceRef must name the owner-relative pending path');
118
125
  }
119
126
  return { status: 'valid', record: { artifactKind: fields.artifactKind, kind, author: fields.author,
120
127
  date: fields.date, job: fields.job, runId: fields.runId, usageSourceRef: fields.usageSourceRef,
128
+ usageIdentity: `learning-source:${fields.sourceId}`,
129
+ sourceId: fields.sourceId,
121
130
  synthesized: fields.synthesized, pendingPath, archivePath, state } };
122
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.344",
3
+ "version": "2.0.345",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -237,7 +237,7 @@
237
237
  "electron-updater": "^6.8.9",
238
238
  "express": "^5.2.1",
239
239
  "extract-zip": "^2.0.1",
240
- "fraim": "2.0.344",
240
+ "fraim": "2.0.345",
241
241
  "mongodb": "^7.0.0",
242
242
  "node-cron": "4.2.1",
243
243
  "node-edge-tts": "^1.2.10",
@@ -1055,3 +1055,7 @@
1055
1055
  }
1056
1056
  .learn-proposal-turn .learn-list { gap: 10px; }
1057
1057
  .learn-proposal-turn .learn-prop { background: var(--surface); }
1058
+ .learn-proposal-history .learn-prop.resolved { opacity: 1; filter: grayscale(1); }
1059
+ .learn-proposal-history .lp-title,
1060
+ .learn-proposal-history .lp-body,
1061
+ .learn-proposal-history .lp-decided-badge { color: var(--muted); }
@@ -4226,7 +4226,10 @@ function renderActive() {
4226
4226
  const hadPendingRowsBeforeRebuild = !!threadHost.querySelector('.message[data-pending="true"]');
4227
4227
  const rebuildThread = messages.length < renderedMessageCount || messagesMutated;
4228
4228
  if (rebuildThread) {
4229
- els['messages'].innerHTML = '';
4229
+ // Streaming message edits must not blur a proposal reason being composed.
4230
+ for (const child of Array.from(threadHost.children)) {
4231
+ if (child.id !== 'learn-proposal-turn') child.remove();
4232
+ }
4230
4233
  renderedMessageCount = 0;
4231
4234
  renderedMessageFingerprints = [];
4232
4235
  }
@@ -5953,7 +5956,7 @@ function appendMessageDom(role, text, conv, message) {
5953
5956
  article.appendChild(chip);
5954
5957
  });
5955
5958
  }
5956
- els['messages'].appendChild(article);
5959
+ els['messages'].insertBefore(article, els['messages'].querySelector('#learn-proposal-turn'));
5957
5960
  }
5958
5961
 
5959
5962
  function ensureThreadMessageViewportObserver() {
@@ -7304,44 +7307,52 @@ function renderReviewExperience(conv) {
7304
7307
  }
7305
7308
  }
7306
7309
 
7307
- // Issue #1755 R5: a sleep-on-learnings run writes its proposal set into its own
7308
- // conversation as markdown. Render it there as an actionable review turn rather
7309
- // than leaving the manager to find the controls in a panel on another page.
7310
- //
7311
- // The turn is rebuilt on every conversation render, because the thread itself is
7312
- // rebuilt on the poll cadence and a decision changes what the cards show.
7310
+ // Proposal review belongs to its conversation, regardless of the launching job.
7311
+ // Keep the controls mounted while polling unchanged data, including typed reasons.
7313
7312
  function renderLearningProposalTurn(conv) {
7314
7313
  const host = els['messages'];
7315
7314
  if (!host) return;
7315
+ renderLearningProposalHistory(conv);
7316
7316
  const existing = host.querySelector('#learn-proposal-turn');
7317
- if (existing) existing.remove();
7318
- if (!conv || conv.jobId !== 'sleep-on-learnings' || !tfConvHasProposals(conv)) return;
7317
+ if (!tfConvHasProposals(conv)) {
7318
+ if (existing) existing.remove();
7319
+ return;
7320
+ }
7319
7321
 
7320
7322
  // Issue #1857: the scope arrives on the set rather than being re-derived from
7321
7323
  // the kickoff text, so a decision reaches the corpus its run was scoped to even
7322
7324
  // when the kickoff wording is ambiguous.
7323
7325
  const scope = conv.proposalSet.runScope || tfProposalScopeForConversation(conv);
7324
7326
  const proposals = tfPendingProposals(conv);
7325
- if (!proposals.length) return;
7326
- // Keep the relay pointed at this run even if the accordion never registered it.
7327
- tfLearningRunByScope[tfLearningRunKey(scope)] = conv;
7327
+ if (!proposals.length) {
7328
+ if (existing) existing.remove();
7329
+ return;
7330
+ }
7331
+ const signature = JSON.stringify([conv.id, tfProposalSubmissions.has(conv.id), conv.proposalSet, proposals.map((p) => {
7332
+ const key = tfProposalStateKey(p);
7333
+ const override = tfProposalOverrides[key] || {};
7334
+ return [tfProposalDecisions[key], override.level, override.skipped];
7335
+ })]);
7336
+ if (existing && existing.proposalSignature === signature) return;
7337
+ if (existing) existing.remove();
7328
7338
 
7329
7339
  const row = document.createElement('article');
7330
7340
  row.className = 'message employee review-turn learn-proposal-turn';
7331
7341
  row.id = 'learn-proposal-turn';
7332
7342
  row.dataset.testid = 'learn-proposal-turn';
7343
+ row.proposalSignature = signature;
7333
7344
  const bubble = document.createElement('div');
7334
7345
  bubble.className = 'bubble';
7335
7346
 
7336
7347
  const label = document.createElement('div');
7337
7348
  label.className = 'review-turn-label';
7338
- const undecided = proposals.filter((p) => !tfProposalDecisions[tfProposalKey(p)]).length;
7349
+ const undecided = proposals.filter((p) => !tfProposalDecisions[tfProposalStateKey(p)]).length;
7339
7350
  label.textContent = undecided
7340
7351
  ? `${undecided} lesson${undecided === 1 ? '' : 's'} waiting on your decision`
7341
7352
  : 'Lessons reviewed';
7342
7353
  bubble.appendChild(label);
7343
7354
 
7344
- bubble.appendChild(tfLearningBatchBar(proposals, scope));
7355
+ if (undecided) bubble.appendChild(tfLearningBatchBar(proposals, scope));
7345
7356
  const list = document.createElement('div');
7346
7357
  list.className = 'learn-list';
7347
7358
  for (const p of proposals) list.appendChild(tfProposalCard(p, scope));
@@ -7376,10 +7387,58 @@ function renderLearningProposalTurn(conv) {
7376
7387
  }
7377
7388
 
7378
7389
  row.appendChild(bubble);
7390
+ if (tfProposalSubmissions.has(conv.id)) {
7391
+ row.querySelectorAll('button, textarea').forEach((control) => { control.disabled = true; });
7392
+ }
7379
7393
  // Below the thread, above the deliverables/next-job furniture.
7380
7394
  host.insertBefore(row, host.querySelector('#deliverables-bar, #next-job-section'));
7381
7395
  }
7382
7396
 
7397
+ // Like ordinary reviewTurns, acknowledged proposals belong to conversation history.
7398
+ // These snapshots describe a decision; they never authorize a later revision.
7399
+ function tfLearningReviewHistory(conv) {
7400
+ return (Array.isArray(conv?.learningReviewTurns) ? conv.learningReviewTurns : []).filter((turn) =>
7401
+ turn && Number.isInteger(turn.messageCount) && turn.messageCount >= 0
7402
+ && tfConvHasProposals({ proposalSet: turn.proposalSet })
7403
+ && Array.isArray(turn.decisions) && turn.decisions.length === turn.proposalSet.proposals.length
7404
+ && turn.proposalSet.proposals.every((p, i) => {
7405
+ const d = turn.decisions[i];
7406
+ return d && d.proposalId === p.proposalId && d.revision === p.revision
7407
+ && ['approve', 'reject'].includes(d.action) && ['project', 'manager', 'org'].includes(d.level)
7408
+ && typeof d.reason === 'string';
7409
+ }));
7410
+ }
7411
+
7412
+ function renderLearningProposalHistory(conv) {
7413
+ const host = els['messages'];
7414
+ const turns = tfLearningReviewHistory(conv);
7415
+ const signature = JSON.stringify([conv?.id, turns]);
7416
+ const previous = Array.from(host.querySelectorAll('.learn-proposal-history'));
7417
+ if (previous.length === turns.length && previous.every((row) => row.proposalSignature === signature)) return;
7418
+ previous.forEach((row) => row.remove());
7419
+ const messages = Array.from(host.children).filter((row) => row.classList.contains('message')
7420
+ && !row.classList.contains('review-turn'));
7421
+ for (const turn of turns) {
7422
+ const row = document.createElement('article');
7423
+ row.className = 'message employee review-turn learn-proposal-turn learn-proposal-history';
7424
+ row.proposalSignature = signature;
7425
+ const bubble = document.createElement('div');
7426
+ bubble.className = 'bubble';
7427
+ const label = document.createElement('div');
7428
+ label.className = 'review-turn-label';
7429
+ label.textContent = 'Lessons reviewed';
7430
+ bubble.appendChild(label);
7431
+ const list = document.createElement('div');
7432
+ list.className = 'learn-list';
7433
+ turn.proposalSet.proposals.forEach((p, i) => list.appendChild(tfProposalCard(
7434
+ { ...p, reviewConversationId: conv.id }, turn.proposalSet.runScope, turn.decisions[i])));
7435
+ bubble.appendChild(list);
7436
+ row.appendChild(bubble);
7437
+ host.insertBefore(row, messages[turn.messageCount]
7438
+ || host.querySelector('#learn-proposal-turn, #employee-working-indicator, #deliverables-bar, #next-job-section'));
7439
+ }
7440
+ }
7441
+
7383
7442
  // Which corpus an approved proposal from this run belongs to. The kickoff text
7384
7443
  // names it, because `tfRunShareLearnings` writes the scope word into the message
7385
7444
  // it starts the run with; a run started another way defaults to the manager's own
@@ -15060,21 +15119,16 @@ function tfRenderManager() {
15060
15119
  // faithful and exercisable.
15061
15120
  // ---------------------------------------------------------------------------
15062
15121
 
15063
- // Per-scope learnings run conversation (so a relayed decision continues the
15064
- // right sleep-on-learnings turn). Keyed by scope: org | manager | project.
15122
+ // Per-scope discovery cache. Decisions resolve their displayed owner separately.
15065
15123
  const tfLearningRunByScope = { org: null, manager: null, project: null, reverse: null };
15066
15124
 
15067
- // Issue #1002 R13: the run key is derived from the SURFACE that started the run,
15068
- // not chosen ad hoc at each call site. Those drifted: the project workspace's
15069
- // "Sleep on learnings" action filed its run under 'project' while the accordion
15070
- // that launched it rendered with scope 'manager' and therefore read 'manager', so
15071
- // live proposals from that button appeared nowhere. One function, both sides.
15125
+ // Keep project, manager and organization discovery scopes distinct.
15072
15126
  function tfLearningRunKey(scope) {
15073
- return scope === 'org' ? 'org' : scope === 'reverse' ? 'reverse' : 'manager';
15127
+ return scope;
15074
15128
  }
15075
15129
 
15076
15130
  /**
15077
- * A proposal's identity, and the only thing a decision is ever keyed on.
15131
+ * A proposal's wire identity; local state additionally binds owner and revision.
15078
15132
  *
15079
15133
  * Issue #1857 R18: these used to be `scope + '-live-' + index`, positional per
15080
15134
  * render. A Skip therefore relayed a title, and `move-artifact-between-levels`
@@ -15086,6 +15140,19 @@ function tfProposalKey(p) {
15086
15140
  return p && p.proposalId;
15087
15141
  }
15088
15142
 
15143
+ // Local edits and decisions must not leak to another owner or a new revision.
15144
+ function tfProposalStateKey(p) {
15145
+ return JSON.stringify([p.reviewConversationId, p.proposalId, p.revision]);
15146
+ }
15147
+
15148
+ function tfProposalOwner(proposals) {
15149
+ const conv = proposals.length && findConversation(proposals[0].reviewConversationId);
15150
+ if (!tfConvHasProposals(conv)) return null;
15151
+ return proposals.every((p) => p.reviewConversationId === conv.id
15152
+ && conv.proposalSet.proposals.some((current) => current.proposalId === p.proposalId
15153
+ && current.revision === p.revision)) ? conv : null;
15154
+ }
15155
+
15089
15156
  /**
15090
15157
  * True when this run is carrying a structured proposal set.
15091
15158
  *
@@ -15097,8 +15164,30 @@ function tfProposalKey(p) {
15097
15164
  * Hub stores it on the conversation, so there is one source and nothing to parse.
15098
15165
  */
15099
15166
  function tfConvHasProposals(conv) {
15100
- return !!(conv && conv.proposalSet && Array.isArray(conv.proposalSet.proposals)
15101
- && conv.proposalSet.proposals.length > 0);
15167
+ const set = conv && conv.proposalSet;
15168
+ const levels = ['project', 'manager', 'org'];
15169
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
15170
+ const text = (v) => typeof v === 'string' && v.trim().length > 0;
15171
+ const count = (v) => Number.isInteger(v) && v >= 0;
15172
+ const optionalText = (v) => v === undefined || typeof v === 'string';
15173
+ // The ingress validates the full contract. Defend the rendering/action boundary
15174
+ // too, since persisted conversation data can be incomplete or from older clients.
15175
+ return !!(object(set) && levels.includes(set.runScope) && Array.isArray(set.proposals)
15176
+ && set.proposals.length > 0 && set.proposals.every((p) => object(p) &&
15177
+ ['proposalId', 'claimKey', 'family', 'title', 'takeaway'].every((field) =>
15178
+ text(p[field]))
15179
+ && Number.isInteger(p.revision) && p.revision > 0
15180
+ && levels.includes(p.suggestedLevel)
15181
+ && ['new', 'update', 'cold'].includes(p.changeType)
15182
+ && count(p.recurrences) && optionalText(p.example) && optionalText(p.suggestionReason)
15183
+ && (p.domain == null || typeof p.domain === 'string')
15184
+ && (p.recurrencesAtLastReview == null || count(p.recurrencesAtLastReview))
15185
+ && (p.reopening === undefined || (object(p.reopening) && text(p.reopening.rejectedAt)
15186
+ && text(p.reopening.reason) && count(p.reopening.countAtRejection))))
15187
+ && (set.suppressed === undefined || (Array.isArray(set.suppressed)
15188
+ && set.suppressed.every((p) => object(p)
15189
+ && ['claimKey', 'family', 'rejectedAt', 'reason'].every((field) => text(p[field]))
15190
+ && ['refused', 'superseded'].includes(p.kind) && count(p.newSourceCount)))));
15102
15191
  }
15103
15192
 
15104
15193
  /**
@@ -15114,23 +15203,25 @@ function tfConvHasProposals(conv) {
15114
15203
  function tfFindLearningRun(scope) {
15115
15204
  const key = tfLearningRunKey(scope);
15116
15205
  const registered = tfLearningRunByScope[key];
15117
- if (tfConvHasProposals(registered)) return registered;
15118
15206
  const list = (state.conversations && state.conversations[state.projectPath]) || [];
15207
+ const current = registered && list.find((c) => c.id === registered.id);
15208
+ if (tfConvHasProposals(current) && current.proposalSet.runScope === scope) return current;
15119
15209
  // Most recent first: the newest proposal set is the one awaiting a decision.
15120
15210
  const candidates = list
15121
- .filter((c) => c && c.jobId === 'sleep-on-learnings' && tfConvHasProposals(c))
15211
+ .filter((c) => tfConvHasProposals(c) && c.proposalSet.runScope === scope)
15122
15212
  .sort((a, b) => (conversationTimestamp(b) || 0) - (conversationTimestamp(a) || 0));
15123
15213
  // Prefer a run asked for at THIS scope. A decision is relayed into the run as a
15124
15214
  // manager turn, and the job writes it to the corpus that run was scoped to, so
15125
15215
  // picking the newest run regardless of scope could apply a manager-level
15126
15216
  // decision to the company corpus when both have a set pending.
15127
- const found = candidates.find((c) => tfProposalScopeForConversation(c) === scope) || candidates[0];
15217
+ const found = candidates[0];
15128
15218
  if (found) tfLearningRunByScope[key] = found;
15129
- return found || registered || null;
15219
+ return found || null;
15130
15220
  }
15131
- // Per-scope decision record so a blessed/skipped proposal stays resolved in the
15221
+ // Per-owner/revision decision record so a blessed/skipped proposal stays resolved in the
15132
15222
  // UI after the relay (the durable write is the job's apply phase, not the Hub).
15133
15223
  const tfProposalDecisions = {};
15224
+ const tfProposalSubmissions = new Set();
15134
15225
 
15135
15226
  function tfScopeMeta(scope) {
15136
15227
  if (scope === 'org') return { cls: 'scope-org', label: 'Org', note: 'Saves to organization learnings · applies on every project' };
@@ -15148,13 +15239,16 @@ function tfScopeMeta(scope) {
15148
15239
  */
15149
15240
  function tfPendingProposals(conv) {
15150
15241
  if (!tfConvHasProposals(conv)) return [];
15151
- return conv.proposalSet.proposals;
15242
+ const reviewed = tfLearningReviewHistory(conv).flatMap((turn) => turn.decisions);
15243
+ return conv.proposalSet.proposals.filter((p) => !reviewed.some((d) =>
15244
+ d.proposalId === p.proposalId && d.revision === p.revision))
15245
+ .map((p) => ({ ...p, reviewConversationId: conv.id }));
15152
15246
  }
15153
15247
 
15154
15248
  /** Claims this run suppressed because the manager already decided them (R7). */
15155
15249
  function tfSuppressedClaims(conv) {
15156
15250
  const suppressed = conv && conv.proposalSet && conv.proposalSet.suppressed;
15157
- return Array.isArray(suppressed) ? suppressed : [];
15251
+ return Array.isArray(suppressed) ? suppressed.filter((p) => p && typeof p === 'object') : [];
15158
15252
  }
15159
15253
 
15160
15254
  // Issue #1002 R13: the job states levels in the manager's words ("keep only for
@@ -15173,31 +15267,12 @@ function tfParseLevelWord(raw) {
15173
15267
  // approve → "Approve as-is" ; reject → "Reject (skip)". The job's review-pending
15174
15268
  // phase consumes the decision and its apply phase performs the durable write.
15175
15269
  async function tfRelayProposalDecision(scope, proposal, decision) {
15176
- const conv = tfFindLearningRun(scope);
15177
15270
  const verb = decision === 'approve' ? 'Approve as-is' : 'Reject (skip)';
15178
- const message = `${verb} the proposal: "${proposal.title}".`;
15179
- tfProposalDecisions[tfProposalKey(proposal)] = decision;
15180
- if (conv && conv.runId) {
15181
- // Relay into the live run via the existing manager-turn path.
15182
- const prev = state.activeId;
15183
- state.activeId = conv.id;
15184
- try {
15185
- // Issue #1857 R18: the decision names the proposal it decided. The prose is
15186
- // what the manager reads back; `proposalDecisions` is what the job records
15187
- // against, so it never has to re-derive a revision from a quoted title.
15188
- await continueRun(message, {
15189
- proposalDecisions: [{
15190
- proposalId: tfProposalKey(proposal), revision: proposal.revision,
15191
- action: decision === 'approve' ? 'approve' : 'reject',
15192
- level: tfProposalLevel(proposal), reason: tfProposalReason(proposal) || '',
15193
- }],
15194
- });
15195
- } finally { state.activeId = prev; }
15196
- } else if (typeof showStatus === 'function') {
15197
- showStatus(decision === 'approve'
15198
- ? `Blessed “${proposal.title}”. Run Share learnings to let sleep-on-learnings write it.`
15199
- : `Skipped “${proposal.title}”. Run Share learnings to record the rejection.`, false);
15200
- }
15271
+ await tfRelayBatch(scope, `${verb} the proposal: "${proposal.title}".`, [{
15272
+ proposalId: proposal.proposalId, revision: proposal.revision,
15273
+ action: decision === 'approve' ? 'approve' : 'reject',
15274
+ level: tfProposalLevel(proposal), reason: tfProposalReason(proposal),
15275
+ }], [proposal]);
15201
15276
  }
15202
15277
 
15203
15278
  // Kick off (or resume) the sleep-on-learnings job for a scope — the live trigger.
@@ -16218,9 +16293,11 @@ function tfLearningBatchBar(proposals, scope) {
16218
16293
  accept.className = 'rb-approve';
16219
16294
  accept.type = 'button';
16220
16295
  accept.dataset.testid = 'learn-batch-accept';
16296
+ const reviewed = proposals.every((p) => tfProposalDecisions[tfProposalStateKey(p)]);
16297
+ accept.disabled = reviewed;
16221
16298
  // Do not say "Save all 5" when one of the five is skipped. Count what will
16222
16299
  // actually be written, and name the skips separately.
16223
- const skipped = proposals.filter((p) => (tfProposalOverrides[tfProposalKey(p)] || {}).skipped);
16300
+ const skipped = proposals.filter((p) => (tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped);
16224
16301
  const saving = proposals.length - skipped.length;
16225
16302
  if (changed.length === 0) {
16226
16303
  accept.textContent = `✓ Keep all ${proposals.length}`;
@@ -16256,6 +16333,7 @@ function tfLearningBatchBar(proposals, scope) {
16256
16333
  }
16257
16334
  accept.disabled = true;
16258
16335
  await tfAcceptLearningBatch(proposals, scope);
16336
+ accept.disabled = false;
16259
16337
  });
16260
16338
  bar.appendChild(accept);
16261
16339
 
@@ -16264,10 +16342,13 @@ function tfLearningBatchBar(proposals, scope) {
16264
16342
  skipAll.type = 'button';
16265
16343
  skipAll.dataset.testid = 'learn-batch-skip-all';
16266
16344
  skipAll.textContent = 'Skip all';
16345
+ skipAll.disabled = reviewed;
16267
16346
  skipAll.addEventListener('click', async () => {
16268
16347
  skipAll.disabled = true;
16269
- for (const p of proposals) tfProposalDecisions[tfProposalKey(p)] = 'reject';
16270
- await tfRelayBatch(scope, `Skip all ${proposals.length} proposals.`);
16348
+ const decisions = proposals.map((p) => ({ proposalId: p.proposalId, revision: p.revision,
16349
+ action: 'reject', level: tfProposalLevel(p), reason: tfProposalReason(p) }));
16350
+ await tfRelayBatch(scope, `Skip all ${proposals.length} proposals.`, decisions, proposals);
16351
+ skipAll.disabled = false;
16271
16352
  });
16272
16353
  bar.appendChild(skipAll);
16273
16354
 
@@ -16279,20 +16360,20 @@ function tfLearningBatchBar(proposals, scope) {
16279
16360
  // wiped by the next render before the manager could save it.
16280
16361
  const tfProposalOverrides = {};
16281
16362
  function tfProposalLevel(p) {
16282
- const o = tfProposalOverrides[tfProposalKey(p)];
16363
+ const o = tfProposalOverrides[tfProposalStateKey(p)];
16283
16364
  return (o && o.level) || p.suggestedLevel || 'manager';
16284
16365
  }
16285
16366
  function tfProposalReason(p) {
16286
- const o = tfProposalOverrides[tfProposalKey(p)];
16367
+ const o = tfProposalOverrides[tfProposalStateKey(p)];
16287
16368
  return (o && o.reason) || '';
16288
16369
  }
16289
16370
  function tfProposalChanged(p) {
16290
- const o = tfProposalOverrides[tfProposalKey(p)];
16371
+ const o = tfProposalOverrides[tfProposalStateKey(p)];
16291
16372
  if (!o) return false;
16292
16373
  return !!o.skipped || (!!o.level && o.level !== (p.suggestedLevel || 'manager'));
16293
16374
  }
16294
16375
  function tfSetProposalOverride(p, patch) {
16295
- tfProposalOverrides[tfProposalKey(p)] = Object.assign({}, tfProposalOverrides[tfProposalKey(p)] || {}, patch);
16376
+ tfProposalOverrides[tfProposalStateKey(p)] = Object.assign({}, tfProposalOverrides[tfProposalStateKey(p)] || {}, patch);
16296
16377
  }
16297
16378
 
16298
16379
  // Issue #1002 R13: accepting the batch relays ONE turn describing every decision,
@@ -16303,17 +16384,14 @@ async function tfAcceptLearningBatch(proposals, scope) {
16303
16384
  const decisions = [];
16304
16385
  for (const p of proposals) {
16305
16386
  const level = tfProposalLevel(p);
16306
- const o = tfProposalOverrides[tfProposalKey(p)] || {};
16387
+ const o = tfProposalOverrides[tfProposalStateKey(p)] || {};
16307
16388
  if (o.skipped) {
16308
- tfProposalDecisions[tfProposalKey(p)] = 'reject';
16309
16389
  lines.push(`- Skip "${p.title}". Reason: ${tfProposalReason(p) || '(none given)'}`);
16310
16390
  decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'reject', level, reason: tfProposalReason(p) || '' });
16311
16391
  } else if (tfProposalChanged(p)) {
16312
- tfProposalDecisions[tfProposalKey(p)] = 'promote:' + level;
16313
16392
  lines.push(`- Save "${p.title}" at ${tfLevelWord(level)}. Reason: ${tfProposalReason(p) || '(none given)'}`);
16314
16393
  decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
16315
16394
  } else {
16316
- tfProposalDecisions[tfProposalKey(p)] = 'approve';
16317
16395
  lines.push(`- Keep "${p.title}" at ${tfLevelWord(level)}.`);
16318
16396
  decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
16319
16397
  }
@@ -16326,7 +16404,7 @@ async function tfAcceptLearningBatch(proposals, scope) {
16326
16404
  // Issue #1857 R18. The prose above is what the manager reads back; `decisions`
16327
16405
  // is what the job records against. Previously only the prose existed, naming
16328
16406
  // each proposal by title, and the job had to guess which revision that was.
16329
- await tfRelayBatch(scope, msg, decisions);
16407
+ await tfRelayBatch(scope, msg, decisions, proposals);
16330
16408
  }
16331
16409
 
16332
16410
  function tfLevelWord(level) {
@@ -16337,19 +16415,28 @@ function tfLevelWord(level) {
16337
16415
 
16338
16416
  // Relay one manager turn into the running learning job, through the same
16339
16417
  // continueRun path every other in-conversation control uses. No second write path.
16340
- async function tfRelayBatch(scope, message, decisions) {
16341
- const conv = tfFindLearningRun(scope);
16418
+ async function tfRelayBatch(scope, message, decisions, proposals) {
16419
+ const conv = tfProposalOwner(proposals);
16342
16420
  if (conv && conv.runId) {
16343
- const prev = state.activeId;
16344
- state.activeId = conv.id;
16421
+ if (tfProposalSubmissions.has(conv.id)
16422
+ || proposals.every((p) => tfProposalDecisions[tfProposalStateKey(p)])) return;
16423
+ tfProposalSubmissions.add(conv.id);
16424
+ const history = {
16425
+ messageCount: (conv.messages || []).length,
16426
+ proposalSet: { runScope: conv.proposalSet.runScope, proposals: proposals.map(({ reviewConversationId, ...p }) => p) },
16427
+ decisions: decisions.map((d) => ({ ...d })),
16428
+ };
16429
+ tfReRenderLearnings(scope);
16345
16430
  try {
16346
- // Issue #1857 R18. Carried the same way an approve_delivery selection already
16347
- // travels: the job records against `proposalId` and `revision` rather than
16348
- // matching the titles quoted in the sentence the manager reads.
16349
- await continueRun(message, decisions && decisions.length ? { proposalDecisions: decisions } : undefined);
16350
- } finally { state.activeId = prev; }
16431
+ const result = await continueRun(message, { conv, proposalDecisions: decisions });
16432
+ if (result.ok) {
16433
+ proposals.forEach((p, i) => { tfProposalDecisions[tfProposalStateKey(p)] = decisions[i].action; });
16434
+ conv.learningReviewTurns = [...tfLearningReviewHistory(conv), history];
16435
+ persistConversation(conv);
16436
+ }
16437
+ } finally { tfProposalSubmissions.delete(conv.id); }
16351
16438
  } else if (typeof showStatus === 'function') {
16352
- showStatus('Run Sleep on learnings first, then your decisions go straight into that run.', false);
16439
+ showStatus('This proposal review has changed. Reopen its conversation before deciding.', true);
16353
16440
  }
16354
16441
  if (typeof tfReRenderLearnings === 'function') tfReRenderLearnings(scope);
16355
16442
  }
@@ -16551,10 +16638,10 @@ async function tfLaunchShareWithOthers(scope, level, selectedTitles, job, projec
16551
16638
  }
16552
16639
  }
16553
16640
 
16554
- function tfProposalCard(p, scope) {
16641
+ function tfProposalCard(p, scope, recordedDecision) {
16555
16642
  const card = document.createElement('div');
16556
16643
  card.className = 'learn-prop';
16557
- const decided = tfProposalDecisions[p.proposalId];
16644
+ const decided = recordedDecision?.action || tfProposalDecisions[tfProposalStateKey(p)];
16558
16645
  if (decided) card.classList.add('resolved');
16559
16646
 
16560
16647
  // Issue #1857: no scope chip. It showed the run's scope while the level buttons
@@ -16624,6 +16711,22 @@ function tfProposalCard(p, scope) {
16624
16711
  stats.appendChild(kind);
16625
16712
  card.appendChild(stats);
16626
16713
 
16714
+ if (recordedDecision) {
16715
+ const result = document.createElement('div');
16716
+ result.className = 'lp-decided-badge';
16717
+ result.dataset.testid = 'lp-decided-badge';
16718
+ result.textContent = recordedDecision.action === 'reject' ? 'Skipped'
16719
+ : 'Approved · ' + tfLevelWord(recordedDecision.level);
16720
+ card.appendChild(result);
16721
+ if (recordedDecision.reason) {
16722
+ const reason = document.createElement('div');
16723
+ reason.className = 'lp-reason';
16724
+ reason.textContent = recordedDecision.reason;
16725
+ card.appendChild(reason);
16726
+ }
16727
+ return card;
16728
+ }
16729
+
16627
16730
  // Issue #1755 R6: a proposal card reads as two zones: what you can still change
16628
16731
  // before deciding, and the decision itself. They used to sit on one undivided
16629
16732
  // row, so a level button and a Skip button looked like the same kind of control.
@@ -16688,7 +16791,7 @@ function tfProposalCard(p, scope) {
16688
16791
  // A skipped lesson is saved at no level, so no level is selected on that row.
16689
16792
  // Showing "Skipped" next to a highlighted level says two contradictory things
16690
16793
  // about the same row. The buttons stay live: picking a level clears the skip.
16691
- const isSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16794
+ const isSkipped = !!(tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped;
16692
16795
  if (isSkipped) pick.classList.add('lp-moot');
16693
16796
  for (const [level, label] of LEVELS) {
16694
16797
  const b = document.createElement('button');
@@ -16718,13 +16821,13 @@ function tfProposalCard(p, scope) {
16718
16821
  decide.dataset.testid = 'lp-decide';
16719
16822
 
16720
16823
  const skip = document.createElement('button');
16721
- skip.className = 'lp-skip' + ((tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? ' on' : '');
16824
+ skip.className = 'lp-skip' + ((tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped || decided === 'reject' ? ' on' : '');
16722
16825
  skip.type = 'button';
16723
16826
  skip.dataset.testid = 'lp-skip';
16724
- skip.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16827
+ skip.textContent = (tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16725
16828
  skip.disabled = !!decided;
16726
16829
  skip.addEventListener('click', () => {
16727
- const wasSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16830
+ const wasSkipped = !!(tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped;
16728
16831
  tfSetProposalOverride(p, { skipped: !wasSkipped });
16729
16832
  reRender();
16730
16833
  });
@@ -16739,7 +16842,7 @@ function tfProposalCard(p, scope) {
16739
16842
  const id = 'lp-why-' + String(p.proposalId).replace(/[^a-zA-Z0-9_-]/g, '');
16740
16843
  const label = document.createElement('label');
16741
16844
  label.setAttribute('for', id);
16742
- label.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16845
+ label.textContent = (tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped
16743
16846
  ? 'Why skip it?'
16744
16847
  : 'Why this level instead?';
16745
16848
  const ta = document.createElement('textarea');
@@ -16749,8 +16852,14 @@ function tfProposalCard(p, scope) {
16749
16852
  ta.value = tfProposalReason(p);
16750
16853
  // Persist on input so a re-render from the poll cadence cannot wipe what the
16751
16854
  // manager typed before they save.
16752
- ta.addEventListener('input', () => { tfSetProposalOverride(p, { reason: ta.value }); });
16753
- ta.addEventListener('change', () => { tfSetProposalOverride(p, { reason: ta.value }); reRender(); });
16855
+ ta.addEventListener('input', () => {
16856
+ tfSetProposalOverride(p, { reason: ta.value });
16857
+ // Update the reason-required action without replacing the focused textarea.
16858
+ const turn = card.closest('#learn-proposal-turn');
16859
+ const bar = turn && turn.querySelector('.learn-batch');
16860
+ const owner = tfProposalOwner([p]);
16861
+ if (bar && owner) bar.replaceWith(tfLearningBatchBar(tfPendingProposals(owner), scope));
16862
+ });
16754
16863
  const hint = document.createElement('span');
16755
16864
  hint.className = 'lp-why-hint';
16756
16865
  hint.textContent = 'Saved as a signal, so future runs get this right without asking.';
@@ -16774,7 +16883,7 @@ function tfProposalCard(p, scope) {
16774
16883
  // R8: state what the decision will actually write, in the zone where it is made.
16775
16884
  const note = document.createElement('span');
16776
16885
  note.className = 'lp-scope-note';
16777
- note.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16886
+ note.textContent = (tfProposalOverrides[tfProposalStateKey(p)] || {}).skipped
16778
16887
  ? 'Nothing is written. The skip is recorded as a signal.'
16779
16888
  : (TF_LEVEL_TITLE[currentLevel] || tfScopeMeta(scope).note);
16780
16889
  decide.appendChild(note);
@@ -16785,17 +16894,11 @@ function tfProposalCard(p, scope) {
16785
16894
  // #521 R7: promote a project candidate to a chosen scope. Reuses the live relay
16786
16895
  // (a scope-targeted instruction into the sleep-on-learnings run) — no new backend.
16787
16896
  async function tfPromoteProposal(originScope, proposal, targetScope) {
16788
- const label = targetScope === 'org' ? 'Company' : targetScope === 'manager' ? 'Manager' : 'Project';
16789
- const conv = tfFindLearningRun(originScope);
16790
- const message = `Promote the proposal "${proposal.title}" to ${label.toLowerCase()} learnings (scope: ${targetScope}).`;
16791
- tfProposalDecisions[tfProposalKey(proposal)] = 'promote:' + targetScope;
16792
- if (conv && conv.runId) {
16793
- const prev = state.activeId;
16794
- state.activeId = conv.id;
16795
- try { await continueRun(message); } finally { state.activeId = prev; }
16796
- } else if (typeof showStatus === 'function') {
16797
- showStatus(`Promoted “${proposal.title}” to ${label} learnings. Run Share learnings to let sleep-on-learnings persist it.`, false);
16798
- }
16897
+ const label = tfLevelWord(targetScope);
16898
+ await tfRelayBatch(originScope, `Promote the proposal "${proposal.title}" to ${label}.`, [{
16899
+ proposalId: proposal.proposalId, revision: proposal.revision,
16900
+ action: 'approve', level: targetScope, reason: tfProposalReason(proposal),
16901
+ }], [proposal]);
16799
16902
  }
16800
16903
 
16801
16904
  // ---------------------------------------------------------------------------