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
|
-
/**
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/review.css
CHANGED
|
@@ -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); }
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
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'].
|
|
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
|
-
//
|
|
7308
|
-
//
|
|
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 (
|
|
7318
|
-
|
|
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)
|
|
7326
|
-
|
|
7327
|
-
|
|
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[
|
|
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
|
|
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
|
-
//
|
|
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
|
|
15127
|
+
return scope;
|
|
15074
15128
|
}
|
|
15075
15129
|
|
|
15076
15130
|
/**
|
|
15077
|
-
* A proposal's identity
|
|
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
|
-
|
|
15101
|
-
|
|
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.
|
|
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
|
|
15217
|
+
const found = candidates[0];
|
|
15128
15218
|
if (found) tfLearningRunByScope[key] = found;
|
|
15129
|
-
return found ||
|
|
15219
|
+
return found || null;
|
|
15130
15220
|
}
|
|
15131
|
-
// Per-
|
|
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
|
-
|
|
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
|
-
|
|
15179
|
-
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
|
|
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[
|
|
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
|
-
|
|
16270
|
-
|
|
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[
|
|
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[
|
|
16367
|
+
const o = tfProposalOverrides[tfProposalStateKey(p)];
|
|
16287
16368
|
return (o && o.reason) || '';
|
|
16288
16369
|
}
|
|
16289
16370
|
function tfProposalChanged(p) {
|
|
16290
|
-
const o = tfProposalOverrides[
|
|
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[
|
|
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[
|
|
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 =
|
|
16418
|
+
async function tfRelayBatch(scope, message, decisions, proposals) {
|
|
16419
|
+
const conv = tfProposalOwner(proposals);
|
|
16342
16420
|
if (conv && conv.runId) {
|
|
16343
|
-
|
|
16344
|
-
|
|
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
|
-
|
|
16347
|
-
|
|
16348
|
-
|
|
16349
|
-
|
|
16350
|
-
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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', () => {
|
|
16753
|
-
|
|
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
|
|
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
|
|
16789
|
-
|
|
16790
|
-
|
|
16791
|
-
|
|
16792
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|