@elvisw/dsh-magic-context 0.0.0-bootstrap.0 → 0.0.0-stage
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/README.md +2 -452
- package/package.json +4 -108
- package/LICENSE +0 -23
- package/NOTICE +0 -93
- package/README.zh-CN.md +0 -368
- package/cordis.patch.yml +0 -20
- package/lib/client.js +0 -2270
- package/lib/core/compartments.js +0 -424
- package/lib/core/corpus-store.js +0 -176
- package/lib/core/directive-detect.js +0 -147
- package/lib/core/domain-reconcile.js +0 -394
- package/lib/core/domains.js +0 -127
- package/lib/core/embedding-local.js +0 -247
- package/lib/core/global-autostage.js +0 -223
- package/lib/core/global-domains.js +0 -302
- package/lib/core/journal.js +0 -248
- package/lib/core/llm-deadline.js +0 -53
- package/lib/core/machine-domain.js +0 -106
- package/lib/core/maintenance.js +0 -380
- package/lib/core/memory-bridge.js +0 -818
- package/lib/core/memory-categories.js +0 -84
- package/lib/core/memory-scoring.js +0 -65
- package/lib/core/memory-search.js +0 -414
- package/lib/core/memory-write.js +0 -1176
- package/lib/core/policy.js +0 -111
- package/lib/core/profile-confirmation.js +0 -55
- package/lib/core/profile-review.js +0 -375
- package/lib/core/profile-write.js +0 -283
- package/lib/core/reference-seeds.js +0 -63
- package/lib/core/route-health.js +0 -106
- package/lib/core/stores.js +0 -1021
- package/lib/core/tagger.js +0 -7
- package/lib/core/types.js +0 -15
- package/lib/core/vector-backfill.js +0 -140
- package/lib/core/vector-lane.js +0 -184
- package/lib/dsh/command-surface.js +0 -376
- package/lib/dsh/compaction-engine.js +0 -231
- package/lib/dsh/config-switches.js +0 -56
- package/lib/dsh/config.js +0 -13
- package/lib/dsh/context-pressure.js +0 -36
- package/lib/dsh/drop-landing.js +0 -42
- package/lib/dsh/emergency.js +0 -89
- package/lib/dsh/flush-command.js +0 -148
- package/lib/dsh/global-review.js +0 -412
- package/lib/dsh/historian.js +0 -907
- package/lib/dsh/live-journal.js +0 -81
- package/lib/dsh/maintenance-wiring.js +0 -229
- package/lib/dsh/management-pass.js +0 -564
- package/lib/dsh/memory-lane.js +0 -535
- package/lib/dsh/pre-step.js +0 -675
- package/lib/dsh/project-drift.js +0 -116
- package/lib/dsh/recomp.js +0 -192
- package/lib/dsh/reduce-guidance.js +0 -188
- package/lib/dsh/seams.js +0 -588
- package/lib/dsh/search-hint.js +0 -186
- package/lib/dsh/session-notice.js +0 -100
- package/lib/dsh/status-command.js +0 -75
- package/lib/dsh/telemetry.js +0 -349
- package/lib/dsh/tool-bridge.js +0 -1009
- package/lib/dsh/volatile.js +0 -89
- package/lib/index.js +0 -931
- package/tools/migrate-profile-package-name.mjs +0 -317
- package/vendor/onnx/LICENSE +0 -27
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +0 -59
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnx/ort.node.min.mjs +0 -8
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Directive-shape detection for profile candidates.
|
|
3
|
-
*
|
|
4
|
-
* Why this exists
|
|
5
|
-
* ---------------
|
|
6
|
-
* A user-profile entry that *describes* the person is safe to maintain
|
|
7
|
-
* automatically. An entry that *instructs the agent* is not: it is injected into
|
|
8
|
-
* every session on the machine, and a hallucinated behavioural rule ("accepts
|
|
9
|
-
* commits without tests") changes what the agent does everywhere. Magic
|
|
10
|
-
* Context promoted both kinds automatically — its only gate was a pool-size
|
|
11
|
-
* count, and the "at least N semantically similar variants" requirement lived
|
|
12
|
-
* in the prompt with no code behind it. Of the 17 live profile rows two thirds
|
|
13
|
-
* turned out to be behavioural rules.
|
|
14
|
-
*
|
|
15
|
-
* So: directive-shaped content must be confirmed by a human before it can take
|
|
16
|
-
* effect. This module decides WHICH candidates have to ask.
|
|
17
|
-
*
|
|
18
|
-
* The error asymmetry is the whole design:
|
|
19
|
-
* - a false POSITIVE costs one question (annoying, safe);
|
|
20
|
-
* - a false NEGATIVE lets an unreviewed rule into every session (the failure
|
|
21
|
-
* this exists to prevent).
|
|
22
|
-
* The rule set is therefore built to over-trigger, and the regression suite
|
|
23
|
-
* (tests/directive-detect.spec.ts) requires ZERO misses on the labelled corpus
|
|
24
|
-
* while merely BOUNDING the harmless direction.
|
|
25
|
-
*
|
|
26
|
-
* A second signal keeps the noise down: an obligation is only directive-shaped
|
|
27
|
-
* when it targets something an agent can DO. "Not accepting a downstream
|
|
28
|
-
* fallback for data problems" is an attitude (descriptive); "must discuss in
|
|
29
|
-
* plan mode before writing any file" is an instruction.
|
|
30
|
-
*/
|
|
31
|
-
/** An obligation must reach this weight before the shape is directive. */
|
|
32
|
-
export const DIRECTIVE_SCORE_THRESHOLD = 2;
|
|
33
|
-
/** Obligations strong enough to stand alone. */
|
|
34
|
-
const HARD_RULES = [
|
|
35
|
-
{ rule: 'obligation:必须', weight: 2, pattern: /必须/ },
|
|
36
|
-
{ rule: 'obligation:务必', weight: 2, pattern: /务必/ },
|
|
37
|
-
{ rule: 'prohibition:严禁', weight: 2, pattern: /严禁/ },
|
|
38
|
-
{ rule: 'prohibition:禁止', weight: 2, pattern: /禁止/ },
|
|
39
|
-
{ rule: 'prohibition:不得', weight: 2, pattern: /不得/ },
|
|
40
|
-
{ rule: 'prohibition:不允许', weight: 2, pattern: /不允许/ },
|
|
41
|
-
{ rule: 'prohibition:不可以', weight: 2, pattern: /不可以/ },
|
|
42
|
-
{ rule: 'obligation:一律', weight: 2, pattern: /一律/ },
|
|
43
|
-
{ rule: 'obligation:只能', weight: 2, pattern: /只能/ },
|
|
44
|
-
{ rule: 'gate:未确认前不', weight: 2, pattern: /未[^。;]{0,12}前不/ },
|
|
45
|
-
{ rule: 'gate:放行门禁', weight: 2, pattern: /放行门禁/ },
|
|
46
|
-
{ rule: 'obligation:en-must', weight: 2, pattern: /\bmust\b/i },
|
|
47
|
-
{ rule: 'prohibition:en-never', weight: 2, pattern: /\bnever\b/i },
|
|
48
|
-
{ rule: 'obligation:en-always', weight: 2, pattern: /\balways\b/i },
|
|
49
|
-
{ rule: 'prohibition:en-do-not', weight: 2, pattern: /\bdo not\b/i },
|
|
50
|
-
{ rule: 'obligation:en-required-to', weight: 2, pattern: /\brequired to\b/i },
|
|
51
|
-
{ rule: 'gate:en-before-doing', weight: 2, pattern: /\bbefore (writing|making|committing|pushing|deploying|changing|running)/i },
|
|
52
|
-
];
|
|
53
|
-
/**
|
|
54
|
-
* Requirements that carry weight only together or with an explicit gate. The
|
|
55
|
-
* `要求…先/再/才` shape is the most valuable one: it describes a process the
|
|
56
|
-
* agent must follow, which is exactly what a directive is.
|
|
57
|
-
*/
|
|
58
|
-
const SOFT_RULES = [
|
|
59
|
-
{ rule: 'requirement:要求…先/再/才/不要', weight: 2, pattern: /要求[^。;]{0,20}(先|再|才|必须|不要|不得)/ },
|
|
60
|
-
{ rule: 'refusal:拒绝…任何/用', weight: 2, pattern: /拒绝[^。;]{0,12}(任何|用)/ },
|
|
61
|
-
{ rule: 'refusal:不接受…兜底/妥协/凑数', weight: 2, pattern: /不接受[^。;]{0,12}(兜底|妥协|凑数)/ },
|
|
62
|
-
{ rule: 'insistence:坚持…以/把…作为/为准', weight: 1, pattern: /坚持[^。;]{0,12}(以|把)[^。;]{0,24}(作为|为准)/ },
|
|
63
|
-
{ rule: 'process:强制', weight: 1, pattern: /强制/ },
|
|
64
|
-
{ rule: 'process:严格执行', weight: 1, pattern: /严格执行/ },
|
|
65
|
-
{ rule: 'process:严格顺序', weight: 1, pattern: /严格顺序/ },
|
|
66
|
-
];
|
|
67
|
-
/**
|
|
68
|
-
* Something an agent can do. An obligation that targets none of these is about
|
|
69
|
-
* the world (a standard, a preference, a data-quality stance), not an
|
|
70
|
-
* instruction to the agent.
|
|
71
|
-
*/
|
|
72
|
-
const ACTION_RULES = [
|
|
73
|
-
{ rule: 'action:写文件/代码', weight: 0, pattern: /写(文件|代码)/ },
|
|
74
|
-
{ rule: 'action:推送', weight: 0, pattern: /推送/ },
|
|
75
|
-
{ rule: 'action:提交', weight: 0, pattern: /提交/ },
|
|
76
|
-
{ rule: 'action:部署', weight: 0, pattern: /部署/ },
|
|
77
|
-
{ rule: 'action:修复', weight: 0, pattern: /修复/ },
|
|
78
|
-
{ rule: 'action:删除', weight: 0, pattern: /删除/ },
|
|
79
|
-
{ rule: 'action:提醒/提示', weight: 0, pattern: /提醒|提示|通知/ },
|
|
80
|
-
{ rule: 'action:报告/汇报', weight: 0, pattern: /报告|汇报/ },
|
|
81
|
-
{ rule: 'action:运行/执行', weight: 0, pattern: /运行|执行/ },
|
|
82
|
-
{ rule: 'action:测试/验证', weight: 0, pattern: /测试|验证/ },
|
|
83
|
-
{ rule: 'action:落地/推进', weight: 0, pattern: /落地|推进/ },
|
|
84
|
-
{ rule: 'action:确认/核对', weight: 0, pattern: /确认|核对|核实/ },
|
|
85
|
-
{ rule: 'action:评估', weight: 0, pattern: /评估/ },
|
|
86
|
-
{ rule: 'action:清理/删除', weight: 0, pattern: /清理/ },
|
|
87
|
-
{ rule: 'action:保持/避免', weight: 0, pattern: /保持|避免/ },
|
|
88
|
-
{ rule: 'action:遵循/遵守', weight: 0, pattern: /遵循|遵守/ },
|
|
89
|
-
{ rule: 'action:备份/安装/配置', weight: 0, pattern: /备份|安装|配置/ },
|
|
90
|
-
{ rule: 'action:切换/回退', weight: 0, pattern: /切换|回退|切回/ },
|
|
91
|
-
{ rule: 'action:en-verb', weight: 0, pattern: /\b(run|push|commit|test|write|delete|deploy|verify|confirm|wait|backup|review|ensure|check|update|refactor)\b/i },
|
|
92
|
-
];
|
|
93
|
-
const ALL_RULES = [...HARD_RULES, ...SOFT_RULES, ...ACTION_RULES];
|
|
94
|
-
function kindOf(rule) {
|
|
95
|
-
if (HARD_RULES.includes(rule))
|
|
96
|
-
return 'hard';
|
|
97
|
-
if (SOFT_RULES.includes(rule))
|
|
98
|
-
return 'soft';
|
|
99
|
-
return 'action';
|
|
100
|
-
}
|
|
101
|
-
/** Every rule that fires, in declaration order, with the matched text. */
|
|
102
|
-
export function detectDirectiveHits(text) {
|
|
103
|
-
const hits = [];
|
|
104
|
-
for (const rule of ALL_RULES) {
|
|
105
|
-
const match = rule.pattern.exec(text);
|
|
106
|
-
if (match !== null) {
|
|
107
|
-
hits.push({ rule: rule.rule, kind: kindOf(rule), match: match[0] });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return hits;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Classify one profile candidate. Conservative by construction: the shape is
|
|
114
|
-
* `directive` when the obligation weight reaches the threshold AND at least one
|
|
115
|
-
* action cue is present.
|
|
116
|
-
*/
|
|
117
|
-
export function classifyProfileShape(text) {
|
|
118
|
-
const hits = detectDirectiveHits(text);
|
|
119
|
-
const weightOf = (rule) => ALL_RULES.find(candidate => candidate.rule === rule)?.weight ?? 0;
|
|
120
|
-
const score = hits.reduce((total, hit) => total + weightOf(hit.rule), 0);
|
|
121
|
-
const hasAction = hits.some(hit => hit.kind === 'action');
|
|
122
|
-
const shape = score >= DIRECTIVE_SCORE_THRESHOLD && hasAction ? 'directive' : 'descriptive';
|
|
123
|
-
return {
|
|
124
|
-
shape,
|
|
125
|
-
score,
|
|
126
|
-
hits,
|
|
127
|
-
reasons: hits.map(hit => hit.rule),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
export function isDirectiveShaped(text) {
|
|
131
|
-
return classifyProfileShape(text).shape === 'directive';
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* The question a directive-shaped candidate has to answer before it may enter
|
|
135
|
-
* the profile. It states the blast radius, because that is what the human is
|
|
136
|
-
* actually deciding about.
|
|
137
|
-
*/
|
|
138
|
-
export function buildConfirmationQuestion(content, verdict) {
|
|
139
|
-
const why = verdict.hits.filter(hit => hit.kind !== 'action').map(hit => hit.rule).join(', ');
|
|
140
|
-
return [
|
|
141
|
-
'一条画像候选读起来像**行为指令**,而不是对使用者的描述。',
|
|
142
|
-
`候选内容:${content}`,
|
|
143
|
-
why === '' ? '' : `判定依据:${why}`,
|
|
144
|
-
'确认后它会在**所有会话**中生效(不分项目)。',
|
|
145
|
-
'请回复:确认 / 改为描述型 / 丢弃。',
|
|
146
|
-
].filter(line => line !== '').join('\n');
|
|
147
|
-
}
|
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Domain-row reconciliation — "publishing is a copy, not a mirror".
|
|
3
|
-
*
|
|
4
|
-
* A human approval publishes a candidate into the shared contract store under a
|
|
5
|
-
* domain identity (`dir:global:org` / `dir:global:machine`), and that is where
|
|
6
|
-
* the lane stops: the published row is a COPY. When the source memory is later
|
|
7
|
-
* rewritten or archived, nothing propagates, and nothing notices. Measured on
|
|
8
|
-
* the live store (2026-10-16): 27 candidates, 21 linked to a domain row, 3 of
|
|
9
|
-
* those stale.
|
|
10
|
-
*
|
|
11
|
-
* This module is the missing audit plus the repair PLAN — never the repair's
|
|
12
|
-
* dangerous half:
|
|
13
|
-
*
|
|
14
|
-
* - `source-rewritten` → stage a NEW pending candidate for that domain, so a
|
|
15
|
-
* human can approve republishing the source's current text. Nothing is
|
|
16
|
-
* published here, and the stale row is deliberately left standing.
|
|
17
|
-
* - `source-archived` / `source-missing` → archive the domain row
|
|
18
|
-
* (`status='archived'`, never a DELETE). A withdrawal is as much a human
|
|
19
|
-
* decision as a publish, so it only ever happens behind an explicit
|
|
20
|
-
* `--write`.
|
|
21
|
-
*
|
|
22
|
-
* The red line this file exists to keep: **the reconciler must never publish.**
|
|
23
|
-
* It therefore has no seam for it at all — {@link DomainRepairPort} carries
|
|
24
|
-
* exactly two writes (archive one row, stage one candidate) and `applyRepair`
|
|
25
|
-
* can do nothing else with them. `tests/domain-reconcile.spec.ts` proves the line
|
|
26
|
-
* by handing `applyRepair` a publish function that throws.
|
|
27
|
-
*
|
|
28
|
-
* Everything here is pure: plain arrays in, plain data out, no database, no
|
|
29
|
-
* filesystem, no printing. `scripts/domain-reconcile.mjs` owns the SQL, the
|
|
30
|
-
* backup gate and the Chinese report; the module is what can be tested.
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
33
|
-
* The contract's "still there" vocabulary. `permanent` is a live status (the
|
|
34
|
-
* promotion path can set it), so a predicate named `status !== 'active'` would
|
|
35
|
-
* read every permanent source as dead and propose withdrawing healthy rows.
|
|
36
|
-
* The corpus holds only `active`/`archived` today; the second value is here so
|
|
37
|
-
* the audit cannot misfire the day one appears.
|
|
38
|
-
*/
|
|
39
|
-
const ALIVE_STATUSES = new Set(['active', 'permanent']);
|
|
40
|
-
function isAlive(status) {
|
|
41
|
-
return ALIVE_STATUSES.has(status);
|
|
42
|
-
}
|
|
43
|
-
const KEY_SEP = '\u0000';
|
|
44
|
-
/** `(domain_id, normalized_hash)` as one map key. NUL cannot occur in either. */
|
|
45
|
-
function linkKey(domainId, normalizedHash) {
|
|
46
|
-
return `${domainId}${KEY_SEP}${normalizedHash}`;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* The one row a candidate is linked to.
|
|
50
|
-
*
|
|
51
|
-
* The contract allows the same hash twice under one domain when the categories
|
|
52
|
-
* differ (`UNIQUE(project_path, category, normalized_hash)`), so "the" row needs
|
|
53
|
-
* a rule: the one whose category matches the candidate's, else the lowest id.
|
|
54
|
-
* Deterministic on purpose — the audit must not depend on read order.
|
|
55
|
-
*/
|
|
56
|
-
function pickDomainRow(rows, category) {
|
|
57
|
-
if (rows === undefined || rows.length === 0)
|
|
58
|
-
return undefined;
|
|
59
|
-
const exact = rows.filter(row => row.category === category);
|
|
60
|
-
const pool = exact.length > 0 ? exact : rows;
|
|
61
|
-
return pool.reduce((lowest, row) => (row.id < lowest.id ? row : lowest), pool[0]);
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Is the source's CURRENT text already handled under this domain?
|
|
65
|
-
*
|
|
66
|
-
* Three honest forms of coverage: an ALIVE domain row holding that text; a
|
|
67
|
-
* PENDING candidate proposing it (the design's own repair — a rewrite stages a
|
|
68
|
-
* proposal rather than republishing); or a REJECTED candidate for exactly that
|
|
69
|
-
* text, which is a recorded human decision rather than an outstanding task. A
|
|
70
|
-
* merely `approved` candidate proves nothing: approval is a state flag, and the
|
|
71
|
-
* point of this audit is that the copy it produced may since have been
|
|
72
|
-
* withdrawn (live case #1420, archived).
|
|
73
|
-
*/
|
|
74
|
-
function findCoverage(domainId, sourceNormalizedHash, rowsByKey, candidatesByKey) {
|
|
75
|
-
const key = linkKey(domainId, sourceNormalizedHash);
|
|
76
|
-
for (const row of rowsByKey.get(key) ?? []) {
|
|
77
|
-
if (isAlive(row.status))
|
|
78
|
-
return { via: 'published', coveredBy: row.id };
|
|
79
|
-
}
|
|
80
|
-
for (const candidate of candidatesByKey.get(key) ?? []) {
|
|
81
|
-
if (candidate.state === 'pending')
|
|
82
|
-
return { via: 'staged', coveredBy: candidate.id };
|
|
83
|
-
}
|
|
84
|
-
for (const candidate of candidatesByKey.get(key) ?? []) {
|
|
85
|
-
if (candidate.state === 'rejected')
|
|
86
|
-
return { via: 'declined', coveredBy: candidate.id };
|
|
87
|
-
}
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
function makeDivergence(kind, candidate, row, sourceMemoryId, source) {
|
|
91
|
-
return {
|
|
92
|
-
kind,
|
|
93
|
-
candidateId: candidate.id,
|
|
94
|
-
domainId: candidate.domainId,
|
|
95
|
-
domainRowId: row.id,
|
|
96
|
-
sourceMemoryId,
|
|
97
|
-
publishedNormalizedHash: candidate.normalizedHash,
|
|
98
|
-
sourceNormalizedHash: source?.normalizedHash ?? null,
|
|
99
|
-
sourceCategory: source?.category ?? null,
|
|
100
|
-
sourceIdentity: source?.projectPath ?? null,
|
|
101
|
-
sourceContent: source?.content ?? null,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Classify one candidate. Exhaustive by construction: `checked` always equals
|
|
106
|
-
* `healthy + unlinked + divergences.length` (the spec asserts it).
|
|
107
|
-
*/
|
|
108
|
-
function classifyCandidate(candidate, rowsByKey, sourcesById, candidatesByKey) {
|
|
109
|
-
const row = pickDomainRow(rowsByKey.get(linkKey(candidate.domainId, candidate.normalizedHash)), candidate.category);
|
|
110
|
-
if (row === undefined)
|
|
111
|
-
return { outcome: 'unlinked' };
|
|
112
|
-
// A domain row that is already withdrawn cannot disagree with anything: the
|
|
113
|
-
// reviewer is looking at a fact nobody can see any more (semantics 3). It
|
|
114
|
-
// still counts as a healthy LINK — the number is reported separately so it is
|
|
115
|
-
// never silently padded.
|
|
116
|
-
if (!isAlive(row.status)) {
|
|
117
|
-
return { outcome: 'skipped-archived-row', domainRowId: row.id };
|
|
118
|
-
}
|
|
119
|
-
// Only an approved candidate is the authority for a published row; a rejected
|
|
120
|
-
// or still-pending one has never published anything, so it cannot be stale.
|
|
121
|
-
if (candidate.state !== 'approved')
|
|
122
|
-
return { outcome: 'healthy' };
|
|
123
|
-
const sourceMemoryId = candidate.sourceMemoryId;
|
|
124
|
-
// Machine-detected candidate: the link itself is the whole comparison, and
|
|
125
|
-
// there is no source that could have moved on.
|
|
126
|
-
if (sourceMemoryId === null || sourceMemoryId === undefined)
|
|
127
|
-
return { outcome: 'healthy' };
|
|
128
|
-
const source = sourcesById.get(sourceMemoryId);
|
|
129
|
-
if (source === undefined) {
|
|
130
|
-
return { outcome: 'divergence', divergence: makeDivergence('source-missing', candidate, row, sourceMemoryId, undefined) };
|
|
131
|
-
}
|
|
132
|
-
if (!isAlive(source.status)) {
|
|
133
|
-
return { outcome: 'divergence', divergence: makeDivergence('source-archived', candidate, row, sourceMemoryId, source) };
|
|
134
|
-
}
|
|
135
|
-
if (source.normalizedHash !== candidate.normalizedHash) {
|
|
136
|
-
// The source moved on. Before calling that a divergence, ask whether its
|
|
137
|
-
// CURRENT text is already handled under this domain — otherwise a repaired
|
|
138
|
-
// domain could never settle (the stale pair keeps its old hash forever).
|
|
139
|
-
const coverage = findCoverage(candidate.domainId, source.normalizedHash, rowsByKey, candidatesByKey);
|
|
140
|
-
if (coverage !== undefined) {
|
|
141
|
-
return {
|
|
142
|
-
outcome: 'covered',
|
|
143
|
-
covered: {
|
|
144
|
-
candidateId: candidate.id,
|
|
145
|
-
domainRowId: row.id,
|
|
146
|
-
sourceMemoryId,
|
|
147
|
-
via: coverage.via,
|
|
148
|
-
coveredBy: coverage.coveredBy,
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
return { outcome: 'divergence', divergence: makeDivergence('source-rewritten', candidate, row, sourceMemoryId, source) };
|
|
153
|
-
}
|
|
154
|
-
return { outcome: 'healthy' };
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* The read-only half: what disagrees with what.
|
|
158
|
-
*
|
|
159
|
-
* `checked` counts every candidate given, which is the whole queue — rejected
|
|
160
|
-
* and pending candidates land in `unlinked` because they were never published.
|
|
161
|
-
* That is not a fault (the live store's 6 unlinked candidates are exactly 5
|
|
162
|
-
* rejected + 1 pending), so every caller must print `unlinked` as a note, never
|
|
163
|
-
* as an alarm.
|
|
164
|
-
*/
|
|
165
|
-
export function auditDomainDivergences(input) {
|
|
166
|
-
const rowsByKey = new Map();
|
|
167
|
-
for (const row of input.domainRows) {
|
|
168
|
-
const key = linkKey(row.domainId, row.normalizedHash);
|
|
169
|
-
const bucket = rowsByKey.get(key);
|
|
170
|
-
if (bucket === undefined)
|
|
171
|
-
rowsByKey.set(key, [row]);
|
|
172
|
-
else
|
|
173
|
-
bucket.push(row);
|
|
174
|
-
}
|
|
175
|
-
const sourcesById = new Map();
|
|
176
|
-
for (const memory of input.sourceMemories)
|
|
177
|
-
sourcesById.set(memory.id, memory);
|
|
178
|
-
// Every candidate, by key: coverage can come from a DIFFERENT candidate than
|
|
179
|
-
// the one being classified (the proposal staged for this very rewrite).
|
|
180
|
-
const candidatesByKey = new Map();
|
|
181
|
-
for (const candidate of input.candidates) {
|
|
182
|
-
const key = linkKey(candidate.domainId, candidate.normalizedHash);
|
|
183
|
-
const bucket = candidatesByKey.get(key);
|
|
184
|
-
if (bucket === undefined)
|
|
185
|
-
candidatesByKey.set(key, [candidate]);
|
|
186
|
-
else
|
|
187
|
-
bucket.push(candidate);
|
|
188
|
-
}
|
|
189
|
-
let checked = 0;
|
|
190
|
-
let healthy = 0;
|
|
191
|
-
let unlinked = 0;
|
|
192
|
-
const unlinkedCandidateIds = [];
|
|
193
|
-
const skippedArchivedDomainRows = [];
|
|
194
|
-
const covered = [];
|
|
195
|
-
const divergences = [];
|
|
196
|
-
for (const candidate of input.candidates) {
|
|
197
|
-
checked += 1;
|
|
198
|
-
const verdict = classifyCandidate(candidate, rowsByKey, sourcesById, candidatesByKey);
|
|
199
|
-
if (verdict.outcome === 'unlinked') {
|
|
200
|
-
unlinked += 1;
|
|
201
|
-
unlinkedCandidateIds.push(candidate.id);
|
|
202
|
-
}
|
|
203
|
-
else if (verdict.outcome === 'healthy') {
|
|
204
|
-
healthy += 1;
|
|
205
|
-
}
|
|
206
|
-
else if (verdict.outcome === 'skipped-archived-row') {
|
|
207
|
-
healthy += 1;
|
|
208
|
-
skippedArchivedDomainRows.push({ candidateId: candidate.id, domainRowId: verdict.domainRowId });
|
|
209
|
-
}
|
|
210
|
-
else if (verdict.outcome === 'covered') {
|
|
211
|
-
// Nothing to repair, so nothing keeps it out of `healthy`; listed anyway.
|
|
212
|
-
healthy += 1;
|
|
213
|
-
covered.push(verdict.covered);
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
divergences.push(verdict.divergence);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
checked,
|
|
221
|
-
healthy,
|
|
222
|
-
unlinked,
|
|
223
|
-
unlinkedCandidateIds,
|
|
224
|
-
skippedArchivedDomainRows,
|
|
225
|
-
covered: covered.slice().sort((left, right) => left.candidateId - right.candidateId),
|
|
226
|
-
divergences: divergences.slice().sort((left, right) => left.candidateId - right.candidateId),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
/** `dir:global:machine` → 'machine'. Anything else → null (never guessed). */
|
|
230
|
-
function suggestedDomainFor(domainId) {
|
|
231
|
-
if (domainId.endsWith(':machine'))
|
|
232
|
-
return 'machine';
|
|
233
|
-
if (domainId.endsWith(':org'))
|
|
234
|
-
return 'org';
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
/** The one-line "why" the review card shows for a staged proposal. */
|
|
238
|
-
export function reconcileReason(divergence) {
|
|
239
|
-
return `domain-reconcile: the published copy under ${divergence.domainId} is stale — source memory `
|
|
240
|
-
+ `#${String(divergence.sourceMemoryId)} was rewritten after domain row #${String(divergence.domainRowId)} `
|
|
241
|
-
+ `was published from hash ${divergence.publishedNormalizedHash}; approving republishes the current text `
|
|
242
|
-
+ '(the stale row is left in place for a human to withdraw).';
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Turn a report into the exact writes a repair would perform. Pure, and still
|
|
246
|
-
* not applied: the CLI decides, and only `--write` gets there.
|
|
247
|
-
*
|
|
248
|
-
* Two rules are load-bearing:
|
|
249
|
-
*
|
|
250
|
-
* 1. **A rewrite never archives.** The stale row is a fact somebody approved;
|
|
251
|
-
* the proposal adds a new one and leaves the old decision visible.
|
|
252
|
-
* 2. **One proposal per unique key.** The adapter's unique index is
|
|
253
|
-
* `(domain_id, normalized_hash)`, so a second idempotent write of the same
|
|
254
|
-
* proposal would upsert the first one's row — and that upsert rewrites
|
|
255
|
-
* `content` even for an APPROVED candidate. Collapsing here (and again at
|
|
256
|
-
* the write seam) is what makes a repeated `--write` a no-op.
|
|
257
|
-
*/
|
|
258
|
-
export function planRepair(report) {
|
|
259
|
-
const archiveRowIds = [];
|
|
260
|
-
const archiveSeen = new Set();
|
|
261
|
-
const stageCandidates = [];
|
|
262
|
-
const stageIndex = new Map();
|
|
263
|
-
const unrepairable = [];
|
|
264
|
-
const archive = (rowId) => {
|
|
265
|
-
if (archiveSeen.has(rowId))
|
|
266
|
-
return;
|
|
267
|
-
archiveSeen.add(rowId);
|
|
268
|
-
archiveRowIds.push(rowId);
|
|
269
|
-
};
|
|
270
|
-
for (const divergence of report.divergences) {
|
|
271
|
-
if (divergence.kind !== 'source-rewritten') {
|
|
272
|
-
archive(divergence.domainRowId);
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
const { sourceContent, sourceNormalizedHash, sourceCategory, sourceIdentity } = divergence;
|
|
276
|
-
if (sourceContent === null || sourceNormalizedHash === null || sourceCategory === null || sourceIdentity === null) {
|
|
277
|
-
unrepairable.push({
|
|
278
|
-
candidateId: divergence.candidateId,
|
|
279
|
-
domainId: divergence.domainId,
|
|
280
|
-
kind: divergence.kind,
|
|
281
|
-
reason: 'the audit classified this as a rewrite but carried no source text — refusing to guess a proposal',
|
|
282
|
-
});
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
const key = linkKey(divergence.domainId, sourceNormalizedHash);
|
|
286
|
-
const existing = stageIndex.get(key);
|
|
287
|
-
const derived = { candidateId: divergence.candidateId, domainRowId: divergence.domainRowId, sourceMemoryId: divergence.sourceMemoryId };
|
|
288
|
-
if (existing !== undefined) {
|
|
289
|
-
const kept = stageCandidates[existing];
|
|
290
|
-
stageCandidates[existing] = { ...kept, derivedFrom: [...kept.derivedFrom, derived] };
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
stageIndex.set(key, stageCandidates.length);
|
|
294
|
-
stageCandidates.push({
|
|
295
|
-
domainId: divergence.domainId,
|
|
296
|
-
content: sourceContent,
|
|
297
|
-
normalizedHash: sourceNormalizedHash,
|
|
298
|
-
category: sourceCategory,
|
|
299
|
-
sourceMemoryId: divergence.sourceMemoryId,
|
|
300
|
-
sourceIdentity,
|
|
301
|
-
suggestedDomain: suggestedDomainFor(divergence.domainId),
|
|
302
|
-
reason: reconcileReason(divergence),
|
|
303
|
-
derivedFrom: [derived],
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
archiveRowIds.sort((left, right) => left - right);
|
|
307
|
-
return { archiveRowIds, stageCandidates, unrepairable };
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Apply a plan through the injected port. This is the only function that writes,
|
|
311
|
-
* and the only two things it can write are the two the port exposes.
|
|
312
|
-
*
|
|
313
|
-
* Withdrawals run first, then proposals: a refusal while staging must not leave
|
|
314
|
-
* a half-applied plan looking complete, and the two sets are disjoint by
|
|
315
|
-
* construction (one divergence is either a rewrite or a dead source, never
|
|
316
|
-
* both). Every action is reported individually — a partial run prints as a
|
|
317
|
-
* partial run.
|
|
318
|
-
*/
|
|
319
|
-
export function applyRepair(plan, port, options = {}) {
|
|
320
|
-
const at = options.now ?? Date.now();
|
|
321
|
-
const journal = options.journal;
|
|
322
|
-
const archived = [];
|
|
323
|
-
const archiveRefusals = [];
|
|
324
|
-
const staged = [];
|
|
325
|
-
const stageRefusals = [];
|
|
326
|
-
for (const rowId of plan.archiveRowIds) {
|
|
327
|
-
const result = port.archiveDomainRow(rowId);
|
|
328
|
-
if (result.ok) {
|
|
329
|
-
if (result.changed)
|
|
330
|
-
archived.push(rowId);
|
|
331
|
-
else
|
|
332
|
-
archiveRefusals.push({ rowId, error: 'already archived (idempotent re-run)' });
|
|
333
|
-
journal?.({
|
|
334
|
-
at,
|
|
335
|
-
action: result.changed ? 'archive' : 'refuse',
|
|
336
|
-
summary: result.changed
|
|
337
|
-
? `archived domain row #${String(rowId)} (its source memory is gone)`
|
|
338
|
-
: `domain row #${String(rowId)} was already archived`,
|
|
339
|
-
data: { rowId, changed: result.changed },
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
else {
|
|
343
|
-
archiveRefusals.push({ rowId, error: result.error });
|
|
344
|
-
journal?.({ at, action: 'refuse', summary: `archive refused for domain row #${String(rowId)}`, data: { rowId, error: result.error } });
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
for (const proposal of plan.stageCandidates) {
|
|
348
|
-
const result = port.stageCandidate(proposal);
|
|
349
|
-
if (result.ok) {
|
|
350
|
-
const reproposed = result.reproposed === true;
|
|
351
|
-
staged.push({
|
|
352
|
-
domainId: proposal.domainId,
|
|
353
|
-
normalizedHash: proposal.normalizedHash,
|
|
354
|
-
sourceMemoryId: proposal.sourceMemoryId,
|
|
355
|
-
candidateId: result.candidateId,
|
|
356
|
-
created: result.created,
|
|
357
|
-
reproposed,
|
|
358
|
-
});
|
|
359
|
-
journal?.({
|
|
360
|
-
at,
|
|
361
|
-
action: 'stage',
|
|
362
|
-
summary: result.created
|
|
363
|
-
? `staged candidate #${String(result.candidateId)} for ${proposal.domainId} (awaiting human approval)`
|
|
364
|
-
: reproposed
|
|
365
|
-
? `re-proposed dead candidate #${String(result.candidateId)} for ${proposal.domainId} (its published copy is archived; awaiting human approval again)`
|
|
366
|
-
: `candidate #${String(result.candidateId)} already staged for ${proposal.domainId}`,
|
|
367
|
-
data: {
|
|
368
|
-
candidateId: result.candidateId,
|
|
369
|
-
domainId: proposal.domainId,
|
|
370
|
-
normalizedHash: proposal.normalizedHash,
|
|
371
|
-
sourceMemoryId: proposal.sourceMemoryId,
|
|
372
|
-
derivedFrom: proposal.derivedFrom,
|
|
373
|
-
created: result.created,
|
|
374
|
-
reproposed,
|
|
375
|
-
},
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
stageRefusals.push({
|
|
380
|
-
domainId: proposal.domainId,
|
|
381
|
-
normalizedHash: proposal.normalizedHash,
|
|
382
|
-
sourceMemoryId: proposal.sourceMemoryId,
|
|
383
|
-
error: result.error,
|
|
384
|
-
});
|
|
385
|
-
journal?.({
|
|
386
|
-
at,
|
|
387
|
-
action: 'refuse',
|
|
388
|
-
summary: `staging refused for ${proposal.domainId}`,
|
|
389
|
-
data: { domainId: proposal.domainId, normalizedHash: proposal.normalizedHash, error: result.error },
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return { archived, archiveRefusals, staged, stageRefusals, unrepairable: plan.unrepairable };
|
|
394
|
-
}
|
package/lib/core/domains.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The adapter's global domains: `user` (the person), `org` (organisation
|
|
3
|
-
* knowledge), `machine` (this machine's environment).
|
|
4
|
-
*
|
|
5
|
-
* Display naming (2026-10-25, docs/domain-naming-work-global-2026-10-25.md):
|
|
6
|
-
* the axis is AUDIENCE, not distribution — `machine` surfaces as 全局/global
|
|
7
|
-
* (every project on this machine), `org` as 工作/work (a hand-curated member
|
|
8
|
-
* set of work projects). The internal tokens below are storage keys and never
|
|
9
|
-
* rename.
|
|
10
|
-
*
|
|
11
|
-
* Why domains exist at all
|
|
12
|
-
* ------------------------
|
|
13
|
-
* A *global* memory is one that is not owned by any single project. Magic
|
|
14
|
-
* Context ships exactly one such lane — the user profile — because a profile
|
|
15
|
-
* "applies everywhere" and therefore needs no activation rule. Organisation
|
|
16
|
-
* knowledge does not have that property: the company database pointer must be
|
|
17
|
-
* invisible in an open-source project's session. So the domain axis answers
|
|
18
|
-
* exactly one question — **under what circumstances should this be visible?** —
|
|
19
|
-
* and deliberately does not answer "what is it about" (that is `category`).
|
|
20
|
-
*
|
|
21
|
-
* One domain = one activation rule + one budget + one cap derived from it.
|
|
22
|
-
* The kind set is closed at three on purpose: a new kind has to earn its place
|
|
23
|
-
* with an independent activation rule, audience and budget.
|
|
24
|
-
*
|
|
25
|
-
* See docs/dsh-plugin-platform-takeover-and-global-domains-2026-10-09.md.
|
|
26
|
-
*/
|
|
27
|
-
import { V2_MEMORY_CATEGORIES } from "./memory-categories.js";
|
|
28
|
-
/** The closed set of global domains. */
|
|
29
|
-
export const DOMAIN_KINDS = ['user', 'org', 'machine'];
|
|
30
|
-
export function isDomainKind(value) {
|
|
31
|
-
return typeof value === 'string' && DOMAIN_KINDS.includes(value);
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Per-domain *render* budget in tokens. These are the numbers the composed
|
|
35
|
-
* baseline must respect; they are not the same as the cap's source (below)
|
|
36
|
-
* because `user` renders across two blocks (m[0] profile + m[1] delta) while
|
|
37
|
-
* remaining a curated, small set.
|
|
38
|
-
*/
|
|
39
|
-
export const DEFAULT_DOMAIN_BUDGET_TOKENS = {
|
|
40
|
-
user: 5000,
|
|
41
|
-
org: 768,
|
|
42
|
-
machine: 512,
|
|
43
|
-
};
|
|
44
|
-
/**
|
|
45
|
-
* The curation target a domain's cap is derived from. It differs from the
|
|
46
|
-
* render budget for `user` on purpose: the profile renders under a 4000-token
|
|
47
|
-
* m[0] budget, but an unbounded set of behavioural notes is not a curated
|
|
48
|
-
* profile — the cap has to be a product decision expressed as a token target.
|
|
49
|
-
*/
|
|
50
|
-
export const DEFAULT_DOMAIN_CAP_BUDGET_TOKENS = {
|
|
51
|
-
user: 750,
|
|
52
|
-
org: 768,
|
|
53
|
-
machine: 500,
|
|
54
|
-
};
|
|
55
|
-
/** Estimated tokens for one rendered memory line (content + list overhead). */
|
|
56
|
-
export const DOMAIN_TOKENS_PER_LINE = 20;
|
|
57
|
-
/** Headroom kept below the cap budget, so a domain never sits exactly at its ceiling. */
|
|
58
|
-
export const DOMAIN_CAP_SAFETY = 0.8;
|
|
59
|
-
/**
|
|
60
|
-
* A domain's cap is DERIVED, never picked: it is the number of lines the cap
|
|
61
|
-
* budget can hold, with headroom. Non-finite and non-positive budgets collapse
|
|
62
|
-
* to the floor of one line rather than to zero (a domain that can hold nothing
|
|
63
|
-
* would silently reject every candidate).
|
|
64
|
-
*/
|
|
65
|
-
export function deriveDomainCap(capBudgetTokens) {
|
|
66
|
-
if (!Number.isFinite(capBudgetTokens) || capBudgetTokens <= 0)
|
|
67
|
-
return 1;
|
|
68
|
-
return Math.max(1, Math.floor((capBudgetTokens / DOMAIN_TOKENS_PER_LINE) * DOMAIN_CAP_SAFETY));
|
|
69
|
-
}
|
|
70
|
-
export function defaultDomainCap(kind) {
|
|
71
|
-
return deriveDomainCap(DEFAULT_DOMAIN_CAP_BUDGET_TOKENS[kind]);
|
|
72
|
-
}
|
|
73
|
-
/** The provider identity that owns a domain's rows in the shared store. */
|
|
74
|
-
export function domainIdentity(kind) {
|
|
75
|
-
return `dir:global:${kind}`;
|
|
76
|
-
}
|
|
77
|
-
/** What a missing allowlist means (the contract's own column default). */
|
|
78
|
-
export const DEFAULT_SHARE_CATEGORIES = ['CONSTRAINTS'];
|
|
79
|
-
/**
|
|
80
|
-
* Parse a share allowlist with the contract's fail-closed convention
|
|
81
|
-
* (upstream `normalizeShareCategories`, workspaces.ts:69-97):
|
|
82
|
-
*
|
|
83
|
-
* missing/null -> the default allowlist
|
|
84
|
-
* malformed, not an array, or ANY unknown category -> `[]`, share nothing
|
|
85
|
-
*
|
|
86
|
-
* The asymmetry is deliberate and load-bearing: a corrupt allowlist must not
|
|
87
|
-
* silently widen what becomes visible across projects.
|
|
88
|
-
*/
|
|
89
|
-
export function parseShareCategories(raw) {
|
|
90
|
-
if (raw === null || raw === undefined)
|
|
91
|
-
return [...DEFAULT_SHARE_CATEGORIES];
|
|
92
|
-
if (typeof raw !== 'string')
|
|
93
|
-
return [];
|
|
94
|
-
let parsed;
|
|
95
|
-
try {
|
|
96
|
-
parsed = JSON.parse(raw);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
101
|
-
if (!Array.isArray(parsed))
|
|
102
|
-
return [];
|
|
103
|
-
const valid = new Set(V2_MEMORY_CATEGORIES);
|
|
104
|
-
const categories = [];
|
|
105
|
-
for (const item of parsed) {
|
|
106
|
-
if (typeof item !== 'string' || !valid.has(item))
|
|
107
|
-
return [];
|
|
108
|
-
if (!categories.includes(item))
|
|
109
|
-
categories.push(item);
|
|
110
|
-
}
|
|
111
|
-
return categories.sort((left, right) => left.localeCompare(right));
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Serialize an allowlist for storage, rejecting unknown categories. This is
|
|
115
|
-
* also the publish-time pre-check: a category outside the allowlist would be
|
|
116
|
-
* written and then be invisible forever, which is a silent failure — so it is
|
|
117
|
-
* refused here instead.
|
|
118
|
-
*/
|
|
119
|
-
export function serializeShareCategories(categories) {
|
|
120
|
-
const valid = new Set(V2_MEMORY_CATEGORIES);
|
|
121
|
-
for (const category of categories) {
|
|
122
|
-
if (!valid.has(category)) {
|
|
123
|
-
throw new Error(`unknown share category '${category}' (allowed: ${V2_MEMORY_CATEGORIES.join(', ')})`);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return JSON.stringify([...new Set(categories)].sort((left, right) => left.localeCompare(right)));
|
|
127
|
-
}
|