@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { projectConfigPath } from "../../platform/paths.ts";
|
|
4
|
+
import { isCommandResolutionFailure } from "../gate/gate.command.ts";
|
|
5
|
+
import type { LessonsPolicyConfig } from "../policy/policy.types.ts";
|
|
6
|
+
import { lessonLinkVerdict } from "./lesson.link.ts";
|
|
7
|
+
import { hoursSince } from "./lesson.score.ts";
|
|
8
|
+
import { appliesHere, isInjectable, packLessonsUnderBudget, rankLessonsForSync } from "./lesson.select.ts";
|
|
9
|
+
import { allLessons, gardenGlobalLessons, gardenProjectLessons } from "./lesson.store.ts";
|
|
10
|
+
import type { HarnessLesson } from "./lesson.types.ts";
|
|
11
|
+
import { validityReason } from "./lesson.validity.ts";
|
|
12
|
+
|
|
13
|
+
export type GardenReport = {
|
|
14
|
+
promoted: string[];
|
|
15
|
+
quarantined: string[];
|
|
16
|
+
pruned: string[];
|
|
17
|
+
/** Project lessons whose refs no longer resolve. Reported, never auto-deleted — a rename can be reverted. */
|
|
18
|
+
stale: string[];
|
|
19
|
+
/** Lessons whose staleness cleared because every ref resolves again. */
|
|
20
|
+
refreshed: string[];
|
|
21
|
+
expired: string[];
|
|
22
|
+
active: number;
|
|
23
|
+
candidates: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A lesson recorded before AD-021, when a gate command that never resolved was classified `verification`
|
|
28
|
+
* instead of `config`. Such a lesson teaches an agent to go fix tests in response to a malformed command,
|
|
29
|
+
* and post-AD-021 the same output classifies `config`, so the signature cannot legitimately recur.
|
|
30
|
+
*
|
|
31
|
+
* hazard: exitCode 0 is passed deliberately. `isCommandResolutionFailure` returns true for 127, and stored
|
|
32
|
+
* instruction text can contain any number — only the message patterns may decide this.
|
|
33
|
+
*/
|
|
34
|
+
function isStaleResolutionMisfile(lesson: HarnessLesson): boolean {
|
|
35
|
+
return (
|
|
36
|
+
lesson.category === "verification" &&
|
|
37
|
+
isCommandResolutionFailure({ exitCode: 0, output: lesson.instruction })
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* invariant: promotion counts distinct sessions. `hitCount` counts fingerprint recurrences, which one stuck
|
|
43
|
+
* session can drive to any number, so it promoted lessons that had occurred once in the world.
|
|
44
|
+
*
|
|
45
|
+
* why: a record written before `sessionKeys` existed has none, and falling back to `hitCount` is what keeps it
|
|
46
|
+
* promotable rather than frozen as a candidate forever.
|
|
47
|
+
*/
|
|
48
|
+
export function promotionCount(lesson: HarnessLesson): number {
|
|
49
|
+
return lesson.sessionKeys.length > 0 ? lesson.sessionKeys.length : lesson.hitCount;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type StaleOutcome = { lesson: HarnessLesson; marked: boolean; cleared: boolean };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* invariant: only the tier whose store shares this repository is graded. A global lesson is read from many
|
|
56
|
+
* repositories, so one persisted flag cannot be true for all of them — that case is decided per repository at
|
|
57
|
+
* selection time instead.
|
|
58
|
+
*/
|
|
59
|
+
function applyStaleness(root: string, lesson: HarnessLesson, now: Date): StaleOutcome {
|
|
60
|
+
if (lesson.tier !== "project" || lesson.refs.length === 0) {
|
|
61
|
+
return { lesson, marked: false, cleared: false };
|
|
62
|
+
}
|
|
63
|
+
const verdict = lessonLinkVerdict(root, lesson.refs);
|
|
64
|
+
const checkedAt = now.toISOString();
|
|
65
|
+
if (verdict.stale) {
|
|
66
|
+
return {
|
|
67
|
+
lesson: { ...lesson, staleReason: verdict.status, staleCheckedAt: checkedAt, updatedAt: checkedAt },
|
|
68
|
+
marked: lesson.staleReason === undefined,
|
|
69
|
+
cleared: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (lesson.staleReason === undefined) {
|
|
73
|
+
return { lesson: { ...lesson, staleCheckedAt: checkedAt }, marked: false, cleared: false };
|
|
74
|
+
}
|
|
75
|
+
const { staleReason: _dropped, ...rest } = lesson;
|
|
76
|
+
return {
|
|
77
|
+
lesson: { ...rest, staleCheckedAt: checkedAt, updatedAt: checkedAt },
|
|
78
|
+
marked: false,
|
|
79
|
+
cleared: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gardenOne(
|
|
84
|
+
root: string,
|
|
85
|
+
lesson: HarnessLesson,
|
|
86
|
+
config: LessonsPolicyConfig,
|
|
87
|
+
now: Date,
|
|
88
|
+
report: GardenReport,
|
|
89
|
+
): HarnessLesson | null {
|
|
90
|
+
if (isStaleResolutionMisfile(lesson)) {
|
|
91
|
+
report.pruned.push(lesson.id);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// why: an expired lesson is the one case the author already decided. Prune it, unlike a broken ref, which is
|
|
96
|
+
// a filesystem observation that a revert could undo.
|
|
97
|
+
if (validityReason(lesson, now) === "expired") {
|
|
98
|
+
report.expired.push(lesson.id);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const outcome = applyStaleness(root, lesson, now);
|
|
103
|
+
let candidate = outcome.lesson;
|
|
104
|
+
if (outcome.marked) {
|
|
105
|
+
report.stale.push(candidate.id);
|
|
106
|
+
}
|
|
107
|
+
if (outcome.cleared) {
|
|
108
|
+
report.refreshed.push(candidate.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (candidate.status === "candidate" && promotionCount(candidate) >= config.promoteHitCount) {
|
|
112
|
+
candidate = {
|
|
113
|
+
...candidate,
|
|
114
|
+
status: "active",
|
|
115
|
+
confidence: Math.max(candidate.confidence, 0.7),
|
|
116
|
+
updatedAt: now.toISOString(),
|
|
117
|
+
};
|
|
118
|
+
report.promoted.push(candidate.id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const idleHours = hoursSince(candidate.lastSeenAt, now);
|
|
122
|
+
if (
|
|
123
|
+
candidate.status === "active" &&
|
|
124
|
+
idleHours > 24 * 90 &&
|
|
125
|
+
promotionCount(candidate) < config.promoteHitCount
|
|
126
|
+
) {
|
|
127
|
+
candidate = { ...candidate, status: "quarantine", updatedAt: now.toISOString() };
|
|
128
|
+
report.quarantined.push(candidate.id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (candidate.status === "quarantine" && idleHours > 24 * 180) {
|
|
132
|
+
report.pruned.push(candidate.id);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// invariant: pruning measures the same clock the ranking does — recurrence, not injection. Reading
|
|
137
|
+
// lastAccessedAt here let an injected lesson postpone its own pruning indefinitely.
|
|
138
|
+
const decayed =
|
|
139
|
+
candidate.confidence * Math.exp(-config.decayLambda * hoursSince(candidate.lastSeenAt, now));
|
|
140
|
+
if (decayed < 0.05 && candidate.status !== "quarantine" && candidate.hitCount < 2) {
|
|
141
|
+
report.pruned.push(candidate.id);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function emptyReport(): GardenReport {
|
|
149
|
+
return {
|
|
150
|
+
promoted: [],
|
|
151
|
+
quarantined: [],
|
|
152
|
+
pruned: [],
|
|
153
|
+
stale: [],
|
|
154
|
+
refreshed: [],
|
|
155
|
+
expired: [],
|
|
156
|
+
active: 0,
|
|
157
|
+
candidates: 0,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function gardenLessons(
|
|
162
|
+
root: string,
|
|
163
|
+
config: LessonsPolicyConfig,
|
|
164
|
+
now = new Date(),
|
|
165
|
+
): Promise<GardenReport> {
|
|
166
|
+
const report = emptyReport();
|
|
167
|
+
const sweep = (current: HarnessLesson[]): HarnessLesson[] => {
|
|
168
|
+
const next: HarnessLesson[] = [];
|
|
169
|
+
for (const lesson of current) {
|
|
170
|
+
if (lesson.source === "core") {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const kept = gardenOne(root, lesson, config, now, report);
|
|
174
|
+
if (kept) {
|
|
175
|
+
next.push(kept);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return next;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const project = await gardenProjectLessons(root, sweep);
|
|
182
|
+
// why: the global tier is gardened in the same pass. Decay, promotion and expiry apply to it identically;
|
|
183
|
+
// only staleness is tier-specific, and `applyStaleness` is what draws that line.
|
|
184
|
+
const global = await gardenGlobalLessons(sweep);
|
|
185
|
+
const kept = [...project, ...global];
|
|
186
|
+
|
|
187
|
+
report.active = kept.filter((l) => l.status === "active").length;
|
|
188
|
+
report.candidates = kept.filter((l) => l.status === "candidate").length;
|
|
189
|
+
return report;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SYNC_TITLE = "Learned harness lessons (auto-synced; do not hand-edit):";
|
|
193
|
+
|
|
194
|
+
export function lessonsMarkdownPath(root: string): string {
|
|
195
|
+
return join(dirname(projectConfigPath(root)), "lessons.md");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Why the synced file has nothing in it, in the operator's terms.
|
|
200
|
+
*
|
|
201
|
+
* hazard: one sentence — "No active project lessons yet." — covered a switched-off capability, an enabled one that
|
|
202
|
+
* had never seen a repeat failure, and a store full of candidates none of which had been promoted. Three different
|
|
203
|
+
* situations and nothing to tell them apart, so the file read as broken. An operator reported it as never updating,
|
|
204
|
+
* and it was being rewritten every session with the same empty text ([/decisions/ad-049.md](/decisions/ad-049.md)).
|
|
205
|
+
*/
|
|
206
|
+
export function emptySyncReason(
|
|
207
|
+
lessons: readonly HarnessLesson[],
|
|
208
|
+
config: LessonsPolicyConfig,
|
|
209
|
+
now: Date,
|
|
210
|
+
): string {
|
|
211
|
+
if (!config.enabled) {
|
|
212
|
+
return "Lessons are switched off for this project (`intelligence.lessons.enabled` is false), so no gate failure is ever recorded. Ask the agent to run the harness-init skill to turn them on.";
|
|
213
|
+
}
|
|
214
|
+
if (lessons.length === 0) {
|
|
215
|
+
return "No lesson recorded yet. One is written when the *same* gate failure repeats inside a session — a gate that fails once, or fails differently each time, records nothing.";
|
|
216
|
+
}
|
|
217
|
+
const candidates = lessons.filter((lesson) => lesson.status === "candidate").length;
|
|
218
|
+
if (candidates > 0 && lessons.every((lesson) => lesson.status !== "active")) {
|
|
219
|
+
const noun = candidates === 1 ? "lesson" : "lessons";
|
|
220
|
+
return `${candidates} candidate ${noun} recorded, none promoted yet. Promotion needs the same failure in ${config.promoteHitCount} distinct sessions — see \`tlc harness lessons list\`.`;
|
|
221
|
+
}
|
|
222
|
+
const withheld = lessons.filter(
|
|
223
|
+
(lesson) => lesson.status === "active" && !isInjectable(lesson, now),
|
|
224
|
+
).length;
|
|
225
|
+
if (withheld > 0) {
|
|
226
|
+
const noun = withheld === 1 ? "lesson is" : "lessons are";
|
|
227
|
+
return `${withheld} active ${noun} withheld — a named reference stopped resolving, or a validity window closed. Run \`tlc harness lessons list\` to see which.`;
|
|
228
|
+
}
|
|
229
|
+
return "No active project lessons yet.";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function renderLessonsMarkdown(
|
|
233
|
+
root: string,
|
|
234
|
+
lessons: HarnessLesson[],
|
|
235
|
+
config: LessonsPolicyConfig,
|
|
236
|
+
): string {
|
|
237
|
+
// invariant: the synced file is what an operator reads as current guidance, so it carries exactly what would
|
|
238
|
+
// be injected — a withheld lesson appearing here would contradict the store.
|
|
239
|
+
const now = new Date();
|
|
240
|
+
// hazard: `appliesHere` as well as `isInjectable`. A global lesson whose refs miss in this repo is withheld from
|
|
241
|
+
// the injected block, so listing it here would make the file contradict the guidance the model receives.
|
|
242
|
+
const ranked = rankLessonsForSync(
|
|
243
|
+
lessons.filter((lesson) => isInjectable(lesson, now) && appliesHere(root, lesson)),
|
|
244
|
+
).slice(0, 12);
|
|
245
|
+
const { body } = packLessonsUnderBudget({
|
|
246
|
+
lessons: ranked,
|
|
247
|
+
maxChars: config.maxCharsSession,
|
|
248
|
+
title: SYNC_TITLE,
|
|
249
|
+
});
|
|
250
|
+
const path = lessonsMarkdownPath(root);
|
|
251
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
252
|
+
const content = `# Harness lessons
|
|
253
|
+
|
|
254
|
+
Auto-synced from gate failures; do not hand-edit.
|
|
255
|
+
|
|
256
|
+
${body || emptySyncReason(lessons, config, now)}
|
|
257
|
+
`;
|
|
258
|
+
writeFileSync(path, content, "utf8");
|
|
259
|
+
return path;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `writeDurableView` is the caller's verdict, not a re-read of the config: the mode alone cannot decide it, because
|
|
264
|
+
* `auto` depends on whether the host delivers context from its session-start hook. Core takes that as data rather
|
|
265
|
+
* than importing a provider ([/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
266
|
+
*/
|
|
267
|
+
export function gardenAndPersistLessons(
|
|
268
|
+
root: string,
|
|
269
|
+
config: LessonsPolicyConfig,
|
|
270
|
+
options: { writeDurableView: boolean },
|
|
271
|
+
now = new Date(),
|
|
272
|
+
): Promise<{ report: GardenReport; markdownPath: string | null }> {
|
|
273
|
+
return gardenLessons(root, config, now).then((report) => {
|
|
274
|
+
if (!options.writeDurableView) {
|
|
275
|
+
return { report, markdownPath: null };
|
|
276
|
+
}
|
|
277
|
+
// hazard: this read `readProjectLessons`, so the file carried one of the three tiers. On the host where it is
|
|
278
|
+
// the only route to the model, a core or global lesson therefore reached nothing
|
|
279
|
+
// ([/decisions/ad-040.md](/decisions/ad-040.md), [/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
280
|
+
const path = renderLessonsMarkdown(root, allLessons(root), config);
|
|
281
|
+
return { report, markdownPath: path };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import type { HarnessLesson, LessonLink, LessonLinkStatus } from "./lesson.types.ts";
|
|
4
|
+
|
|
5
|
+
const LINK_SEPARATOR = ":";
|
|
6
|
+
|
|
7
|
+
export function parseLessonLink(raw: string): LessonLink | null {
|
|
8
|
+
const trimmed = raw.trim();
|
|
9
|
+
if (!trimmed) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const at = trimmed.lastIndexOf(LINK_SEPARATOR);
|
|
13
|
+
if (at <= 0 || at === trimmed.length - 1) {
|
|
14
|
+
return { path: trimmed };
|
|
15
|
+
}
|
|
16
|
+
const path = trimmed.slice(0, at).trim();
|
|
17
|
+
const symbol = trimmed.slice(at + 1).trim();
|
|
18
|
+
if (!path) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return symbol ? { path, symbol } : { path };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatLessonLink(link: LessonLink): string {
|
|
25
|
+
return link.symbol ? `${link.path}${LINK_SEPARATOR}${link.symbol}` : link.path;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// hazard: an absolute path resolves against a machine, not a repository, so a global lesson would report
|
|
29
|
+
// `present` in every product on the machine that happens to contain that file.
|
|
30
|
+
function resolveLinkPath(root: string, link: LessonLink): string | null {
|
|
31
|
+
return isAbsolute(link.path) ? null : resolve(root, link.path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function checkLessonLink(root: string, link: LessonLink): LessonLinkStatus {
|
|
35
|
+
const absolute = resolveLinkPath(root, link);
|
|
36
|
+
if (absolute === null || !existsSync(absolute)) {
|
|
37
|
+
return "path-missing";
|
|
38
|
+
}
|
|
39
|
+
if (!link.symbol) {
|
|
40
|
+
return "present";
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// why: substring, not parsing — a symbol table needs a language per extension and a parse per check, and a
|
|
44
|
+
// false `present` only costs keeping a lesson a rename would have retired.
|
|
45
|
+
return readFileSync(absolute, "utf8").includes(link.symbol) ? "present" : "symbol-missing";
|
|
46
|
+
} catch {
|
|
47
|
+
// invariant: unreadable is deferred, never stale. A file this process cannot open is not evidence the
|
|
48
|
+
// lesson stopped being true.
|
|
49
|
+
return "unreadable";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const STATUS_SEVERITY: Record<LessonLinkStatus, number> = {
|
|
54
|
+
present: 0,
|
|
55
|
+
unreadable: 1,
|
|
56
|
+
"symbol-missing": 2,
|
|
57
|
+
"path-missing": 3,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function worstLinkStatus(statuses: readonly LessonLinkStatus[]): LessonLinkStatus {
|
|
61
|
+
let worst: LessonLinkStatus = "present";
|
|
62
|
+
for (const status of statuses) {
|
|
63
|
+
if (STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) {
|
|
64
|
+
worst = status;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return worst;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type LessonLinkVerdict = {
|
|
71
|
+
status: LessonLinkStatus;
|
|
72
|
+
stale: boolean;
|
|
73
|
+
brokenRefs: string[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function lessonLinkVerdict(root: string, refs: readonly LessonLink[]): LessonLinkVerdict {
|
|
77
|
+
if (refs.length === 0) {
|
|
78
|
+
return { status: "present", stale: false, brokenRefs: [] };
|
|
79
|
+
}
|
|
80
|
+
const statuses = refs.map((ref) => checkLessonLink(root, ref));
|
|
81
|
+
const brokenRefs = refs
|
|
82
|
+
.filter((_, index) => statuses[index] === "path-missing" || statuses[index] === "symbol-missing")
|
|
83
|
+
.map(formatLessonLink);
|
|
84
|
+
return { status: worstLinkStatus(statuses), stale: brokenRefs.length > 0, brokenRefs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function lessonRefs(lesson: HarnessLesson): LessonLink[] {
|
|
88
|
+
return Array.isArray(lesson.refs) ? lesson.refs : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isStaleLesson(lesson: HarnessLesson): boolean {
|
|
92
|
+
return typeof lesson.staleReason === "string" && lesson.staleReason.length > 0;
|
|
93
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { HarnessLesson } from "./lesson.types.ts";
|
|
2
|
+
|
|
3
|
+
const MS_PER_HOUR = 3_600_000;
|
|
4
|
+
|
|
5
|
+
// hazard: an unparseable or missing timestamp used to yield NaN, which does not throw but poisons every
|
|
6
|
+
// comparator that sorts on the decayed value. Unknown reads as "just seen", which is the conservative side.
|
|
7
|
+
export function hoursSince(iso: string, now: Date): number {
|
|
8
|
+
const then = new Date(iso).getTime();
|
|
9
|
+
if (!Number.isFinite(then)) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
return Math.max(0, (now.getTime() - then) / MS_PER_HOUR);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* invariant: decay is measured from `lastSeenAt` — the last time the failure actually recurred — and never
|
|
17
|
+
* from `lastAccessedAt`.
|
|
18
|
+
*
|
|
19
|
+
* hazard: `lastAccessedAt` is written by `touchAccessed` when a lesson is *selected for injection*, so
|
|
20
|
+
* reading it here made relevance self-fulfilling: showing a lesson reset the clock that decided whether to
|
|
21
|
+
* keep showing it. Any lesson matching a gate name became immortal and never pruned.
|
|
22
|
+
*/
|
|
23
|
+
export function decayedConfidence(lesson: HarnessLesson, decayLambda: number, now: Date): number {
|
|
24
|
+
if (lesson.source === "core") {
|
|
25
|
+
return lesson.confidence;
|
|
26
|
+
}
|
|
27
|
+
return lesson.confidence * Math.exp(-decayLambda * hoursSince(lesson.lastSeenAt, now));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function relevanceScore(lesson: HarnessLesson, args: { gate?: string; text?: string }): number {
|
|
31
|
+
let score = 0.25;
|
|
32
|
+
const gate = (args.gate ?? "").toLowerCase();
|
|
33
|
+
const text = (args.text ?? "").toLowerCase();
|
|
34
|
+
if (gate && lesson.failedGate.toLowerCase() === gate) {
|
|
35
|
+
score += 1.2;
|
|
36
|
+
}
|
|
37
|
+
if (gate && lesson.triggerTokens.some((token) => gate.includes(token.toLowerCase()))) {
|
|
38
|
+
score += 0.35;
|
|
39
|
+
}
|
|
40
|
+
for (const token of lesson.triggerTokens) {
|
|
41
|
+
const t = token.toLowerCase();
|
|
42
|
+
if (t && text.includes(t)) {
|
|
43
|
+
score += 0.2;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
score += lesson.priority / 200;
|
|
47
|
+
return score;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function rankScore(
|
|
51
|
+
lesson: HarnessLesson,
|
|
52
|
+
args: { gate?: string; text?: string; decayLambda: number; projectBoost: number; now?: Date },
|
|
53
|
+
): number {
|
|
54
|
+
const now = args.now ?? new Date();
|
|
55
|
+
const relevance = relevanceScore(lesson, { gate: args.gate, text: args.text });
|
|
56
|
+
const confidence = decayedConfidence(lesson, args.decayLambda, now);
|
|
57
|
+
// why: the boost favours the nearest tier. A lesson written for this repository outranks one carried in from
|
|
58
|
+
// another product, which is what keeps a cross-product tier from drowning local knowledge.
|
|
59
|
+
const boost = lesson.tier === "project" ? args.projectBoost : 1;
|
|
60
|
+
return relevance * confidence * boost;
|
|
61
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { LessonsPolicyConfig } from "../policy/policy.types.ts";
|
|
2
|
+
import { isStaleLesson, lessonLinkVerdict } from "./lesson.link.ts";
|
|
3
|
+
import { rankScore } from "./lesson.score.ts";
|
|
4
|
+
import { allLessons, touchAccessed } from "./lesson.store.ts";
|
|
5
|
+
import type { HarnessLesson } from "./lesson.types.ts";
|
|
6
|
+
import { isWithinValidity } from "./lesson.validity.ts";
|
|
7
|
+
|
|
8
|
+
export type SelectMode = "session" | "retry";
|
|
9
|
+
|
|
10
|
+
const OMIT_NOTE_RESERVE = 96;
|
|
11
|
+
|
|
12
|
+
// invariant: injectable is decided before ranking, not by scoring a withheld lesson low. A stale or out-of-window
|
|
13
|
+
// lesson must not reach the turn at any score.
|
|
14
|
+
export function isInjectable(lesson: HarnessLesson, now: Date): boolean {
|
|
15
|
+
return !isStaleLesson(lesson) && isWithinValidity(lesson, now);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* why: a global lesson is read from many repositories, so one stored `staleReason` cannot be true for all of
|
|
20
|
+
* them — a ref that is missing here may be present in the product the lesson came from. Applicability is
|
|
21
|
+
* therefore computed per repository for the global tier, while a project lesson relies on the flag `garden`
|
|
22
|
+
* wrote against the same repository it lives in.
|
|
23
|
+
*/
|
|
24
|
+
export function appliesHere(root: string, lesson: HarnessLesson): boolean {
|
|
25
|
+
if (lesson.tier !== "global" || lesson.refs.length === 0) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return !lessonLinkVerdict(root, lesson.refs).stale;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function allowedForMode(lesson: HarnessLesson, mode: SelectMode, gate?: string): boolean {
|
|
32
|
+
if (lesson.status === "quarantine") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (mode === "session") {
|
|
36
|
+
return lesson.status === "active";
|
|
37
|
+
}
|
|
38
|
+
if (lesson.status === "active") {
|
|
39
|
+
return !gate || lesson.failedGate === gate || lesson.failedGate === "stagnation";
|
|
40
|
+
}
|
|
41
|
+
if (lesson.status === "candidate") {
|
|
42
|
+
return Boolean(gate) && lesson.failedGate === gate;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// why: the tier is rendered because it calibrates trust — a lesson carried in from another product is advice
|
|
48
|
+
// about a different repository, and the turn should be able to tell.
|
|
49
|
+
export function renderLessonBlock(lesson: HarnessLesson): string {
|
|
50
|
+
const lines = [
|
|
51
|
+
`- [${lesson.failedGate}/${lesson.status}/${lesson.tier}] ${lesson.instruction}`,
|
|
52
|
+
` avoid: ${lesson.avoid}`,
|
|
53
|
+
` prefer: ${lesson.prefer}`,
|
|
54
|
+
` before retrying: ${lesson.preRetryCheck}`,
|
|
55
|
+
];
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatLessonsSection(lessons: HarnessLesson[], title: string): string {
|
|
60
|
+
if (lessons.length === 0) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
return [title, ...lessons.map((lesson) => renderLessonBlock(lesson))].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function omitLessonsNote(omitted: number): string {
|
|
67
|
+
if (omitted <= 0) {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
const noun = omitted === 1 ? "lesson" : "lessons";
|
|
71
|
+
return `_(${omitted} more active ${noun} omitted under char budget)_`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function packLessonsUnderBudget(args: { lessons: HarnessLesson[]; maxChars: number; title: string }): {
|
|
75
|
+
body: string;
|
|
76
|
+
included: HarnessLesson[];
|
|
77
|
+
omitted: number;
|
|
78
|
+
} {
|
|
79
|
+
const { lessons, title } = args;
|
|
80
|
+
const maxChars = Math.max(0, args.maxChars);
|
|
81
|
+
if (lessons.length === 0) {
|
|
82
|
+
return { body: "", included: [], omitted: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const packBudget = Math.max(0, maxChars - OMIT_NOTE_RESERVE);
|
|
86
|
+
const included: HarnessLesson[] = [];
|
|
87
|
+
|
|
88
|
+
for (const lesson of lessons) {
|
|
89
|
+
const candidate = formatLessonsSection([...included, lesson], title);
|
|
90
|
+
if (included.length === 0) {
|
|
91
|
+
included.push(lesson);
|
|
92
|
+
if (candidate.length > packBudget) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (candidate.length <= packBudget) {
|
|
98
|
+
included.push(lesson);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let omitted = lessons.length - included.length;
|
|
105
|
+
let body = formatLessonsSection(included, title);
|
|
106
|
+
const note = omitLessonsNote(omitted);
|
|
107
|
+
if (!note) {
|
|
108
|
+
return { body, included, omitted };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const withNote = `${body}\n${note}`;
|
|
112
|
+
if (withNote.length <= maxChars) {
|
|
113
|
+
return { body: withNote, included, omitted };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
while (included.length > 1) {
|
|
117
|
+
included.pop();
|
|
118
|
+
omitted = lessons.length - included.length;
|
|
119
|
+
body = formatLessonsSection(included, title);
|
|
120
|
+
const next = `${body}\n${omitLessonsNote(omitted)}`;
|
|
121
|
+
if (next.length <= maxChars) {
|
|
122
|
+
return { body: next, included: [...included], omitted };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { body, included: [...included], omitted: lessons.length - included.length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function rankLessonsForSync(lessons: HarnessLesson[]): HarnessLesson[] {
|
|
130
|
+
return [...lessons]
|
|
131
|
+
.filter((lesson) => lesson.status === "active")
|
|
132
|
+
.sort(
|
|
133
|
+
(a, b) =>
|
|
134
|
+
b.priority - a.priority ||
|
|
135
|
+
b.hitCount - a.hitCount ||
|
|
136
|
+
b.confidence - a.confidence ||
|
|
137
|
+
new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime() ||
|
|
138
|
+
a.id.localeCompare(b.id),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function selectLessons(args: {
|
|
143
|
+
projectDir: string;
|
|
144
|
+
config: LessonsPolicyConfig;
|
|
145
|
+
mode: SelectMode;
|
|
146
|
+
gate?: string;
|
|
147
|
+
text?: string;
|
|
148
|
+
now?: Date;
|
|
149
|
+
}): Promise<{ lessons: HarnessLesson[]; usedIds: string[]; omitted: number }> {
|
|
150
|
+
if (!args.config.enabled) {
|
|
151
|
+
return { lessons: [], usedIds: [], omitted: 0 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const maxCount = args.mode === "session" ? args.config.maxInjectSession : args.config.maxInjectRetry;
|
|
155
|
+
const maxChars = args.mode === "session" ? args.config.maxCharsSession : args.config.maxCharsRetry;
|
|
156
|
+
const now = args.now ?? new Date();
|
|
157
|
+
|
|
158
|
+
const ranked = allLessons(args.projectDir)
|
|
159
|
+
.filter(
|
|
160
|
+
(lesson) =>
|
|
161
|
+
isInjectable(lesson, now) &&
|
|
162
|
+
appliesHere(args.projectDir, lesson) &&
|
|
163
|
+
allowedForMode(lesson, args.mode, args.gate),
|
|
164
|
+
)
|
|
165
|
+
.map((lesson) => ({
|
|
166
|
+
lesson,
|
|
167
|
+
score: rankScore(lesson, {
|
|
168
|
+
gate: args.gate,
|
|
169
|
+
text: args.text,
|
|
170
|
+
decayLambda: args.config.decayLambda,
|
|
171
|
+
projectBoost: args.config.projectBoost,
|
|
172
|
+
now,
|
|
173
|
+
}),
|
|
174
|
+
}))
|
|
175
|
+
.sort((a, b) => b.score - a.score || b.lesson.priority - a.lesson.priority);
|
|
176
|
+
|
|
177
|
+
// invariant: pinned first, in the operator's own order, before anything scored. Everything else about a pinned
|
|
178
|
+
// lesson still applies — staleness, validity, mode and the char budget all bind
|
|
179
|
+
// ([/decisions/ad-043.md](/decisions/ad-043.md)).
|
|
180
|
+
const ordered = [
|
|
181
|
+
...ranked.filter((row) => row.lesson.pinned),
|
|
182
|
+
...ranked.filter((row) => !row.lesson.pinned),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const picked: HarnessLesson[] = [];
|
|
186
|
+
let chars = 0;
|
|
187
|
+
for (const row of ordered) {
|
|
188
|
+
if (picked.length >= maxCount) {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
const block = renderLessonBlock(row.lesson);
|
|
192
|
+
if (chars + block.length > maxChars && picked.length > 0) {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (block.length > maxChars && picked.length === 0) {
|
|
196
|
+
picked.push(row.lesson);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
picked.push(row.lesson);
|
|
200
|
+
chars += block.length;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const usedIds = picked.filter((l) => l.source !== "core").map((l) => l.id);
|
|
204
|
+
await touchAccessed(args.projectDir, usedIds, now);
|
|
205
|
+
// hazard: `maxInjectSession` defaults to 5 and `maxCharsSession` to 900, which fits about two rendered blocks —
|
|
206
|
+
// so the count promises five and delivers two, silently. Whoever reads the injected block has to be able to tell
|
|
207
|
+
// that eligible lessons were dropped ([/decisions/ad-043.md](/decisions/ad-043.md)).
|
|
208
|
+
return { lessons: picked, usedIds: picked.map((l) => l.id), omitted: ordered.length - picked.length };
|
|
209
|
+
}
|