@loopover/miner 0.1.0
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/DEPLOYMENT.md +156 -0
- package/Dockerfile +38 -0
- package/README.md +269 -0
- package/bin/gittensory-miner-mcp.d.ts +64 -0
- package/bin/gittensory-miner-mcp.js +279 -0
- package/bin/gittensory-miner.js +196 -0
- package/docs/coding-agent-driver.md +140 -0
- package/docs/config-precedence.md +96 -0
- package/docs/cross-repo-discovery-phase1.md +55 -0
- package/docs/discovery-index-contract.md +60 -0
- package/docs/discovery-plane-operator-guide.md +101 -0
- package/docs/env-reference.md +30 -0
- package/docs/fleet-run-manifest.md +50 -0
- package/docs/miner-goal-spec.md +76 -0
- package/docs/miner-selfimprove-calibration.md +105 -0
- package/docs/observability.md +60 -0
- package/docs/operations-runbook.md +209 -0
- package/docs/repo-agnostic-capability-audit.md +129 -0
- package/docs/unattended-scheduling.md +107 -0
- package/expected-engine.version +1 -0
- package/lib/ams-policy.d.ts +20 -0
- package/lib/ams-policy.js +68 -0
- package/lib/attempt-cli.d.ts +99 -0
- package/lib/attempt-cli.js +523 -0
- package/lib/attempt-input-builder.d.ts +24 -0
- package/lib/attempt-input-builder.js +91 -0
- package/lib/attempt-log.d.ts +37 -0
- package/lib/attempt-log.js +177 -0
- package/lib/attempt-runner.d.ts +59 -0
- package/lib/attempt-runner.js +164 -0
- package/lib/attempt-worktree.d.ts +31 -0
- package/lib/attempt-worktree.js +94 -0
- package/lib/calibration-cli.d.ts +1 -0
- package/lib/calibration-cli.js +92 -0
- package/lib/calibration-run.d.ts +161 -0
- package/lib/calibration-run.js +232 -0
- package/lib/calibration-types.d.ts +46 -0
- package/lib/calibration-types.js +74 -0
- package/lib/calibration.d.ts +24 -0
- package/lib/calibration.js +99 -0
- package/lib/ci-poller.d.ts +33 -0
- package/lib/ci-poller.js +234 -0
- package/lib/claim-adjudication.d.ts +21 -0
- package/lib/claim-adjudication.js +36 -0
- package/lib/claim-conflict-resolver.d.ts +29 -0
- package/lib/claim-conflict-resolver.js +101 -0
- package/lib/claim-ledger-cli.d.ts +59 -0
- package/lib/claim-ledger-cli.js +320 -0
- package/lib/claim-ledger-expiry.d.ts +20 -0
- package/lib/claim-ledger-expiry.js +41 -0
- package/lib/claim-ledger.d.ts +63 -0
- package/lib/claim-ledger.js +303 -0
- package/lib/cli-error.d.ts +3 -0
- package/lib/cli-error.js +27 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +72 -0
- package/lib/coding-agent-construction.d.ts +17 -0
- package/lib/coding-agent-construction.js +103 -0
- package/lib/coding-agent-house-rules.d.ts +24 -0
- package/lib/coding-agent-house-rules.js +64 -0
- package/lib/coding-task-spec.d.ts +44 -0
- package/lib/coding-task-spec.js +183 -0
- package/lib/config-precedence.d.ts +25 -0
- package/lib/config-precedence.js +38 -0
- package/lib/deny-check.d.ts +11 -0
- package/lib/deny-check.js +76 -0
- package/lib/deny-hook-synthesis.d.ts +93 -0
- package/lib/deny-hook-synthesis.js +399 -0
- package/lib/deny-hooks.d.ts +28 -0
- package/lib/deny-hooks.js +155 -0
- package/lib/deployment-docs-audit.d.ts +41 -0
- package/lib/deployment-docs-audit.js +115 -0
- package/lib/discover-cli.d.ts +94 -0
- package/lib/discover-cli.js +284 -0
- package/lib/discovery-throttle.d.ts +8 -0
- package/lib/discovery-throttle.js +33 -0
- package/lib/env-file-indirection.d.ts +4 -0
- package/lib/env-file-indirection.js +45 -0
- package/lib/event-ledger-cli.d.ts +82 -0
- package/lib/event-ledger-cli.js +266 -0
- package/lib/event-ledger.d.ts +37 -0
- package/lib/event-ledger.js +210 -0
- package/lib/execute-local-write.d.ts +14 -0
- package/lib/execute-local-write.js +50 -0
- package/lib/feasibility-cli.d.ts +25 -0
- package/lib/feasibility-cli.js +80 -0
- package/lib/forge-config.d.ts +17 -0
- package/lib/forge-config.js +37 -0
- package/lib/governor-action-mode.d.ts +26 -0
- package/lib/governor-action-mode.js +47 -0
- package/lib/governor-chokepoint-persisted.d.ts +18 -0
- package/lib/governor-chokepoint-persisted.js +46 -0
- package/lib/governor-chokepoint.d.ts +14 -0
- package/lib/governor-chokepoint.js +52 -0
- package/lib/governor-kill-switch.d.ts +26 -0
- package/lib/governor-kill-switch.js +47 -0
- package/lib/governor-ledger-cli.d.ts +32 -0
- package/lib/governor-ledger-cli.js +155 -0
- package/lib/governor-ledger.d.ts +46 -0
- package/lib/governor-ledger.js +203 -0
- package/lib/governor-open-pr.d.ts +21 -0
- package/lib/governor-open-pr.js +27 -0
- package/lib/governor-pause-cli.d.ts +23 -0
- package/lib/governor-pause-cli.js +171 -0
- package/lib/governor-run-halt.d.ts +37 -0
- package/lib/governor-run-halt.js +58 -0
- package/lib/governor-state.d.ts +63 -0
- package/lib/governor-state.js +363 -0
- package/lib/governor-write-rate-limit.d.ts +30 -0
- package/lib/governor-write-rate-limit.js +64 -0
- package/lib/harness-submission-trigger.d.ts +69 -0
- package/lib/harness-submission-trigger.js +138 -0
- package/lib/http-retry.d.ts +12 -0
- package/lib/http-retry.js +52 -0
- package/lib/laptop-init.d.ts +53 -0
- package/lib/laptop-init.js +332 -0
- package/lib/live-issue-snapshot.d.ts +16 -0
- package/lib/live-issue-snapshot.js +117 -0
- package/lib/local-store.d.ts +18 -0
- package/lib/local-store.js +62 -0
- package/lib/logger.d.ts +58 -0
- package/lib/logger.js +167 -0
- package/lib/loop-cli.d.ts +66 -0
- package/lib/loop-cli.js +540 -0
- package/lib/loop-closure.d.ts +35 -0
- package/lib/loop-closure.js +66 -0
- package/lib/loop-reentry.d.ts +51 -0
- package/lib/loop-reentry.js +124 -0
- package/lib/manage-poll.d.ts +79 -0
- package/lib/manage-poll.js +254 -0
- package/lib/manage-status.d.ts +70 -0
- package/lib/manage-status.js +241 -0
- package/lib/metrics-cli.d.ts +9 -0
- package/lib/metrics-cli.js +50 -0
- package/lib/migrate-cli.d.ts +24 -0
- package/lib/migrate-cli.js +111 -0
- package/lib/miner-goal-spec.d.ts +12 -0
- package/lib/miner-goal-spec.js +67 -0
- package/lib/opportunity-fanout.d.ts +86 -0
- package/lib/opportunity-fanout.js +597 -0
- package/lib/opportunity-ranker.d.ts +36 -0
- package/lib/opportunity-ranker.js +118 -0
- package/lib/orb-export.d.ts +54 -0
- package/lib/orb-export.js +206 -0
- package/lib/plan-store-cli.d.ts +37 -0
- package/lib/plan-store-cli.js +151 -0
- package/lib/plan-store.d.ts +51 -0
- package/lib/plan-store.js +231 -0
- package/lib/policy-doc-cache.d.ts +25 -0
- package/lib/policy-doc-cache.js +79 -0
- package/lib/policy-verdict-cache.d.ts +35 -0
- package/lib/policy-verdict-cache.js +106 -0
- package/lib/portfolio-dashboard.d.ts +30 -0
- package/lib/portfolio-dashboard.js +105 -0
- package/lib/portfolio-discovery.d.ts +29 -0
- package/lib/portfolio-discovery.js +100 -0
- package/lib/portfolio-queue-cli.d.ts +87 -0
- package/lib/portfolio-queue-cli.js +509 -0
- package/lib/portfolio-queue-expiry.d.ts +20 -0
- package/lib/portfolio-queue-expiry.js +51 -0
- package/lib/portfolio-queue-manager.d.ts +50 -0
- package/lib/portfolio-queue-manager.js +134 -0
- package/lib/portfolio-queue.d.ts +62 -0
- package/lib/portfolio-queue.js +351 -0
- package/lib/pr-disposition-poller.d.ts +26 -0
- package/lib/pr-disposition-poller.js +163 -0
- package/lib/pr-number-parse.d.ts +4 -0
- package/lib/pr-number-parse.js +23 -0
- package/lib/pr-outcome.d.ts +41 -0
- package/lib/pr-outcome.js +90 -0
- package/lib/prediction-ledger.d.ts +47 -0
- package/lib/prediction-ledger.js +222 -0
- package/lib/pretooluse-hook.d.ts +31 -0
- package/lib/pretooluse-hook.js +93 -0
- package/lib/process-lifecycle.d.ts +31 -0
- package/lib/process-lifecycle.js +106 -0
- package/lib/purge-cli.d.ts +44 -0
- package/lib/purge-cli.js +198 -0
- package/lib/rejection-signal.d.ts +6 -0
- package/lib/rejection-signal.js +101 -0
- package/lib/rejection-state-machine.d.ts +37 -0
- package/lib/rejection-state-machine.js +81 -0
- package/lib/rejection-templates.d.ts +12 -0
- package/lib/rejection-templates.js +71 -0
- package/lib/replay-objective-anchor.d.ts +79 -0
- package/lib/replay-objective-anchor.js +179 -0
- package/lib/replay-snapshot.d.ts +41 -0
- package/lib/replay-snapshot.js +288 -0
- package/lib/replay-task-generation.d.ts +126 -0
- package/lib/replay-task-generation.js +223 -0
- package/lib/repo-clone.d.ts +19 -0
- package/lib/repo-clone.js +102 -0
- package/lib/run-state-cli.d.ts +27 -0
- package/lib/run-state-cli.js +154 -0
- package/lib/run-state.d.ts +37 -0
- package/lib/run-state.js +150 -0
- package/lib/schema-version.d.ts +17 -0
- package/lib/schema-version.js +71 -0
- package/lib/self-review-context.d.ts +28 -0
- package/lib/self-review-context.js +339 -0
- package/lib/slop-assessment.d.ts +3 -0
- package/lib/slop-assessment.js +20 -0
- package/lib/stack-detection.d.ts +41 -0
- package/lib/stack-detection.js +248 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +441 -0
- package/lib/store-maintenance.d.ts +31 -0
- package/lib/store-maintenance.js +183 -0
- package/lib/submission-freshness-check.d.ts +32 -0
- package/lib/submission-freshness-check.js +93 -0
- package/lib/update-check.d.ts +36 -0
- package/lib/update-check.js +161 -0
- package/lib/version.d.ts +3 -0
- package/lib/version.js +10 -0
- package/lib/worktree-allocator.d.ts +39 -0
- package/lib/worktree-allocator.js +262 -0
- package/package.json +50 -0
- package/schema/miner-goal-spec.schema.json +111 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { removeWorktree } from "@loopover/engine";
|
|
3
|
+
import { openLocalStoreDb, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js";
|
|
4
|
+
|
|
5
|
+
// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports:
|
|
6
|
+
// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive
|
|
7
|
+
// worktree-allocator.ts (#4269) uses for attempt isolation, just detached rather than on a new branch,
|
|
8
|
+
// since a replay target is read-only, never a place to commit -- so it never mutates the caller's own
|
|
9
|
+
// checkout/branch.
|
|
10
|
+
// (b) a context bundle: commit history up to and including T (by ANCESTRY, via `git log T` -- walking the DAG
|
|
11
|
+
// is the tamper-resistant way to bound "up to T", since a commit's committer date is user-controlled and
|
|
12
|
+
// can't be trusted alone), tags reachable from T (`git tag --merged T`), and the README as it existed at
|
|
13
|
+
// T (`git ls-tree` + `git show T:<name>`, matched case-insensitively rather than a guessed filename list).
|
|
14
|
+
//
|
|
15
|
+
// REUSE NOTE: this issue's own text frames "the discover and analyze phases... already read git history" as
|
|
16
|
+
// the reuse starting point. Grepped both packages (git log/git tag/commits/tags/releases) before writing this
|
|
17
|
+
// and found no such utility anywhere -- opportunity-fanout.js reads GitHub API issue `updated_at`, not git
|
|
18
|
+
// commit/tag history at all. The one genuinely reusable piece is worktree-allocator.ts's injected-exec
|
|
19
|
+
// convention (WorktreeExecFn) and its removeWorktree -- both reused directly below (import from
|
|
20
|
+
// @loopover/engine), rather than inventing a THIRD "inject the git subprocess" abstraction
|
|
21
|
+
// alongside cli-subprocess-driver.ts's and worktree-allocator.ts's own.
|
|
22
|
+
//
|
|
23
|
+
// FAIL-FAST VALIDATION: ancestry-walking (git log T) already excludes anything NOT reachable from T by
|
|
24
|
+
// construction, but a tag can point at a commit that IS an ancestor of T while the TAG's own creation/tagger
|
|
25
|
+
// date is LATER (e.g. a tag added long after the commit it points to), and commit committer-dates are not
|
|
26
|
+
// strictly monotonic along the DAG in general (rebases, clock skew). So checking every exported commit's date
|
|
27
|
+
// and every exported tag's date against T's own commit date is a genuine, not merely defensive, check.
|
|
28
|
+
//
|
|
29
|
+
// PERSISTENCE: the context bundle is cached in the local store, UNIQUE-keyed on (repo_full_name, commit_sha) --
|
|
30
|
+
// re-exporting the same (repo, T) pair returns the identical cached row rather than re-running git, which is
|
|
31
|
+
// both how "byte-reproducible" holds trivially and avoids redundant work on repeat replay runs. The working-
|
|
32
|
+
// tree export itself is git-content-addressed already (the same commit SHA always checks out identical files).
|
|
33
|
+
|
|
34
|
+
const defaultDbFileName = "replay-snapshot.sqlite3";
|
|
35
|
+
let defaultDb = null;
|
|
36
|
+
|
|
37
|
+
export function resolveReplaySnapshotDbPath(env = process.env) {
|
|
38
|
+
return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_REPLAY_SNAPSHOT_DB", env);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeDbPath(dbPath) {
|
|
42
|
+
return normalizeLocalStoreDbPath(dbPath, resolveReplaySnapshotDbPath(), "invalid_replay_snapshot_db_path");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FIELD_SEP = "\x1f";
|
|
46
|
+
const README_NAME_PATTERN = /^readme(\.\w+)?$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeRepoFullName(repoFullName) {
|
|
49
|
+
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
|
|
50
|
+
const [owner, repo, extra] = repoFullName.trim().split("/");
|
|
51
|
+
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
|
|
52
|
+
return `${owner}/${repo}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeCommitSha(commitSha) {
|
|
56
|
+
if (typeof commitSha !== "string" || !commitSha.trim()) throw new Error("invalid_commit_sha");
|
|
57
|
+
return commitSha.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Worktree exports live under this dir inside the repo, mirroring worktree-allocator.ts's WORKTREE_SUBDIR. */
|
|
61
|
+
export const REPLAY_SNAPSHOT_SUBDIR = ".gittensory-replay-snapshots";
|
|
62
|
+
|
|
63
|
+
/** PURE: the deterministic on-disk location for a (repo, commit) replay export -- same pair -> same path. */
|
|
64
|
+
export function planReplaySnapshotPath(input) {
|
|
65
|
+
const commitSha = normalizeCommitSha(input.commitSha);
|
|
66
|
+
return join(input.repoPath, REPLAY_SNAPSHOT_SUBDIR, commitSha);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertExecResult(result, description) {
|
|
70
|
+
if (result.code !== 0) {
|
|
71
|
+
const detail = (result.stderr ?? "").trim() || `exit_${result.code}`;
|
|
72
|
+
throw new Error(`${description}: ${detail}`);
|
|
73
|
+
}
|
|
74
|
+
return result.stdout ?? "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Detached checkout at commitSha via `git worktree add --detach` -- never creates a branch, never touches the
|
|
78
|
+
* caller's own checkout. Idempotent in effect: `git worktree add` itself fails if the path already has a
|
|
79
|
+
* worktree, which callers avoid by checking the store cache first (see exportReplaySnapshot). */
|
|
80
|
+
async function addDetachedWorktree(exec, repoPath, worktreePath, commitSha) {
|
|
81
|
+
const result = await exec("git", ["worktree", "add", "--detach", worktreePath, commitSha], { cwd: repoPath });
|
|
82
|
+
assertExecResult(result, "git_worktree_add_failed");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readTargetCommitDate(exec, repoPath, commitSha) {
|
|
86
|
+
const result = await exec("git", ["log", "-1", "--format=%cI", commitSha], { cwd: repoPath });
|
|
87
|
+
const stdout = assertExecResult(result, "git_log_target_failed").trim();
|
|
88
|
+
if (!stdout) throw new Error(`git_log_target_failed: no commit found for ${commitSha}`);
|
|
89
|
+
return stdout;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readCommitHistory(exec, repoPath, commitSha) {
|
|
93
|
+
const result = await exec("git", ["log", commitSha, `--format=%H${FIELD_SEP}%cI${FIELD_SEP}%s`], { cwd: repoPath });
|
|
94
|
+
const stdout = assertExecResult(result, "git_log_history_failed");
|
|
95
|
+
return stdout
|
|
96
|
+
.split("\n")
|
|
97
|
+
.filter((line) => line.length > 0)
|
|
98
|
+
.map((line) => {
|
|
99
|
+
const [sha, date, subject] = line.split(FIELD_SEP);
|
|
100
|
+
return { sha, date, subject: subject ?? "" };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Lightweight tags have no tag object of their own, so `%(creatordate)` falls back to the POINTED-TO commit's
|
|
105
|
+
// date rather than a genuine tag-creation date -- git has no record of when a lightweight tag was actually
|
|
106
|
+
// created at all. That means a lightweight tag added long after T, but pointing at an ancestor of T, would
|
|
107
|
+
// silently pass validateSnapshotFreshness's date check every time (its reported "date" is always <= T's, by
|
|
108
|
+
// construction of --merged). Since this can never be verified, lightweight tags are excluded from the export
|
|
109
|
+
// entirely -- `%(objecttype)` is "tag" only for an annotated tag's own tag object, "commit" for a lightweight
|
|
110
|
+
// tag's direct target, which is how the two are told apart.
|
|
111
|
+
async function readReachableTags(exec, repoPath, commitSha) {
|
|
112
|
+
const result = await exec(
|
|
113
|
+
"git",
|
|
114
|
+
["tag", "--merged", commitSha, `--format=%(refname:short)${FIELD_SEP}%(creatordate:iso-strict)${FIELD_SEP}%(objectname)${FIELD_SEP}%(objecttype)`],
|
|
115
|
+
{ cwd: repoPath },
|
|
116
|
+
);
|
|
117
|
+
const stdout = assertExecResult(result, "git_tag_merged_failed");
|
|
118
|
+
return stdout
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter((line) => line.length > 0)
|
|
121
|
+
.map((line) => {
|
|
122
|
+
const [name, date, targetSha, objectType] = line.split(FIELD_SEP);
|
|
123
|
+
return { name, date, targetSha, objectType };
|
|
124
|
+
})
|
|
125
|
+
.filter((tag) => tag.objectType === "tag")
|
|
126
|
+
.map(({ objectType, ...tag }) => tag);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Finds the repo-root README (any casing/extension) at commitSha and returns its content, or null if none
|
|
130
|
+
* exists at that commit. Uses `git ls-tree` to find the real filename rather than guessing a fixed spelling
|
|
131
|
+
* list. */
|
|
132
|
+
async function readReadmeAtCommit(exec, repoPath, commitSha) {
|
|
133
|
+
const listing = await exec("git", ["ls-tree", "--name-only", commitSha], { cwd: repoPath });
|
|
134
|
+
const stdout = assertExecResult(listing, "git_ls_tree_failed");
|
|
135
|
+
const filename = stdout
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((line) => line.trim())
|
|
138
|
+
.find((line) => README_NAME_PATTERN.test(line));
|
|
139
|
+
if (!filename) return null;
|
|
140
|
+
|
|
141
|
+
const shown = await exec("git", ["show", `${commitSha}:${filename}`], { cwd: repoPath });
|
|
142
|
+
const content = assertExecResult(shown, "git_show_readme_failed");
|
|
143
|
+
return { filename, content };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** PURE: fails fast (throws) if any exported commit or tag carries a date LATER than the target commit's own
|
|
147
|
+
* date. Returns nothing on success. */
|
|
148
|
+
export function validateSnapshotFreshness(input) {
|
|
149
|
+
const targetMs = Date.parse(input.targetDate);
|
|
150
|
+
const violations = [];
|
|
151
|
+
for (const commit of input.commits) {
|
|
152
|
+
if (Date.parse(commit.date) > targetMs) violations.push(`commit ${commit.sha} dated ${commit.date} is after target ${input.targetDate}`);
|
|
153
|
+
}
|
|
154
|
+
for (const tag of input.tags) {
|
|
155
|
+
if (Date.parse(tag.date) > targetMs) violations.push(`tag ${tag.name} dated ${tag.date} is after target ${input.targetDate}`);
|
|
156
|
+
}
|
|
157
|
+
if (violations.length > 0) throw new Error(`replay_snapshot_freshness_violation: ${violations.join("; ")}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function openReplaySnapshotStore(dbPath = resolveReplaySnapshotDbPath()) {
|
|
161
|
+
const resolvedPath = normalizeDbPath(dbPath);
|
|
162
|
+
const db = openLocalStoreDb(resolvedPath);
|
|
163
|
+
db.exec(`
|
|
164
|
+
CREATE TABLE IF NOT EXISTS replay_snapshots (
|
|
165
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
166
|
+
repo_full_name TEXT NOT NULL,
|
|
167
|
+
commit_sha TEXT NOT NULL,
|
|
168
|
+
worktree_path TEXT NOT NULL,
|
|
169
|
+
target_date TEXT NOT NULL,
|
|
170
|
+
commits_json TEXT NOT NULL,
|
|
171
|
+
tags_json TEXT NOT NULL,
|
|
172
|
+
readme_filename TEXT,
|
|
173
|
+
readme_content TEXT,
|
|
174
|
+
exported_at TEXT NOT NULL,
|
|
175
|
+
UNIQUE (repo_full_name, commit_sha)
|
|
176
|
+
)
|
|
177
|
+
`);
|
|
178
|
+
const getStatement = db.prepare("SELECT * FROM replay_snapshots WHERE repo_full_name = ? AND commit_sha = ?");
|
|
179
|
+
const insertStatement = db.prepare(`
|
|
180
|
+
INSERT INTO replay_snapshots
|
|
181
|
+
(repo_full_name, commit_sha, worktree_path, target_date, commits_json, tags_json, readme_filename, readme_content, exported_at)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
183
|
+
`);
|
|
184
|
+
|
|
185
|
+
function rowToSnapshot(row) {
|
|
186
|
+
return {
|
|
187
|
+
repoFullName: row.repo_full_name,
|
|
188
|
+
commitSha: row.commit_sha,
|
|
189
|
+
worktreePath: row.worktree_path,
|
|
190
|
+
targetDate: row.target_date,
|
|
191
|
+
commits: JSON.parse(row.commits_json),
|
|
192
|
+
tags: JSON.parse(row.tags_json),
|
|
193
|
+
readme: row.readme_filename ? { filename: row.readme_filename, content: row.readme_content } : null,
|
|
194
|
+
exportedAt: row.exported_at,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
dbPath: resolvedPath,
|
|
200
|
+
getSnapshot(repoFullName, commitSha) {
|
|
201
|
+
const row = getStatement.get(normalizeRepoFullName(repoFullName), normalizeCommitSha(commitSha));
|
|
202
|
+
return row ? rowToSnapshot(row) : null;
|
|
203
|
+
},
|
|
204
|
+
saveSnapshot(snapshot) {
|
|
205
|
+
const repoFullName = normalizeRepoFullName(snapshot.repoFullName);
|
|
206
|
+
const commitSha = normalizeCommitSha(snapshot.commitSha);
|
|
207
|
+
insertStatement.run(
|
|
208
|
+
repoFullName,
|
|
209
|
+
commitSha,
|
|
210
|
+
snapshot.worktreePath,
|
|
211
|
+
snapshot.targetDate,
|
|
212
|
+
JSON.stringify(snapshot.commits),
|
|
213
|
+
JSON.stringify(snapshot.tags),
|
|
214
|
+
snapshot.readme?.filename ?? null,
|
|
215
|
+
snapshot.readme?.content ?? null,
|
|
216
|
+
new Date().toISOString(),
|
|
217
|
+
);
|
|
218
|
+
return this.getSnapshot(repoFullName, commitSha);
|
|
219
|
+
},
|
|
220
|
+
close() {
|
|
221
|
+
db.close();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getDefaultReplaySnapshotStore() {
|
|
227
|
+
defaultDb ??= openReplaySnapshotStore();
|
|
228
|
+
return defaultDb;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function closeDefaultReplaySnapshotStore() {
|
|
232
|
+
if (!defaultDb) return;
|
|
233
|
+
defaultDb.close();
|
|
234
|
+
defaultDb = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Export a frozen, reproducible replay snapshot for (repoFullName, commitSha): a detached working-tree checkout
|
|
239
|
+
* at that commit plus a context bundle (commit history, reachable tags, README-at-commit). Returns the CACHED
|
|
240
|
+
* snapshot without touching git again if one already exists for this exact (repo, commit) pair.
|
|
241
|
+
*
|
|
242
|
+
* @param {{ repoPath: string, repoFullName: string, commitSha: string }} input
|
|
243
|
+
* @param {{ exec: import("./worktree-allocator.js").WorktreeExecFn, store?: ReturnType<typeof openReplaySnapshotStore> }} deps
|
|
244
|
+
*/
|
|
245
|
+
export async function exportReplaySnapshot(input, deps) {
|
|
246
|
+
if (!input || typeof input !== "object") throw new Error("invalid_replay_snapshot_input");
|
|
247
|
+
const repoFullName = normalizeRepoFullName(input.repoFullName);
|
|
248
|
+
const commitSha = normalizeCommitSha(input.commitSha);
|
|
249
|
+
if (typeof input.repoPath !== "string" || !input.repoPath.trim()) throw new Error("invalid_repo_path");
|
|
250
|
+
const repoPath = input.repoPath.trim();
|
|
251
|
+
|
|
252
|
+
if (!deps || typeof deps !== "object" || typeof deps.exec !== "function") throw new Error("invalid_exec");
|
|
253
|
+
const { exec } = deps;
|
|
254
|
+
const store = deps.store ?? getDefaultReplaySnapshotStore();
|
|
255
|
+
|
|
256
|
+
const cached = store.getSnapshot(repoFullName, commitSha);
|
|
257
|
+
if (cached) return cached;
|
|
258
|
+
|
|
259
|
+
const worktreePath = planReplaySnapshotPath({ repoPath, commitSha });
|
|
260
|
+
await addDetachedWorktree(exec, repoPath, worktreePath, commitSha);
|
|
261
|
+
|
|
262
|
+
// Everything below can fail (a bad git read, or a deliberate freshness violation) after the worktree already
|
|
263
|
+
// exists on disk at the deterministic path above. Left behind, a retry for the same (repo, commit) pair would
|
|
264
|
+
// hit `git worktree add`'s own "path already exists" refusal instead of the real error, permanently masking
|
|
265
|
+
// it. Clean up the worktree on any failure here before rethrowing, so a retry starts from a clean slate.
|
|
266
|
+
try {
|
|
267
|
+
const targetDate = await readTargetCommitDate(exec, repoPath, commitSha);
|
|
268
|
+
const commits = await readCommitHistory(exec, repoPath, commitSha);
|
|
269
|
+
const tags = await readReachableTags(exec, repoPath, commitSha);
|
|
270
|
+
const readme = await readReadmeAtCommit(exec, repoPath, commitSha);
|
|
271
|
+
|
|
272
|
+
validateSnapshotFreshness({ targetDate, commits, tags });
|
|
273
|
+
|
|
274
|
+
return store.saveSnapshot({ repoFullName, commitSha, worktreePath, targetDate, commits, tags, readme });
|
|
275
|
+
} catch (error) {
|
|
276
|
+
await removeReplaySnapshotWorktree(exec, repoPath, worktreePath).catch(() => {
|
|
277
|
+
/* best-effort cleanup -- the original error below is the one that matters to the caller */
|
|
278
|
+
});
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Tear down a replay snapshot's working-tree export (the cached context-bundle row is left in place -- it is
|
|
284
|
+
* cheap, commit-keyed, and re-usable even after the on-disk tree is removed; only re-adding the worktree would
|
|
285
|
+
* require the tree again, which is out of this function's scope). */
|
|
286
|
+
export async function removeReplaySnapshotWorktree(exec, repoPath, worktreePath) {
|
|
287
|
+
return removeWorktree({ exec, repoPath, worktreePath });
|
|
288
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export const FORWARD_REF_PLACEHOLDER: string;
|
|
2
|
+
|
|
3
|
+
export type RecencyPool = "recent" | "older";
|
|
4
|
+
export const RECENCY_POOLS: readonly RecencyPool[];
|
|
5
|
+
|
|
6
|
+
export type ForwardReference = {
|
|
7
|
+
kind: "link" | "hashref" | "sha" | "bare-issue-number";
|
|
8
|
+
value: string | number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ForwardRefContext = {
|
|
12
|
+
knownIssueMax?: number;
|
|
13
|
+
knownCommitShas?: string[];
|
|
14
|
+
revealedIssueNumbers?: number[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DetectedForwardReferences = {
|
|
18
|
+
scrubbable: ForwardReference[];
|
|
19
|
+
unscrubbable: ForwardReference[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ScrubResult = {
|
|
23
|
+
scrubbed: string;
|
|
24
|
+
removed: ForwardReference[];
|
|
25
|
+
residual: ForwardReference[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type LintResult = {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
residual: ForwardReference[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type FreezePointThresholds = {
|
|
34
|
+
minPriorCommits?: number;
|
|
35
|
+
minRevealedCommits?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type FreezePointCandidate = {
|
|
39
|
+
repo?: string;
|
|
40
|
+
commitT?: string;
|
|
41
|
+
priorCommitCount?: number;
|
|
42
|
+
revealedCommitCount?: number;
|
|
43
|
+
lastActivityAt?: string;
|
|
44
|
+
frozenContextTexts?: unknown[];
|
|
45
|
+
revealedGroundTruth?: unknown;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type FreezePointSelection = {
|
|
49
|
+
eligible: boolean;
|
|
50
|
+
reasons: string[];
|
|
51
|
+
priorCommitCount: number;
|
|
52
|
+
revealedCommitCount: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ReplayTaskOptions = {
|
|
56
|
+
thresholds?: FreezePointThresholds;
|
|
57
|
+
modelCutoffIso?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ReplayTaskRejected = {
|
|
61
|
+
eligible: false;
|
|
62
|
+
rejected: "selection" | "unscrubbable_forward_reference";
|
|
63
|
+
reasons?: string[];
|
|
64
|
+
residual?: ForwardReference[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ReplayTask = {
|
|
68
|
+
eligible: true;
|
|
69
|
+
pool: RecencyPool;
|
|
70
|
+
frozen: {
|
|
71
|
+
repo: string | null;
|
|
72
|
+
commitT: string | null;
|
|
73
|
+
contextTexts: string[];
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type ReplayScoringKey = {
|
|
78
|
+
eligible: true;
|
|
79
|
+
commitCount: number;
|
|
80
|
+
groundTruth: unknown;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// generateReplayScoringKey never lints/scrubs frozen context (it doesn't touch context text at all), so
|
|
84
|
+
// unlike ReplayTaskRejected it can only ever reject on selection -- narrower than reusing ReplayTaskRejected,
|
|
85
|
+
// which would advertise an "unscrubbable_forward_reference" branch this function can never actually produce.
|
|
86
|
+
export type ReplayScoringKeyRejected = {
|
|
87
|
+
eligible: false;
|
|
88
|
+
rejected: "selection";
|
|
89
|
+
reasons: string[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function detectForwardReferences(
|
|
93
|
+
text: unknown,
|
|
94
|
+
context: ForwardRefContext | null | undefined,
|
|
95
|
+
): DetectedForwardReferences;
|
|
96
|
+
|
|
97
|
+
export function scrubForwardReferences(
|
|
98
|
+
text: unknown,
|
|
99
|
+
context: ForwardRefContext | null | undefined,
|
|
100
|
+
): ScrubResult;
|
|
101
|
+
|
|
102
|
+
export function lintFrozenContext(
|
|
103
|
+
texts: unknown,
|
|
104
|
+
context: ForwardRefContext | null | undefined,
|
|
105
|
+
): LintResult;
|
|
106
|
+
|
|
107
|
+
export function selectFreezePoint(
|
|
108
|
+
candidate: FreezePointCandidate | null | undefined,
|
|
109
|
+
thresholds: FreezePointThresholds | null | undefined,
|
|
110
|
+
): FreezePointSelection;
|
|
111
|
+
|
|
112
|
+
export function classifyRecencyPool(
|
|
113
|
+
candidate: FreezePointCandidate | null | undefined,
|
|
114
|
+
options: { modelCutoffIso?: string } | null | undefined,
|
|
115
|
+
): RecencyPool;
|
|
116
|
+
|
|
117
|
+
export function generateReplayTask(
|
|
118
|
+
candidate: FreezePointCandidate | null | undefined,
|
|
119
|
+
context: ForwardRefContext | null | undefined,
|
|
120
|
+
options: ReplayTaskOptions | null | undefined,
|
|
121
|
+
): ReplayTask | ReplayTaskRejected;
|
|
122
|
+
|
|
123
|
+
export function generateReplayScoringKey(
|
|
124
|
+
candidate: FreezePointCandidate | null | undefined,
|
|
125
|
+
options: ReplayTaskOptions | null | undefined,
|
|
126
|
+
): ReplayScoringKey | ReplayScoringKeyRejected;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Leakage-safe task generation for the historical-replay calibration harness (#3011).
|
|
2
|
+
//
|
|
3
|
+
// A frozen snapshot at commit T is only useful for calibration if (a) the freeze point has enough real
|
|
4
|
+
// history on both sides to be worth scoring, and (b) nothing in the frozen context lets a replay run infer
|
|
5
|
+
// the future by pattern-matching text rather than reasoning. This module selects calibration-worthy freeze
|
|
6
|
+
// points, scrubs forward references out of the frozen context, tags each point's recency pool, and returns
|
|
7
|
+
// the frozen replay task without the revealed post-T ground truth. Scoring data is exposed through a separate
|
|
8
|
+
// function so replay execution never has to hold both sides at once. Every function here is pure and
|
|
9
|
+
// deterministic — no clock, no randomness, no IO — so a given (candidate, context) always yields an
|
|
10
|
+
// identical task.
|
|
11
|
+
|
|
12
|
+
// What a scrubbed-away forward reference is replaced with. A fixed, self-delimiting token so the scrubbed
|
|
13
|
+
// text stays readable and the substitution is itself deterministic.
|
|
14
|
+
export const FORWARD_REF_PLACEHOLDER = "[redacted-forward-ref]";
|
|
15
|
+
|
|
16
|
+
// Recency pools. Freeze points are mixed across these bands so a judge/planner that has memorized recent
|
|
17
|
+
// public history cannot dominate the calibration signal.
|
|
18
|
+
export const RECENCY_POOLS = Object.freeze(["recent", "older"]);
|
|
19
|
+
|
|
20
|
+
function toIssueNumberSet(values) {
|
|
21
|
+
const set = new Set();
|
|
22
|
+
if (Array.isArray(values)) {
|
|
23
|
+
for (const value of values) {
|
|
24
|
+
if (Number.isInteger(value) && value > 0) set.add(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return set;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toShaSet(values) {
|
|
31
|
+
const set = new Set();
|
|
32
|
+
if (Array.isArray(values)) {
|
|
33
|
+
for (const value of values) {
|
|
34
|
+
if (typeof value === "string" && /^[0-9a-f]{7,40}$/i.test(value)) set.add(value.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return set;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveContext(context) {
|
|
41
|
+
return {
|
|
42
|
+
knownIssueMax:
|
|
43
|
+
Number.isInteger(context?.knownIssueMax) && context.knownIssueMax >= 0 ? context.knownIssueMax : 0,
|
|
44
|
+
knownCommitShas: toShaSet(context?.knownCommitShas),
|
|
45
|
+
revealedIssueNumbers: toIssueNumberSet(context?.revealedIssueNumbers),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Core scanner shared by scrub/detect/lint. Walks a text in a fixed priority order (deep-links first, so an
|
|
50
|
+
// issue/PR/commit URL is handled before its inner number/SHA can match a barer pattern) and classifies each
|
|
51
|
+
// forward reference as either:
|
|
52
|
+
// - scrubbable: a self-delimited token (`#123`, a GitHub issues/pull/commit URL, or a raw commit SHA) that
|
|
53
|
+
// resolves only to post-T state and can be safely replaced with the placeholder; or
|
|
54
|
+
// - unscrubbable: a *bare* integer that exactly matches a known post-T issue number. A bare number cannot be
|
|
55
|
+
// blanket-removed without destroying legitimate pre-T numbers (versions, counts), so it is detected but
|
|
56
|
+
// left in place — its presence must fail the freeze point rather than be silently mangled.
|
|
57
|
+
function processForwardReferences(rawText, context) {
|
|
58
|
+
const resolved = resolveContext(context);
|
|
59
|
+
const removed = [];
|
|
60
|
+
|
|
61
|
+
const text = typeof rawText === "string" ? rawText : "";
|
|
62
|
+
|
|
63
|
+
// 1. GitHub issue/pull deep-links whose number is after T.
|
|
64
|
+
let scrubbed = text.replace(
|
|
65
|
+
/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d+)\b/gi,
|
|
66
|
+
(match, digits) => {
|
|
67
|
+
if (Number(digits) > resolved.knownIssueMax) {
|
|
68
|
+
removed.push({ kind: "link", value: match });
|
|
69
|
+
return FORWARD_REF_PLACEHOLDER;
|
|
70
|
+
}
|
|
71
|
+
return match;
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// 2. GitHub commit deep-links whose SHA is not in pre-T history.
|
|
76
|
+
scrubbed = scrubbed.replace(
|
|
77
|
+
/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/commit\/([0-9a-f]{7,40})\b/gi,
|
|
78
|
+
(match, sha) => {
|
|
79
|
+
if (!resolved.knownCommitShas.has(sha.toLowerCase())) {
|
|
80
|
+
removed.push({ kind: "link", value: match });
|
|
81
|
+
return FORWARD_REF_PLACEHOLDER;
|
|
82
|
+
}
|
|
83
|
+
return match;
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// 3. Bare `#123` issue/PR references after T (not already inside a now-removed link).
|
|
88
|
+
scrubbed = scrubbed.replace(/(^|[^\w/])#(\d+)\b/g, (match, prefix, digits) => {
|
|
89
|
+
if (Number(digits) > resolved.knownIssueMax) {
|
|
90
|
+
removed.push({ kind: "hashref", value: `#${digits}` });
|
|
91
|
+
return `${prefix}${FORWARD_REF_PLACEHOLDER}`;
|
|
92
|
+
}
|
|
93
|
+
return match;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 4. Raw commit SHAs not in pre-T history. Require at least one hex letter so a plain decimal number is
|
|
97
|
+
// never misread as a SHA — those flow to the bare-issue-number residual check below instead.
|
|
98
|
+
scrubbed = scrubbed.replace(/(^|[^\w/#])([0-9a-f]{7,40})\b/gi, (match, prefix, sha) => {
|
|
99
|
+
if (!/[a-f]/i.test(sha)) return match;
|
|
100
|
+
if (!resolved.knownCommitShas.has(sha.toLowerCase())) {
|
|
101
|
+
removed.push({ kind: "sha", value: sha });
|
|
102
|
+
return `${prefix}${FORWARD_REF_PLACEHOLDER}`;
|
|
103
|
+
}
|
|
104
|
+
return match;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Residual: bare integers that name a real post-T issue and so leak the future, but cannot be safely
|
|
108
|
+
// auto-removed. Detected against the surviving text — if any remain, the freeze point is not usable as-is.
|
|
109
|
+
const residual = [];
|
|
110
|
+
if (resolved.revealedIssueNumbers.size > 0) {
|
|
111
|
+
for (const bareMatch of scrubbed.matchAll(/(?:^|[^\w#/])(\d+)\b/g)) {
|
|
112
|
+
const value = Number(bareMatch[1]);
|
|
113
|
+
if (resolved.revealedIssueNumbers.has(value)) {
|
|
114
|
+
residual.push({ kind: "bare-issue-number", value });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { scrubbed, removed, residual };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Detect forward references in text without modifying it, split by whether they can be safely scrubbed.
|
|
123
|
+
export function detectForwardReferences(text, context) {
|
|
124
|
+
const { removed, residual } = processForwardReferences(text, context);
|
|
125
|
+
return { scrubbable: removed, unscrubbable: residual };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Scrub the safely-removable forward references from text, returning the cleaned text, what was removed, and
|
|
129
|
+
// any unscrubbable references that remain (a non-empty `residual` means the text still leaks the future).
|
|
130
|
+
export function scrubForwardReferences(text, context) {
|
|
131
|
+
return processForwardReferences(text, context);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A freeze point's frozen context is clean iff every provided text scrubs to zero residual forward references.
|
|
135
|
+
export function lintFrozenContext(texts, context) {
|
|
136
|
+
const list = Array.isArray(texts) ? texts : texts == null ? [] : [texts];
|
|
137
|
+
const residual = [];
|
|
138
|
+
for (const text of list) {
|
|
139
|
+
residual.push(...processForwardReferences(text, context).residual);
|
|
140
|
+
}
|
|
141
|
+
return { ok: residual.length === 0, residual };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Selection: a freeze point is calibration-worthy only with enough real history on both sides of T.
|
|
145
|
+
export function selectFreezePoint(candidate, thresholds) {
|
|
146
|
+
const minPriorCommits = Number.isInteger(thresholds?.minPriorCommits) ? thresholds.minPriorCommits : 0;
|
|
147
|
+
const minRevealedCommits = Number.isInteger(thresholds?.minRevealedCommits)
|
|
148
|
+
? thresholds.minRevealedCommits
|
|
149
|
+
: 0;
|
|
150
|
+
const priorCommitCount = Number.isInteger(candidate?.priorCommitCount) ? candidate.priorCommitCount : 0;
|
|
151
|
+
const revealedCommitCount = Number.isInteger(candidate?.revealedCommitCount)
|
|
152
|
+
? candidate.revealedCommitCount
|
|
153
|
+
: 0;
|
|
154
|
+
|
|
155
|
+
const reasons = [];
|
|
156
|
+
if (priorCommitCount < minPriorCommits) reasons.push("insufficient_prior_history");
|
|
157
|
+
if (revealedCommitCount < minRevealedCommits) reasons.push("insufficient_revealed_history");
|
|
158
|
+
|
|
159
|
+
return { eligible: reasons.length === 0, reasons, priorCommitCount, revealedCommitCount };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Pool provenance: a freeze point whose last activity is at/after the calibration run's model cutoff is
|
|
163
|
+
// "recent" (higher memorization risk); everything else, including an unknown date, is "older". ISO-8601
|
|
164
|
+
// timestamps sort lexicographically, so no clock is needed.
|
|
165
|
+
export function classifyRecencyPool(candidate, options) {
|
|
166
|
+
const modelCutoffIso = typeof options?.modelCutoffIso === "string" ? options.modelCutoffIso : "";
|
|
167
|
+
const lastActivityAt = typeof candidate?.lastActivityAt === "string" ? candidate.lastActivityAt : "";
|
|
168
|
+
if (!modelCutoffIso || !lastActivityAt) return "older";
|
|
169
|
+
return lastActivityAt >= modelCutoffIso ? "recent" : "older";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// One-shot replay generator. Applies selection, then scrubs and lints the frozen context, then returns only
|
|
173
|
+
// the frozen replay task. Revealed post-T ground truth is intentionally available only through
|
|
174
|
+
// generateReplayScoringKey, so a replay worker/serializer/logger/model call never receives both sides.
|
|
175
|
+
export function generateReplayTask(candidate, context, options) {
|
|
176
|
+
const selection = selectFreezePoint(candidate, options?.thresholds);
|
|
177
|
+
if (!selection.eligible) {
|
|
178
|
+
return { eligible: false, rejected: "selection", reasons: selection.reasons };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const frozenTexts = Array.isArray(candidate?.frozenContextTexts) ? candidate.frozenContextTexts : [];
|
|
182
|
+
const lint = lintFrozenContext(frozenTexts, context);
|
|
183
|
+
if (!lint.ok) {
|
|
184
|
+
return { eligible: false, rejected: "unscrubbable_forward_reference", residual: lint.residual };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pool = classifyRecencyPool(candidate, options);
|
|
188
|
+
const scrubbedTexts = frozenTexts.map((text) => processForwardReferences(text, context).scrubbed);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
eligible: true,
|
|
192
|
+
pool,
|
|
193
|
+
frozen: {
|
|
194
|
+
repo: typeof candidate?.repo === "string" ? candidate.repo : null,
|
|
195
|
+
commitT: typeof candidate?.commitT === "string" ? candidate.commitT : null,
|
|
196
|
+
contextTexts: scrubbedTexts,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Scoring-only accessor. Call this from the isolated scorer path after replay execution has finished; do not
|
|
202
|
+
// pass its result to replay workers. It deliberately shares only selection eligibility with generateReplayTask
|
|
203
|
+
// and never carries frozen context.
|
|
204
|
+
//
|
|
205
|
+
// IMPORTANT: `eligible: true` here means only that selectFreezePoint accepted the candidate -- it does NOT
|
|
206
|
+
// mean generateReplayTask would also produce a usable frozen task for it. generateReplayTask can still reject
|
|
207
|
+
// a selection-eligible candidate afterward (`rejected: "unscrubbable_forward_reference"`, from
|
|
208
|
+
// lintFrozenContext), because scrub/lint eligibility is about the FROZEN CONTEXT TEXT, which this function
|
|
209
|
+
// never touches -- it only reveals commitCount/groundTruth, so lint/scrub has nothing to check here. A caller
|
|
210
|
+
// must not assume a scoring key implies a replay task was ever generated for the same candidate; check
|
|
211
|
+
// generateReplayTask's own result independently before treating the two as a matched pair.
|
|
212
|
+
export function generateReplayScoringKey(candidate, options) {
|
|
213
|
+
const selection = selectFreezePoint(candidate, options?.thresholds);
|
|
214
|
+
if (!selection.eligible) {
|
|
215
|
+
return { eligible: false, rejected: "selection", reasons: selection.reasons };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
eligible: true,
|
|
220
|
+
commitCount: selection.revealedCommitCount,
|
|
221
|
+
groundTruth: candidate?.revealedGroundTruth ?? null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function resolveRepoCloneBaseDir(env?: Record<string, string | undefined>): string;
|
|
2
|
+
|
|
3
|
+
export function resolveRepoCloneDir(repoFullName: string, env?: Record<string, string | undefined>): string;
|
|
4
|
+
|
|
5
|
+
export type EnsureRepoClonedResult = { ok: boolean; repoPath: string; error?: string };
|
|
6
|
+
|
|
7
|
+
export type RunGitFn = (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean; stdout: string; stderr: string }>;
|
|
8
|
+
|
|
9
|
+
export function ensureRepoCloned(
|
|
10
|
+
repoFullName: string,
|
|
11
|
+
options?: {
|
|
12
|
+
baseBranch?: string;
|
|
13
|
+
cloneBaseDir?: string;
|
|
14
|
+
env?: Record<string, string | undefined>;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
remoteUrl?: string;
|
|
17
|
+
runGit?: RunGitFn;
|
|
18
|
+
},
|
|
19
|
+
): Promise<EnsureRepoClonedResult>;
|