Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 240 additions & 1 deletion hooks-core/harvest-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,244 @@
* to resolve is refused rather than stored as permanently-current.
*/

import { putNodeWithEdges, load, nodeId } from './wiki.mjs';
import {
putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode,
} from './wiki.mjs';
import { indexFile } from './staleness.mjs';
import { symbolKey } from './symbols.mjs';
import { canonicalPath } from './paths.mjs';
import { randomBytes } from 'node:crypto';
import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs';

/**
* The types that may cross a project boundary.
*
* This is not a new taxonomy: it is the exact complement of the set staleness.mjs
* already calls CONTENT_DEPENDENT. A `finding` or a `map` is a claim ABOUT the
* anchor's contents, so it is only true where those contents are; carrying it to
* another repository would assert something about files that repository does not
* have. The rest -- what a command does, what failed, what was decided, what the
* user asked for -- are claims about the WORK, and the work follows the person.
*
* Deriving one set from the other keeps them from drifting apart: if a type ever
* becomes content-dependent, it stops being shareable in the same commit.
*/
export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']);

/** Claim text, normalised enough that the same lesson learned twice is one row. */
const claimFingerprint = (claim) =>
String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300);

/**
* Copies a portable lesson into the machine-wide graph.
*
* ANCHORED TO THE PROJECT IT CAME FROM, not to the file that happened to be open.
* The source file's path means nothing in another checkout, and an unanchored
* finding is refused everywhere else in this codebase for a good reason -- so the
* shared copy is anchored to its origin project's root, which is a real path, is
* never content-checked (these types are not content-dependent), and doubles as
* the provenance the reader needs to judge whether the lesson transfers.
*
* Failures here are swallowed: this runs inside the harvest worker, and the
* project-local write has already succeeded. A shared tier that cannot be written
* must not cost the caller the lesson it just learned.
*/
function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) {
try {
if (!SHAREABLE_TYPES.has(finding.type)) return false;
const target = sharedDir();
if (!projectRoot) return false;

const root = canonicalPath(projectRoot);
const rootId = nodeId('file', root);

// Same claim, already carried up from anywhere: keep the first. Two repos
// teaching the same lesson is evidence it generalises, not a reason to say
// it twice on every command.
const graph = load(target);
const fp = claimFingerprint(finding.claim);
for (const node of graph.nodes.values()) {
if (node.kind !== 'finding' || node.retired) continue;
if (claimFingerprint(node.claim) === fp) return false;
}

// The anchor node must exist before the edge points at it, or the shared
// graph inherits exactly the orphan-finding problem the local one refuses.
if (!graph.nodes.has(rootId)) putNode(target, { kind: 'file', key: root });

return Boolean(
putNodeWithEdges(
target,
{
kind: 'finding',
key: `shared-${key}`,
claim: finding.claim,
confidence: finding.confidence,
type: finding.type,
trigger: typeof finding.trigger === 'string' && finding.trigger ? finding.trigger : undefined,
origin: provenance,
quote: typeof finding.quote === 'string' && finding.quote.trim() ? finding.quote : undefined,
// WHERE IT WAS LEARNED, carried so the reader can discount it. A lesson
// from another repository is worth surfacing and is not automatically
// true here; naming its origin is what lets that judgement be made.
sourceProject: root,
sessionId,
},
[{ edge: 'derived_from', to: rootId }]
)
);
} catch {
/* the local write already succeeded; the shared tier is best-effort */
return false;
}
}

/**
* How many refusals retire a cross-project lesson.
*
* Two, not one. A single refusal can be the model being cautious about a claim
* that is in fact fine here, and retiring on one would let a single hedge delete
* knowledge. Two independent sessions declining the same lesson is a pattern
* about the LESSON rather than about one moment.
*/
const REFUSALS_TO_RETIRE = 2;

