fraim-hub 2.0.340 → 2.0.341
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.
- package/dist/registry/scripts/learning/completion-state.js +10 -2
- package/dist/src/ai-hub/desktop-main.js +7 -3
- package/dist/src/ai-hub/hosts.js +28 -0
- package/dist/src/ai-hub/manager-turns.js +16 -0
- package/dist/src/ai-hub/server.js +148 -6
- package/dist/src/core/handoff-contracts.js +122 -21
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +128 -1
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +163 -38
- package/package.json +5 -2
- package/public/ai-hub/coach-intake-surface.js +415 -0
- package/public/ai-hub/index.html +21 -63
- package/public/ai-hub/pane-surfaces.js +315 -0
- package/public/ai-hub/review.css +42 -0
- package/public/ai-hub/script.js +473 -370
- package/public/ai-hub/styles.css +61 -40
|
@@ -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
|
+
}
|
|
@@ -24,6 +24,7 @@ exports.readOrgBrand = readOrgBrand;
|
|
|
24
24
|
exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
25
25
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
26
26
|
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
27
|
+
exports.readRejectedProposals = readRejectedProposals;
|
|
27
28
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
28
29
|
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
29
30
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
@@ -579,6 +580,34 @@ function pendingL0SortKey(fileName) {
|
|
|
579
580
|
const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
|
|
580
581
|
return timestamp?.[0] || fileName;
|
|
581
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Issue #1857 R11: how many proposals are waiting for the manager's review.
|
|
585
|
+
*
|
|
586
|
+
* This counts directory entries and nothing else. It does not open a proposal, and
|
|
587
|
+
* deliberately has no access to one: R10 is an invariant about influence rather
|
|
588
|
+
* than storage, so the employee learns that a backlog exists and which job clears
|
|
589
|
+
* it, never what any pending claim says.
|
|
590
|
+
*
|
|
591
|
+
* The same shape as the pending-L0 line it sits beside, for the same reason: a
|
|
592
|
+
* count plus the job that clears it is actionable; a list of contents is not.
|
|
593
|
+
*/
|
|
594
|
+
function countPendingProposals(roots, resolvedUserId) {
|
|
595
|
+
let total = 0;
|
|
596
|
+
for (const base of [roots.repoLearningsBase, roots.globalPersonalBase]) {
|
|
597
|
+
if (!base)
|
|
598
|
+
continue;
|
|
599
|
+
const queueDir = (0, path_1.join)(base, 'review-queue', resolvedUserId);
|
|
600
|
+
if (!(0, fs_1.existsSync)(queueDir))
|
|
601
|
+
continue;
|
|
602
|
+
try {
|
|
603
|
+
total += (0, fs_1.readdirSync)(queueDir).filter((name) => name.endsWith('.json')).length;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// An unreadable queue is not worth failing a job's startup over.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return total;
|
|
610
|
+
}
|
|
582
611
|
function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
|
|
583
612
|
const sources = [];
|
|
584
613
|
const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
@@ -823,7 +852,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
823
852
|
const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
|
|
824
853
|
const hasL2 = l2Files.length > 0;
|
|
825
854
|
const hasL1 = l1Files.length > 0;
|
|
826
|
-
|
|
855
|
+
// Issue #1857 R11. Counted before the gate on purpose: a manager with a full
|
|
856
|
+
// review queue and an empty corpus is exactly the case where the backlog most
|
|
857
|
+
// needs saying, and computing it after the early return would silence it there.
|
|
858
|
+
const pendingProposals = countPendingProposals(roots, resolvedUserId);
|
|
859
|
+
const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0 || pendingProposals > 0;
|
|
827
860
|
if (!hasContent)
|
|
828
861
|
return '';
|
|
829
862
|
let section = forJob
|
|
@@ -862,6 +895,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
862
895
|
}
|
|
863
896
|
section += '\n';
|
|
864
897
|
}
|
|
898
|
+
// Issue #1857 R11. The count, never the content: an unapproved proposal does
|
|
899
|
+
// not influence this job, and the employee is told only that the manager has a
|
|
900
|
+
// backlog and which job clears it.
|
|
901
|
+
if (pendingProposals > 0) {
|
|
902
|
+
const plural = pendingProposals !== 1;
|
|
903
|
+
section += `${pendingProposals} proposal${plural ? 's are' : ' is'} waiting for your manager's review; run \`sleep-on-learnings\` to review ${plural ? 'them' : 'it'}.\n\n`;
|
|
904
|
+
}
|
|
865
905
|
const coachPresent = resolved.coachPresent;
|
|
866
906
|
if (forJob) {
|
|
867
907
|
if (hasL2 || hasL1) {
|
|
@@ -1825,6 +1865,93 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1825
1865
|
flush();
|
|
1826
1866
|
return out;
|
|
1827
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Read the rejection history a sleep run writes on completion.
|
|
1870
|
+
*
|
|
1871
|
+
* The file is a sequence of `<!-- sleep-rejection:run:action:revision -->` blocks,
|
|
1872
|
+
* each holding a heading, the manager's reason, an optional revisit condition and
|
|
1873
|
+
* the rejected proposal as JSON. `run-application.ts` writes it; this reads it
|
|
1874
|
+
* back without changing a byte, because the file is also a synthesis input and a
|
|
1875
|
+
* reader that rewrote it would corrupt the next run's history.
|
|
1876
|
+
*
|
|
1877
|
+
* A malformed block is skipped rather than failing the whole read: the manager
|
|
1878
|
+
* losing the view of every rejection because one block is damaged is worse than
|
|
1879
|
+
* the view missing that one.
|
|
1880
|
+
*/
|
|
1881
|
+
function readRejectedProposals(workspaceRoot, userId) {
|
|
1882
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
1883
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1884
|
+
if (!resolvedUserId)
|
|
1885
|
+
return [];
|
|
1886
|
+
const out = [];
|
|
1887
|
+
const seen = new Set();
|
|
1888
|
+
const headingRe = /^## (Rejected|Superseded): (.+)$/m;
|
|
1889
|
+
const reasonRe = /^Reason: (.+)$/m;
|
|
1890
|
+
const revisitRe = /^Revisit when: (.+)$/m;
|
|
1891
|
+
const jsonRe = /```json\r?\n([\s\S]*?)\r?\n```/;
|
|
1892
|
+
for (const base of [roots.repoLearningsBase, roots.globalPersonalBase, roots.managerCacheBase]) {
|
|
1893
|
+
if (!base)
|
|
1894
|
+
continue;
|
|
1895
|
+
const file = (0, path_1.join)(base, `${resolvedUserId}-rejected-proposals.md`);
|
|
1896
|
+
if (!(0, fs_1.existsSync)(file))
|
|
1897
|
+
continue;
|
|
1898
|
+
let body;
|
|
1899
|
+
try {
|
|
1900
|
+
body = (0, fs_1.readFileSync)(file, 'utf8');
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
const displayPath = file.split('\\').join('/');
|
|
1906
|
+
for (const raw of body.split('<!-- sleep-rejection:').slice(1)) {
|
|
1907
|
+
const end = raw.indexOf('<!-- /sleep-rejection -->');
|
|
1908
|
+
const chunk = end >= 0 ? raw.slice(0, end) : raw;
|
|
1909
|
+
const marker = chunk.slice(0, Math.max(0, chunk.indexOf('-->')));
|
|
1910
|
+
const parts = marker.trim().split(':');
|
|
1911
|
+
const heading = headingRe.exec(chunk);
|
|
1912
|
+
const reason = reasonRe.exec(chunk);
|
|
1913
|
+
const revisit = revisitRe.exec(chunk);
|
|
1914
|
+
const json = jsonRe.exec(chunk);
|
|
1915
|
+
if (!heading || !reason)
|
|
1916
|
+
continue;
|
|
1917
|
+
let proposal = {};
|
|
1918
|
+
if (json) {
|
|
1919
|
+
try {
|
|
1920
|
+
proposal = JSON.parse(json[1]);
|
|
1921
|
+
}
|
|
1922
|
+
catch {
|
|
1923
|
+
// Keep the human-readable half; the JSON only enriches it.
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
const decision = (proposal.decision || {});
|
|
1927
|
+
const content = (proposal.content || {});
|
|
1928
|
+
const trace = (content.trace || {});
|
|
1929
|
+
const claimKey = typeof proposal.claimKey === 'string' && proposal.claimKey
|
|
1930
|
+
? proposal.claimKey
|
|
1931
|
+
: parts[1] || heading[2];
|
|
1932
|
+
// The same claim can appear in more than one configured base. The first
|
|
1933
|
+
// base that carries it wins, matching how the tier merge resolves.
|
|
1934
|
+
const key = `${claimKey}:${String(decision.at || '')}`;
|
|
1935
|
+
if (seen.has(key))
|
|
1936
|
+
continue;
|
|
1937
|
+
seen.add(key);
|
|
1938
|
+
out.push({
|
|
1939
|
+
claimKey,
|
|
1940
|
+
title: heading[2].trim(),
|
|
1941
|
+
reason: reason[1].trim(),
|
|
1942
|
+
...(revisit ? { revisitCondition: revisit[1].trim() } : {}),
|
|
1943
|
+
// The heading and the stored decision agree; the heading is what a
|
|
1944
|
+
// manager reading the file sees, so it is the one that governs here.
|
|
1945
|
+
kind: heading[1] === 'Superseded' || decision.kind === 'superseded' ? 'superseded' : 'refused',
|
|
1946
|
+
rejectedAt: typeof decision.at === 'string' ? decision.at : '',
|
|
1947
|
+
...(typeof trace.recurrences === 'number' ? { recurrencesAtRejection: trace.recurrences } : {}),
|
|
1948
|
+
displayPath,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// Newest first: the manager is most often checking what they just said no to.
|
|
1953
|
+
return out.sort((a, b) => String(b.rejectedAt).localeCompare(String(a.rejectedAt)));
|
|
1954
|
+
}
|
|
1828
1955
|
function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
|
|
1829
1956
|
const roots = getLearningRoots(workspaceRoot);
|
|
1830
1957
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.modelAttributionIncomplete = modelAttributionIncomplete;
|
|
4
|
+
exports.countEvidenceProblem = countEvidenceProblem;
|
|
5
|
+
exports.usageEvidenceExclusions = usageEvidenceExclusions;
|
|
6
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
7
|
+
const validCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
8
|
+
/** Missing attribution is unknown, including a known bucket beside unattributed history. */
|
|
9
|
+
function modelAttributionIncomplete(record) {
|
|
10
|
+
const buckets = [record.offered.byModel, record.fired.byModel];
|
|
11
|
+
if (buckets.some(map => !map || Object.entries(map).some(([name, n]) => !validCount(n) || !(0, learning_usage_store_1.normalizeModelName)(name) || (0, learning_usage_store_1.normalizeModelName)(name) !== name)))
|
|
12
|
+
return true;
|
|
13
|
+
return Object.values(record.offered.byModel).reduce((a, b) => a + b, 0) !== record.offered.total
|
|
14
|
+
|| Object.values(record.fired.byModel).reduce((a, b) => a + b, 0)
|
|
15
|
+
!== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
|
|
16
|
+
}
|
|
17
|
+
/** Validate counts independently of a cached integrity flag. Never alter the record. */
|
|
18
|
+
function countEvidenceProblem(record, offersEraStart) {
|
|
19
|
+
const counts = [record.offered.total, record.offered.aboveThreshold, record.fired.total,
|
|
20
|
+
...Object.values(record.offered.byModel), ...Object.values(record.fired.byModel),
|
|
21
|
+
...Object.values(record.fired.byOutcome)];
|
|
22
|
+
if (counts.some(n => !validCount(n)) || learning_usage_store_1.FIRING_OUTCOMES.some(outcome => !validCount(record.fired.byOutcome[outcome]))
|
|
23
|
+
|| record.offered.aboveThreshold > record.offered.total
|
|
24
|
+
|| Object.values(record.fired.byOutcome).reduce((a, b) => a + b, 0) !== record.fired.total) {
|
|
25
|
+
return 'Usage counters are inconsistent.';
|
|
26
|
+
}
|
|
27
|
+
const violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
|
|
28
|
+
if (!offersEraStart && record.offered.total > 0 && record.fired.total > record.offered.total) {
|
|
29
|
+
return 'Excess uses lack provenance establishing the offer-recording era.';
|
|
30
|
+
}
|
|
31
|
+
return violation ? violation.detail : null;
|
|
32
|
+
}
|
|
33
|
+
/** Read-time exclusions follow the actual record, never ambient UI or model state. */
|
|
34
|
+
function usageEvidenceExclusions(store) {
|
|
35
|
+
const excluded = new Map();
|
|
36
|
+
for (const key of store.evidence?.excludedKeys ?? [])
|
|
37
|
+
excluded.set(key, 'Unreadable usage record.');
|
|
38
|
+
const byTitle = new Map();
|
|
39
|
+
for (const record of Object.values(store.entries)) {
|
|
40
|
+
const problem = countEvidenceProblem(record, store.offersEraStart);
|
|
41
|
+
if (store.evidence?.status === 'unavailable')
|
|
42
|
+
excluded.set(record.key, 'Usage history is unavailable.');
|
|
43
|
+
else if (problem)
|
|
44
|
+
excluded.set(record.key, problem);
|
|
45
|
+
if (!record.fileType)
|
|
46
|
+
continue;
|
|
47
|
+
for (const title of record.titles) {
|
|
48
|
+
const titleKey = (0, learning_usage_store_1.titleUsageKey)(record.fileType, title);
|
|
49
|
+
byTitle.set(titleKey, [...new Set([...(byTitle.get(titleKey) ?? []), record.key])]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const keys of byTitle.values()) {
|
|
53
|
+
if (keys.length > 1 && keys.some(key => key.startsWith('T:'))) {
|
|
54
|
+
for (const key of keys)
|
|
55
|
+
excluded.set(key, 'Ambiguous duplicate usage identity.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return excluded;
|
|
59
|
+
}
|
|
@@ -15,6 +15,7 @@ exports.resolveCurrentModel = resolveCurrentModel;
|
|
|
15
15
|
* Nothing here writes.
|
|
16
16
|
*/
|
|
17
17
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
18
|
+
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
18
19
|
function toLookupValue(record) {
|
|
19
20
|
return {
|
|
20
21
|
key: record.key,
|
|
@@ -24,7 +25,7 @@ function toLookupValue(record) {
|
|
|
24
25
|
firedTotalAttestations: record.fired.total,
|
|
25
26
|
lastFired: record.fired.lastFiredAt,
|
|
26
27
|
lastOffered: record.offered.lastOfferedAt,
|
|
27
|
-
firedUnder: Object.keys(record.fired.byModel),
|
|
28
|
+
firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
|
|
28
29
|
byOutcome: record.fired.byOutcome,
|
|
29
30
|
log: record.fired.log,
|
|
30
31
|
};
|
|
@@ -36,7 +37,10 @@ function toLookupValue(record) {
|
|
|
36
37
|
*/
|
|
37
38
|
function buildUsageLookup(store) {
|
|
38
39
|
const lookup = new Map();
|
|
40
|
+
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
39
41
|
for (const record of Object.values(store.entries)) {
|
|
42
|
+
if (excluded.has(record.key))
|
|
43
|
+
continue;
|
|
40
44
|
const value = toLookupValue(record);
|
|
41
45
|
lookup.set(record.key, value);
|
|
42
46
|
if (record.fileType) {
|