@withtruing/companion 0.0.0-stage → 0.13.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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The analyse_shipment task kind's own local-git work — reading a commit the server only knows
3
+ * by sha/title/message (see server/drift.ts's own comment on why the diff never crosses the
4
+ * wire to this package: the server doesn't fetch it at all for a local connection, same
5
+ * structural discipline every other dispatch-guarded function keeps). Shared by both backends
6
+ * (api-key-caller.ts, claude-cli-caller.ts) since neither can skip this step — even the CLI
7
+ * backend has no Bash/git tool available to it (`--restricted --tools "Read,Grep,Glob"`, see
8
+ * claude-cli-caller.ts), so "read this commit's diff" has to happen here, in this package's own
9
+ * trusted code, not inside the model's own tool-use loop the way file exploration does for
10
+ * every other task kind.
11
+ */
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { managedFetchEnvironment, reportBackgroundFetch } from './managed-repo.js';
15
+ const execFileAsync = promisify(execFile);
16
+ /** Copied verbatim from server/drift.ts's own looksTrivial (path-pattern only) — see that
17
+ * file's comment for why this one travels with the companion while looksIrrelevant (vocabulary-
18
+ * based, scored against the graph's own structured spec data) does not: the dispatch only
19
+ * sends rendered prose text (spec.stableText), not a structured Graph object, so there's
20
+ * nothing for a local vocabulary-overlap check to run against. This one needs only the diff's
21
+ * own changed-paths, which the companion reads directly — a real, if partial, way to keep goal
22
+ * 4 (reduce AI calls) alive on this path: a lockfile-only or CI-only commit still costs zero AI
23
+ * spend even though the fuller server-side filter doesn't run out here. */
24
+ const TRIVIAL_PATH_PATTERNS = [
25
+ /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Gemfile\.lock|poetry\.lock|Cargo\.lock)$/,
26
+ /^\.github\/workflows\//,
27
+ /^\.(eslintrc|prettierrc|editorconfig)/i,
28
+ /^(dist|build|coverage|node_modules)\//,
29
+ ];
30
+ function looksTrivial(changedPaths) {
31
+ if (!changedPaths.length)
32
+ return true;
33
+ return changedPaths.every((p) => TRIVIAL_PATH_PATTERNS.some((re) => re.test(p)));
34
+ }
35
+ /** Confirms the commit exists in the local checkout, attempting one `git fetch` first if it
36
+ * doesn't — using whatever git credentials/remotes are already configured locally (the
37
+ * customer's own, e.g. an SSH key or credential helper). A watched managed copy instead asks
38
+ * the Console for short-lived GitHub access. A webhook can fire before a customer's local
39
+ * checkout has pulled the commit it's about; this is the one real recovery attempt before
40
+ * giving up (see the plan's own edge-case table: "posts reason:'sha_not_local' rather than
41
+ * silently analysing the wrong tree state"). */
42
+ async function ensureCommitLocal(repoPath, sha) {
43
+ const hasCommit = async () => {
44
+ try {
45
+ await execFileAsync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: repoPath, windowsHide: true });
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ };
52
+ if (await hasCommit())
53
+ return true;
54
+ try {
55
+ await execFileAsync('git', ['fetch'], { cwd: repoPath, timeout: 60_000, env: await managedFetchEnvironment(repoPath), windowsHide: true });
56
+ reportBackgroundFetch(repoPath, { ok: true });
57
+ }
58
+ catch (error) {
59
+ const stderr = error.stderr;
60
+ reportBackgroundFetch(repoPath, { ok: false, stderr: typeof stderr === 'string' ? stderr : String(error) });
61
+ // Best-effort — network trouble, no remote configured, etc. hasCommit() below is the real
62
+ // verdict either way; a failed fetch just means it stays whatever it already was.
63
+ }
64
+ return hasCommit();
65
+ }
66
+ /** `git show <sha>` rather than `git diff <sha>~1 <sha>` — the latter fails outright on a root
67
+ * commit (no parent to diff against), which is a real case a webhook-triggered check could
68
+ * plausibly hit for a freshly-initialized repo; `git show` handles it correctly (diffs against
69
+ * an empty tree). Accepts the minor redundancy of `git show`'s own commit-metadata header
70
+ * duplicating what the server already sent in spec.volatileText (title/message) rather than
71
+ * trade away that robustness for a marginally cleaner prompt.
72
+ * `git show --name-only --pretty=format:` suppresses the header for the paths-only call, so
73
+ * changedPaths is exactly the file list, nothing else. No MAX_FILE_BYTES-style read cap here —
74
+ * unlike a whole file, a single commit's diff is essentially never pathologically large; the
75
+ * caller (api-key-caller.ts / claude-cli-caller.ts) still degrades gracefully past
76
+ * MAX_LOCAL_DIFF_CHARS the same way the server's own diff-size handling already does. */
77
+ async function readCommitDiff(repoPath, sha) {
78
+ const [{ stdout: diffText }, { stdout: pathsOut }] = await Promise.all([
79
+ execFileAsync('git', ['show', sha], { cwd: repoPath, maxBuffer: 64 * 1024 * 1024, windowsHide: true }),
80
+ execFileAsync('git', ['show', '--name-only', '--pretty=format:', sha], { cwd: repoPath, maxBuffer: 8 * 1024 * 1024, windowsHide: true }),
81
+ ]);
82
+ const changedPaths = pathsOut
83
+ .split('\n')
84
+ .map((s) => s.trim())
85
+ .filter(Boolean);
86
+ return { diffText, changedPaths };
87
+ }
88
+ /** The one shared entry point both backends call before ever spending an AI call — ensure the
89
+ * commit is actually here, read its real diff, and apply the same cheap deterministic filter
90
+ * the server runs for a server-execution connection (see poller.ts's own comment on why it
91
+ * can't run server-side for a local one). */
92
+ export async function prepareShipmentAnalysis(repoPath, sha) {
93
+ if (!(await ensureCommitLocal(repoPath, sha)))
94
+ return { outcome: 'sha_not_local' };
95
+ const { diffText, changedPaths } = await readCommitDiff(repoPath, sha);
96
+ if (looksTrivial(changedPaths))
97
+ return { outcome: 'trivial' };
98
+ return { outcome: 'proceed', diffText, changedPaths };
99
+ }
100
+ /** Diffs above this are too expensive/noisy to embed verbatim — mirrors server/drift.ts's own
101
+ * MAX_DIFF_CHARS, just a much higher ceiling: local reads aren't rate-limited or GitHub-API-
102
+ * metered the way the server's own diff fetch is (goal 1), so this is a sanity backstop against
103
+ * a pathological single commit (a vendored/generated file committed whole), not a real-world
104
+ * common-case constraint the way the server's 40,000-char cap is. */
105
+ const MAX_LOCAL_DIFF_CHARS = 300_000;
106
+ /** Embeds a locally-read diff into spec.volatileText, degrading the same way
107
+ * server/drift.ts's own sizeNote does for an oversized diff — changed-files-only, not silently
108
+ * truncated mid-patch. Shared by both backends so the size-aware wording only needs writing
109
+ * once. */
110
+ export function appendDiffToVolatile(spec, diffText, changedPaths) {
111
+ const diffSection = diffText.length <= MAX_LOCAL_DIFF_CHARS
112
+ ? `# The diff\n\n${diffText}`
113
+ : `# The diff\n\n(${diffText.length} characters — too large to include in full; going on the changed files list only)\n\nChanged files:\n${changedPaths.join('\n')}`;
114
+ return { ...spec, volatileText: `${spec.volatileText}\n\n${diffSection}` };
115
+ }
@@ -0,0 +1,187 @@
1
+ const RESTRICTED_KEYWORDS = [
2
+ 'allOf',
3
+ 'oneOf',
4
+ 'not',
5
+ 'if',
6
+ 'then',
7
+ 'else',
8
+ ];
9
+ function isSchema(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ function cloneSchema(schema) {
13
+ return structuredClone(schema);
14
+ }
15
+ function propertyPath(path, property) {
16
+ return `${path}.properties[${JSON.stringify(property)}]`;
17
+ }
18
+ function isNullable(schema) {
19
+ const type = schema.type;
20
+ if (type === 'null' || (Array.isArray(type) && type.includes('null')))
21
+ return true;
22
+ if (Array.isArray(schema.enum) && schema.enum.includes(null))
23
+ return true;
24
+ if (Array.isArray(schema.anyOf)) {
25
+ return schema.anyOf.some((alternative) => isSchema(alternative) && isNullable(alternative));
26
+ }
27
+ return false;
28
+ }
29
+ function makeNullable(schema) {
30
+ const type = schema.type;
31
+ const objectOrArray = type === 'object' || type === 'array' || 'properties' in schema || 'items' in schema;
32
+ if (objectOrArray) {
33
+ return { anyOf: [schema, { type: 'null' }] };
34
+ }
35
+ if (typeof type === 'string') {
36
+ schema.type = [type, 'null'];
37
+ }
38
+ else if (Array.isArray(type)) {
39
+ schema.type = [...type, 'null'];
40
+ }
41
+ else if (!Array.isArray(schema.enum)) {
42
+ return { anyOf: [schema, { type: 'null' }] };
43
+ }
44
+ if (Array.isArray(schema.enum) && !schema.enum.includes(null)) {
45
+ schema.enum = [...schema.enum, null];
46
+ }
47
+ return schema;
48
+ }
49
+ function transformSchema(schema, schemaPath, valuePath, context) {
50
+ for (const keyword of RESTRICTED_KEYWORDS) {
51
+ if (!(keyword in schema))
52
+ continue;
53
+ if (!context.enforcedAfter.has(keyword)) {
54
+ throw new Error(`Codex rejects JSON Schema keyword ${keyword} at ${schemaPath}; ` +
55
+ `it may only be removed when the caller lists ${keyword} in enforcedAfter and re-checks it after the reply.`);
56
+ }
57
+ delete schema[keyword];
58
+ context.record.removedKeywords.push({ path: schemaPath, keyword });
59
+ }
60
+ if (Array.isArray(schema.anyOf)) {
61
+ schema.anyOf = schema.anyOf.map((alternative, index) => isSchema(alternative)
62
+ ? transformSchema(alternative, `${schemaPath}.anyOf[${index}]`, valuePath, context)
63
+ : alternative);
64
+ }
65
+ const objectSchema = schema.type === 'object' || isSchema(schema.properties);
66
+ if (objectSchema) {
67
+ if (!('additionalProperties' in schema)) {
68
+ schema.additionalProperties = false;
69
+ context.record.additionalPropertiesFalseAdded.push(schemaPath);
70
+ }
71
+ else if (schema.additionalProperties !== false) {
72
+ schema.additionalProperties = false;
73
+ context.record.additionalPropertiesChangedToFalse.push(schemaPath);
74
+ }
75
+ const properties = isSchema(schema.properties) ? schema.properties : {};
76
+ const originallyRequired = new Set(Array.isArray(schema.required)
77
+ ? schema.required.filter((entry) => typeof entry === 'string')
78
+ : []);
79
+ const propertyNames = Object.keys(properties);
80
+ schema.required = propertyNames;
81
+ const transformedProperties = {};
82
+ for (const property of propertyNames) {
83
+ const propertySchema = properties[property];
84
+ if (!isSchema(propertySchema)) {
85
+ transformedProperties[property] = propertySchema;
86
+ continue;
87
+ }
88
+ const childSchemaPath = propertyPath(schemaPath, property);
89
+ const childValuePath = [...valuePath, property];
90
+ let transformed = transformSchema(propertySchema, childSchemaPath, childValuePath, context);
91
+ if (!originallyRequired.has(property)) {
92
+ context.record.requiredPropertiesAdded.push(childSchemaPath);
93
+ if (!isNullable(transformed)) {
94
+ transformed = makeNullable(transformed);
95
+ context.record.introducedNullableProperties.push({
96
+ schemaPath: childSchemaPath,
97
+ valuePath: childValuePath,
98
+ });
99
+ }
100
+ }
101
+ transformedProperties[property] = transformed;
102
+ }
103
+ schema.properties = transformedProperties;
104
+ }
105
+ if (isSchema(schema.items)) {
106
+ schema.items = transformSchema(schema.items, `${schemaPath}.items`, [...valuePath, { arrayItem: true }], context);
107
+ }
108
+ else if (Array.isArray(schema.items)) {
109
+ schema.items = schema.items.map((item, index) => isSchema(item)
110
+ ? transformSchema(item, `${schemaPath}.items[${index}]`, [...valuePath, { arrayItem: true }], context)
111
+ : item);
112
+ }
113
+ for (const keyword of ['$defs', 'definitions', 'dependentSchemas']) {
114
+ const schemas = schema[keyword];
115
+ if (!isSchema(schemas))
116
+ continue;
117
+ const transformed = {};
118
+ for (const [name, child] of Object.entries(schemas)) {
119
+ transformed[name] = isSchema(child)
120
+ ? transformSchema(child, `${schemaPath}.${keyword}[${JSON.stringify(name)}]`, valuePath, context)
121
+ : child;
122
+ }
123
+ schema[keyword] = transformed;
124
+ }
125
+ for (const keyword of ['contains', 'propertyNames']) {
126
+ if (isSchema(schema[keyword])) {
127
+ schema[keyword] = transformSchema(schema[keyword], `${schemaPath}.${keyword}`, [...valuePath, ...(keyword === 'contains' ? [{ arrayItem: true }] : [])], context);
128
+ }
129
+ }
130
+ if (Array.isArray(schema.prefixItems)) {
131
+ schema.prefixItems = schema.prefixItems.map((item, index) => isSchema(item)
132
+ ? transformSchema(item, `${schemaPath}.prefixItems[${index}]`, [...valuePath, { arrayItem: true }], context)
133
+ : item);
134
+ }
135
+ return schema;
136
+ }
137
+ /** Convert a conventional object-result schema to Codex/OpenAI strict structured-output form.
138
+ * Restricted conditional/composition keywords are never weakened silently: callers must name
139
+ * each rule that their own post-response validation will enforce. */
140
+ export function toCodexStrictSchema(input, options = {}) {
141
+ if (!isSchema(input) || input.type !== 'object' || 'anyOf' in input) {
142
+ throw new Error('Codex structured output requires a single object schema at the root; root anyOf and non-object roots are not supported.');
143
+ }
144
+ const record = {
145
+ requiredPropertiesAdded: [],
146
+ introducedNullableProperties: [],
147
+ additionalPropertiesFalseAdded: [],
148
+ additionalPropertiesChangedToFalse: [],
149
+ removedKeywords: [],
150
+ };
151
+ return {
152
+ schema: transformSchema(cloneSchema(input), '$', [], {
153
+ enforcedAfter: new Set(options.enforcedAfter ?? []),
154
+ record,
155
+ }),
156
+ record,
157
+ };
158
+ }
159
+ function stripAtPath(value, path, index = 0) {
160
+ if (value === null || typeof value !== 'object' || index >= path.length)
161
+ return;
162
+ const segment = path[index];
163
+ if (typeof segment !== 'string') {
164
+ if (!Array.isArray(value))
165
+ return;
166
+ for (const item of value)
167
+ stripAtPath(item, path, index + 1);
168
+ return;
169
+ }
170
+ if (Array.isArray(value))
171
+ return;
172
+ const object = value;
173
+ if (index === path.length - 1) {
174
+ if (object[segment] === null)
175
+ delete object[segment];
176
+ return;
177
+ }
178
+ stripAtPath(object[segment], path, index + 1);
179
+ }
180
+ /** Restore the absent-property shape used by the Claude path. Only nullability introduced by
181
+ * this transform is stripped; null accepted by the original schema remains intact. */
182
+ export function stripIntroducedNulls(value, record) {
183
+ for (const introduced of record.introducedNullableProperties) {
184
+ stripAtPath(value, introduced.valuePath);
185
+ }
186
+ return value;
187
+ }
@@ -0,0 +1,357 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { statSync } from 'node:fs';
4
+ import { promisify } from 'node:util';
5
+ import { hasLinkedPathComponent } from './investigation-reader.js';
6
+ import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, isSupportedMapperVersion, MappingFailure, trackedNamedPathStatus, } from './import-resolver.js';
7
+ import { repositoryMissingReason, spawnFailureCode } from './local-scan.js';
8
+ import { createSandbox } from './sandbox.js';
9
+ export const SURFACE_SEED_PROMPT_VERSION = 'map_module_seeds@1';
10
+ export const SURFACE_SEED_MODEL = 'claude-sonnet-5-5';
11
+ const execFileAsync = promisify(execFile);
12
+ const MODEL_ROLES = [
13
+ 'implementation',
14
+ 'schema_or_migration',
15
+ 'ui',
16
+ 'registration',
17
+ 'contract',
18
+ 'config',
19
+ ];
20
+ const MODEL_ROLE_SET = new Set(MODEL_ROLES);
21
+ const compareDiagnostic = (a, b) => a.code.localeCompare(b.code) || a.path.localeCompare(b.path) || a.detail.localeCompare(b.detail);
22
+ function failureDetail(errorOrReason) {
23
+ if (typeof errorOrReason === 'string')
24
+ return errorOrReason;
25
+ if (errorOrReason && typeof errorOrReason === 'object') {
26
+ const candidate = errorOrReason;
27
+ if (typeof candidate.reason === 'string')
28
+ return candidate.reason;
29
+ if (typeof candidate.message === 'string')
30
+ return candidate.message;
31
+ }
32
+ return String(errorOrReason);
33
+ }
34
+ function hasUnavailableSpawnCode(error, seen = new Set()) {
35
+ if (!error || typeof error !== 'object' || seen.has(error))
36
+ return false;
37
+ seen.add(error);
38
+ const candidate = error;
39
+ if (candidate.code === 'ENOENT' || candidate.code === 'EACCES')
40
+ return true;
41
+ if (hasUnavailableSpawnCode(candidate.cause, seen))
42
+ return true;
43
+ return Array.isArray(candidate.errors)
44
+ && candidate.errors.some((nested) => hasUnavailableSpawnCode(nested, seen));
45
+ }
46
+ function repositoryPathIsMissing(repoPath) {
47
+ try {
48
+ return !statSync(repoPath).isDirectory();
49
+ }
50
+ catch (error) {
51
+ const code = spawnFailureCode(error);
52
+ return code === 'ENOENT' || code === 'ENOTDIR';
53
+ }
54
+ }
55
+ /** Central authority for deciding whether failure to obtain an immutable repository sandbox can
56
+ * consume the retry budget or is a genuinely missing commit. */
57
+ export function classifySandboxFailure(errorOrReason, repoPath) {
58
+ if (repoPath && repositoryPathIsMissing(repoPath)) {
59
+ return { kind: 'transient', code: 'repository_unavailable', detail: repositoryMissingReason(repoPath) };
60
+ }
61
+ const detail = failureDetail(errorOrReason);
62
+ const spawnUnavailable = hasUnavailableSpawnCode(errorOrReason) || /spawn git(?:\.exe)?\b/i.test(detail);
63
+ const repositoryInvalid = /not a git repository|doesn't look like a git repository|cannot change to|no such file or directory/i.test(detail);
64
+ const unavailable = errorOrReason && typeof errorOrReason === 'object'
65
+ ? errorOrReason
66
+ : undefined;
67
+ const fetchAttempted = unavailable?.fetchOk === true || unavailable?.fetchOk === false
68
+ || /after fetch (?:succeeded|failed)/i.test(detail);
69
+ const unresolvedCommit = /commit [0-9a-f]+ could not be resolved/i.test(detail);
70
+ if (!spawnUnavailable && !repositoryInvalid && fetchAttempted && unresolvedCommit) {
71
+ return { kind: 'stable', code: 'commit_unavailable', detail };
72
+ }
73
+ return { kind: 'transient', code: 'repository_unavailable', detail };
74
+ }
75
+ const seedSchema = {
76
+ type: 'object',
77
+ additionalProperties: false,
78
+ required: ['seeds', 'roles'],
79
+ properties: {
80
+ seeds: {
81
+ type: 'array', minItems: 1, maxItems: 12,
82
+ items: {
83
+ type: 'object', additionalProperties: false, required: ['path', 'role', 'reason'],
84
+ properties: {
85
+ path: { type: 'string' },
86
+ role: { enum: [...MODEL_ROLES] },
87
+ reason: { type: 'string' },
88
+ },
89
+ },
90
+ },
91
+ roles: {
92
+ type: 'array', minItems: MODEL_ROLES.length, maxItems: MODEL_ROLES.length,
93
+ items: {
94
+ type: 'object', additionalProperties: false, required: ['role', 'status', 'note'],
95
+ properties: {
96
+ role: { enum: [...MODEL_ROLES] },
97
+ status: { enum: ['seeded', 'none_identified'] },
98
+ note: { type: 'string' },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ };
104
+ function safeRepoPath(path) {
105
+ return path.length > 0
106
+ && !path.startsWith('/')
107
+ && !path.includes('\\')
108
+ && !/^[A-Za-z]:/.test(path)
109
+ && !path.split('/').some((segment) => !segment || segment === '.' || segment === '..');
110
+ }
111
+ function parseNameStatus(output) {
112
+ if (output === '')
113
+ return [];
114
+ if (!output.endsWith('\0'))
115
+ return null;
116
+ const fields = output.slice(0, -1).split('\0');
117
+ const changes = [];
118
+ for (let index = 0; index < fields.length;) {
119
+ const rawStatus = fields[index++];
120
+ const scored = /^([RC])([0-9]{1,3})$/.exec(rawStatus);
121
+ const status = scored?.[1] ?? rawStatus;
122
+ if (!['A', 'D', 'M', 'R', 'C', 'T'].includes(status)
123
+ || (scored && Number(scored[2]) > 100))
124
+ return null;
125
+ if (status === 'R' || status === 'C') {
126
+ const fromPath = fields[index++];
127
+ const path = fields[index++];
128
+ if (!fromPath || !path || !safeRepoPath(fromPath) || !safeRepoPath(path))
129
+ return null;
130
+ changes.push({ status, fromPath, path });
131
+ }
132
+ else {
133
+ const path = fields[index++];
134
+ if (!path || !safeRepoPath(path))
135
+ return null;
136
+ changes.push({ status: status, path });
137
+ }
138
+ }
139
+ return changes.sort((left, right) => left.path.localeCompare(right.path)
140
+ || left.status.localeCompare(right.status));
141
+ }
142
+ /** Reads the exact tree change offered by the server. A failed or malformed diff simply declines
143
+ * reuse; a fresh model seed stage remains available. */
144
+ export async function readReuseChangeSet(sandboxPath, candidateCommit, headCommit) {
145
+ try {
146
+ const { stdout } = await execFileAsync('git', ['--literal-pathspecs', 'diff', '--no-color', '--name-status', '-z', '-M', candidateCommit, headCommit, '--'], { cwd: sandboxPath, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true });
147
+ return parseNameStatus(stdout);
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ function isRecord(value) {
154
+ return !!value && typeof value === 'object' && !Array.isArray(value);
155
+ }
156
+ function validateModelReply(raw) {
157
+ if (!isRecord(raw) || !Array.isArray(raw.seeds) || !Array.isArray(raw.roles)
158
+ || raw.seeds.length < 1 || raw.seeds.length > 12 || raw.roles.length !== MODEL_ROLES.length) {
159
+ throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned the wrong shape');
160
+ }
161
+ const seeds = raw.seeds.map((value) => {
162
+ if (!isRecord(value) || typeof value.path !== 'string' || !MODEL_ROLE_SET.has(String(value.role))
163
+ || typeof value.reason !== 'string' || !value.reason.trim()) {
164
+ throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned a malformed seed');
165
+ }
166
+ return { path: value.path, role: value.role, reason: value.reason };
167
+ });
168
+ const roles = raw.roles.map((value) => {
169
+ if (!isRecord(value) || !MODEL_ROLE_SET.has(String(value.role))
170
+ || (value.status !== 'seeded' && value.status !== 'none_identified')
171
+ || typeof value.note !== 'string') {
172
+ throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned malformed role accounting');
173
+ }
174
+ return {
175
+ role: value.role,
176
+ status: value.status,
177
+ note: value.note,
178
+ };
179
+ });
180
+ if (new Set(roles.map((entry) => entry.role)).size !== MODEL_ROLES.length
181
+ || MODEL_ROLES.some((role) => !roles.some((entry) => entry.role === role))) {
182
+ throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model must account for every role exactly once');
183
+ }
184
+ return { seeds, roles };
185
+ }
186
+ function seedPrompt(claim, trackedPaths) {
187
+ return `# Module\n\n${JSON.stringify({
188
+ title: claim.seedInput.module.title,
189
+ specText: claim.seedInput.module.specText,
190
+ }, null, 2)}\n\n# Statements\n\n${JSON.stringify(claim.seedInput.statements.map((statement) => statement.text), null, 2)}\n\n# Tracked repository paths\n\n${trackedPaths.join('\n')}\n\n# Task\n\nChoose 1–12 tracked files most likely to implement this module. Account for every required role. A none_identified role records uncertainty; it is not evidence that the role is absent.`;
191
+ }
192
+ export async function mapSurfaceSnapshot(input) {
193
+ const { claim, sandboxPath, tree } = input;
194
+ if (!isSupportedMapperVersion(claim.mapperVersion)) {
195
+ throw new MappingFailure('stable', 'mapper_version_unsupported', `Unsupported mapper version ${String(claim.mapperVersion)}`);
196
+ }
197
+ const diagnostics = [];
198
+ const namedPaths = [];
199
+ const namedSeeds = [];
200
+ for (const path of [...claim.seedInput.namedPaths].sort((left, right) => left.localeCompare(right))) {
201
+ let status = trackedNamedPathStatus(tree, path);
202
+ if (status !== 'unverifiable' && await hasLinkedPathComponent(sandboxPath, path))
203
+ status = 'unverifiable';
204
+ namedPaths.push({ path, present: status === 'present' });
205
+ if (status === 'present')
206
+ namedSeeds.push({ path, role: 'named', source: 'named' });
207
+ else if (status === 'missing')
208
+ diagnostics.push({
209
+ code: 'named_path_missing', path, detail: 'absent from complete tracked-file enumeration',
210
+ });
211
+ else
212
+ diagnostics.push({
213
+ code: 'named_path_unverifiable', path, detail: 'not a safe tracked regular file',
214
+ });
215
+ }
216
+ const regularPaths = [...tree.entries.values()]
217
+ .filter((entry) => (entry.mode === '100644' || entry.mode === '100755') && entry.type === 'blob')
218
+ .map((entry) => entry.path)
219
+ .sort((left, right) => left.localeCompare(right));
220
+ const regularSet = new Set(regularPaths);
221
+ let promptVersion;
222
+ let backend;
223
+ let model;
224
+ let modelSeeds;
225
+ let roles;
226
+ if (input.reuse) {
227
+ ({ promptVersion, backend, model, modelSeeds, roles } = input.reuse.candidate.modelJudgement);
228
+ modelSeeds = modelSeeds.map((seed) => ({ ...seed }));
229
+ roles = roles.map((role) => ({ ...role }));
230
+ }
231
+ else {
232
+ let raw;
233
+ try {
234
+ raw = await input.callModel({
235
+ model: SURFACE_SEED_MODEL,
236
+ effort: 'medium',
237
+ maxTokens: 4_096,
238
+ system: 'Select plausible module seed files only from the supplied tracked path list. Return strict JSON matching the schema.',
239
+ prompt: seedPrompt(claim, regularPaths),
240
+ schema: seedSchema,
241
+ });
242
+ }
243
+ catch (error) {
244
+ if (error instanceof MappingFailure)
245
+ throw error;
246
+ throw new MappingFailure('transient', 'seed_model_failed', error instanceof Error ? error.message : String(error));
247
+ }
248
+ const reply = validateModelReply(raw);
249
+ promptVersion = SURFACE_SEED_PROMPT_VERSION;
250
+ backend = input.execution.backend;
251
+ model = input.execution.model;
252
+ modelSeeds = reply.seeds.map((seed) => ({ ...seed, valid: false }));
253
+ roles = reply.roles.sort((left, right) => left.role.localeCompare(right.role));
254
+ }
255
+ const validModelSeeds = [];
256
+ for (const seed of modelSeeds) {
257
+ const validAtCommit = regularSet.has(seed.path) && !(await hasLinkedPathComponent(sandboxPath, seed.path));
258
+ if (!input.reuse)
259
+ seed.valid = validAtCommit;
260
+ const valid = seed.valid && validAtCommit;
261
+ if (valid)
262
+ validModelSeeds.push({ path: seed.path, role: seed.role, source: 'model' });
263
+ else
264
+ diagnostics.push({ code: 'model_seed_invalid', path: seed.path, detail: seed.reason });
265
+ }
266
+ const seedByPath = new Map();
267
+ for (const seed of namedSeeds)
268
+ seedByPath.set(seed.path, seed);
269
+ for (const seed of validModelSeeds)
270
+ if (!seedByPath.has(seed.path))
271
+ seedByPath.set(seed.path, seed);
272
+ const seeds = [...seedByPath.values()].sort((a, b) => a.path.localeCompare(b.path));
273
+ if (seeds.length === 0)
274
+ throw new MappingFailure('stable', 'no_seeds', 'No present named path or valid model seed');
275
+ const expanded = await expandImports({
276
+ sandboxPath,
277
+ commit: claim.commit,
278
+ tree,
279
+ seeds,
280
+ settings: claim.mapperSettings,
281
+ mapperVersion: claim.mapperVersion,
282
+ });
283
+ const expansion = diagnostics.some((diagnostic) => diagnostic.code === 'named_path_unverifiable')
284
+ ? { ...expanded, status: 'incomplete' }
285
+ : expanded;
286
+ return {
287
+ format: 'auto-surface-manifest@1',
288
+ generationId: claim.generationId,
289
+ workspaceId: claim.workspaceId,
290
+ moduleId: claim.moduleId,
291
+ connectionId: claim.connectionId,
292
+ commit: claim.commit,
293
+ inputDigest: claim.inputDigest,
294
+ seedStage: {
295
+ promptVersion,
296
+ backend,
297
+ model,
298
+ namedPaths,
299
+ modelSeeds: input.reuse
300
+ ? modelSeeds
301
+ : modelSeeds.sort((a, b) => a.path.localeCompare(b.path) || a.role.localeCompare(b.role)),
302
+ roles,
303
+ seeds,
304
+ diagnostics: diagnostics.sort(compareDiagnostic),
305
+ ...(input.reuse
306
+ ? { reusedFrom: input.reuse.candidate.generationId, changeSet: input.reuse.changeSet }
307
+ : {}),
308
+ },
309
+ expansion,
310
+ };
311
+ }
312
+ export async function runSurfaceMapping(input) {
313
+ let sandbox;
314
+ try {
315
+ sandbox = await createSandbox(input.repoPath, input.claim.commit);
316
+ }
317
+ catch (error) {
318
+ const failure = classifySandboxFailure(error, input.repoPath);
319
+ throw new MappingFailure(failure.kind, failure.code, failure.detail);
320
+ }
321
+ if (sandbox.obtainedBy === 'unavailable') {
322
+ const failure = classifySandboxFailure(sandbox, input.repoPath);
323
+ throw new MappingFailure(failure.kind, failure.code, failure.detail);
324
+ }
325
+ try {
326
+ const tree = await enumerateTrackedFiles(sandbox.path, input.claim.commit);
327
+ const candidate = input.claim.reuseCandidate;
328
+ const changeSet = candidate
329
+ ? await readReuseChangeSet(sandbox.path, candidate.commit, input.claim.commit)
330
+ : null;
331
+ const expansionFiles = new Set(candidate?.expansionFiles ?? []);
332
+ const reuse = candidate && changeSet
333
+ && changeSet.every((change) => change.status === 'M' && expansionFiles.has(change.path))
334
+ ? { candidate, changeSet }
335
+ : undefined;
336
+ return await mapSurfaceSnapshot({ ...input, sandboxPath: sandbox.path, tree, ...(reuse ? { reuse } : {}) });
337
+ }
338
+ finally {
339
+ await sandbox.cleanup();
340
+ }
341
+ }
342
+ export function canonicalManifestJson(manifest) {
343
+ const canonical = (value) => {
344
+ if (Array.isArray(value))
345
+ return value.map(canonical);
346
+ if (value && typeof value === 'object') {
347
+ return Object.fromEntries(Object.keys(value).sort()
348
+ .map((key) => [key, canonical(value[key])]));
349
+ }
350
+ return value;
351
+ };
352
+ return JSON.stringify(canonical(manifest));
353
+ }
354
+ export function manifestHash(manifest) {
355
+ return `sha256:${createHash('sha256').update(canonicalManifestJson(manifest)).digest('hex')}`;
356
+ }
357
+ export { DEFAULT_MAPPER_SETTINGS, MappingFailure };