/**
* Language that marks a delivered lesson as declined for NOT TRANSFERRING.
*
* Deliberately narrow, and narrower than it first was. Bare uncertainty is not a
* refusal: an answer that supplies a value and adds "verify the filename matches
* before relying on it" has used the lesson and calibrated it, which is the
* behaviour a cross-project fact should produce. Only an explicit non-transfer
* assertion counts.
*/
const REFUSAL_LANGUAGE =
/(does\s?n[o']?t transfer|doesn't apply here|would be fabrication|belongs to (that|another|a different) (repo|project)|scoped to a different project|carrying it over)/i;

/**
* Which of the lessons delivered this turn were declined?
*
* THE SIGNAL WAS ALREADY THERE AND NOBODY READ IT. Measured on this machine, a
* shared lesson carrying a build-error baseline was delivered into another
* project and the model answered: "the 536 figure is HarmonicEngine's baseline
* for a different build target, and it doesn't transfer." That is the graph being
* told, in plain words, that a lesson is mis-scoped -- and nothing recorded it,
* so the same lesson would be delivered again, and again, costing its tokens
* every time to be refused every time.
*
* Matching is by claim rather than by key because the response quotes the claim,
* not the id. A lesson is only counted when its own distinctive text appears near
* the refusal, so one refusal in a long answer cannot condemn every lesson
* delivered alongside it.
*/
export function detectRefusals(delivered, responseText) {
const text = String(responseText || '');
if (!text || !Array.isArray(delivered) || !delivered.length) return [];
if (!REFUSAL_LANGUAGE.test(text)) return [];

const refused = [];
for (const lesson of delivered) {
const claim = String(lesson.claim || '');
if (!claim) continue;
// A distinctive fragment of the claim: the longest word run is a poor test,
// so the check is on the rarest token the claim contains.
const tokens = (claim.match(/[A-Za-z0-9._/-]{6,}/g) || [])
.map((t) => t.toLowerCase())
.filter((t) => !/^(because|through|without|another|different|project)$/.test(t));
if (!tokens.length) continue;
const mentioned = tokens.some((t) => text.toLowerCase().includes(t));
if (mentioned) refused.push(lesson.key);
}
return refused;
}

/**
* Records that a cross-project lesson was declined, retiring it once the pattern
* is established.
*
* A retired finding is excluded from every read path in this codebase, so this
* stops the lesson costing tokens without destroying it -- the claim and its
* refusal count stay in the log, which is what makes the decision auditable
* later. Deleting would leave no trace of why a lesson vanished.
*/
export function recordRefusal(sharedDirPath, key) {
try {
const graph = load(sharedDirPath);
const target = [...graph.nodes.values()].find(
(n) => n.kind === 'finding' && n.key === key
);
if (!target) return null;

const refusals = (Number(target.refusals) || 0) + 1;
const retired = refusals >= REFUSALS_TO_RETIRE ? true : target.retired;

putNode(sharedDirPath, {
...target,
kind: 'finding',
key: target.key,
refusals,
retired,
retiredReason:
retired && !target.retired
? `declined as non-transferable in ${refusals} sessions`
: target.retiredReason,
});
return { key, refusals, retired: Boolean(retired) };
} catch {
return null;
}
}

/**
* Back-fills lessons that were learned BEFORE the shared tier existed.
*
* Promotion happens at harvest time, so every lesson already in a project graph
* stays there forever without this -- and on the machine that motivated the
* feature that was all of them: 35 live lessons, every one filed under the single
* repository that happened to teach it.
*
* IDEMPOTENT, because a migration that cannot be re-run is a migration nobody
* dares re-run. Promotion dedupes on the claim text, so a second pass over the
* same graph adds nothing, and a graph that gained findings since the last pass
* contributes only those.
*
* Returns what it did rather than printing, so the caller can report and the
* tests can assert.
*/
export function promoteExisting(projectDir, projectRoot, { sessionId = 'migration' } = {}) {
const result = { considered: 0, eligible: 0, promoted: 0, skipped: 0 };
if (!projectDir || !projectRoot) return result;
if (isSharedDir(projectDir)) return result;

let graph;
try {
graph = load(projectDir);
} catch {
return result;
}

for (const node of graph.nodes.values()) {
if (node.kind !== 'finding' || node.retired || !node.claim) continue;
result.considered += 1;
if (!SHAREABLE_TYPES.has(node.type)) continue;
result.eligible += 1;

// The stored node already carries everything promotion needs, so it is
// handed over as-is rather than reconstructed -- a reconstruction would be a
// second, divergent definition of what a promoted finding looks like.
const ok = promoteToShared(node, {
projectRoot,
sessionId,
provenance: node.origin,
key: node.key,
});
if (ok) result.promoted += 1;
else result.skipped += 1;
}

return result;
}

/**
* Resolves an `path` or `path#symbol` anchor to an existing node id.
* Returns null when the target cannot be created or found.
Expand Down Expand Up @@ -160,6 +391,14 @@ export function writeHarvested(
// A failed write returns null. Reporting the key anyway would tell the
// caller a claim was stored that no later session can retrieve.
if (id) written.push(key);

// AFTER the project write, and never instead of it. The project graph is the
// record of what happened here; the shared tier is a copy for the lessons
// that are not about here. Skipped when the two are the same directory,
// which is the suite's usual arrangement.
if (id && !isSharedDir(dir)) {
promoteToShared(finding, { projectRoot, sessionId, provenance, key });
}
}

return written;
Expand Down
Loading
Loading