imprint-mcp 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/cli.ts +61 -9
- package/src/imprint/teach-state.ts +156 -14
- package/src/imprint/teach.ts +297 -116
- package/src/imprint/tool-candidates.ts +121 -0
package/README.md
CHANGED
|
@@ -93,6 +93,11 @@ regenerate/record it with `bun scripts/demo-teach.ts`.)*
|
|
|
93
93
|
> [!TIP]
|
|
94
94
|
> All three steps happen in a single `imprint teach` command. Credentials and PII are redacted automatically before anything reaches the LLM.
|
|
95
95
|
|
|
96
|
+
When one recording yields multiple tools, all candidates are selected by default.
|
|
97
|
+
Imprint automatically includes upstream lookup/producer tools required by any
|
|
98
|
+
selected downstream tool. Use `--primary-tool` to compile only the primary tool
|
|
99
|
+
and its prerequisites; `--all-tools` makes the default full-set selection explicit.
|
|
100
|
+
|
|
96
101
|
The recorder also has a bounded fallback for Next.js React Server Component
|
|
97
102
|
responses that Chromium evicts before `Network.getResponseBody` can read them.
|
|
98
103
|
It streams only narrowly identified GET navigation/prefetch and POST Server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,45 @@ export function parseDuration(dur: string): number | null {
|
|
|
40
40
|
return num;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type TeachSelectionMode =
|
|
44
|
+
| 'tool'
|
|
45
|
+
| 'primary'
|
|
46
|
+
| 'all-explicit'
|
|
47
|
+
| 'all-default'
|
|
48
|
+
| 'interactive-default-all';
|
|
49
|
+
|
|
50
|
+
/** Pure CLI selection-mode resolver shared by validation and telemetry. */
|
|
51
|
+
export function resolveTeachSelectionMode(opts: {
|
|
52
|
+
tool?: string;
|
|
53
|
+
primaryTool?: boolean;
|
|
54
|
+
allTools?: boolean;
|
|
55
|
+
noInteractive?: boolean;
|
|
56
|
+
}): { mode: TeachSelectionMode; error?: string } {
|
|
57
|
+
if (opts.tool && opts.allTools) {
|
|
58
|
+
return { mode: 'tool', error: 'error: --tool cannot be combined with --all-tools' };
|
|
59
|
+
}
|
|
60
|
+
if (opts.tool && opts.primaryTool) {
|
|
61
|
+
return { mode: 'tool', error: 'error: --tool cannot be combined with --primary-tool' };
|
|
62
|
+
}
|
|
63
|
+
if (opts.allTools && opts.primaryTool) {
|
|
64
|
+
return {
|
|
65
|
+
mode: 'primary',
|
|
66
|
+
error: 'error: --all-tools cannot be combined with --primary-tool',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
mode: opts.tool
|
|
71
|
+
? 'tool'
|
|
72
|
+
: opts.primaryTool
|
|
73
|
+
? 'primary'
|
|
74
|
+
: opts.allTools
|
|
75
|
+
? 'all-explicit'
|
|
76
|
+
: opts.noInteractive
|
|
77
|
+
? 'all-default'
|
|
78
|
+
: 'interactive-default-all',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
43
82
|
const HELP = `imprint v${VERSION} — teach an AI agent to use any website. Once.
|
|
44
83
|
|
|
45
84
|
USAGE
|
|
@@ -112,7 +151,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
112
151
|
summary:
|
|
113
152
|
'Record a workflow, compile both artifacts, emit the tool, and connect to your AI platform — all in one interactive flow. Supports resuming incomplete runs and multiple workflows per site.',
|
|
114
153
|
usage: [
|
|
115
|
-
'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
|
|
154
|
+
'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--primary-tool | --all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
|
|
116
155
|
],
|
|
117
156
|
flags: [
|
|
118
157
|
{ name: '--url <url>', description: 'Starting URL (else about:blank).' },
|
|
@@ -123,13 +162,15 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
123
162
|
{ name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
|
|
124
163
|
{
|
|
125
164
|
name: '--no-interactive',
|
|
126
|
-
description:
|
|
127
|
-
'Run without prompts; compile the primary detected tool and print integration snippets.',
|
|
165
|
+
description: 'Run without prompts; compile all detected tools by default.',
|
|
128
166
|
},
|
|
129
167
|
{
|
|
130
168
|
name: '--all-tools',
|
|
131
|
-
description:
|
|
132
|
-
|
|
169
|
+
description: 'Explicitly compile all detected tools (the default).',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: '--primary-tool',
|
|
173
|
+
description: 'Compile only the primary detected tool and its upstream dependencies.',
|
|
133
174
|
},
|
|
134
175
|
{
|
|
135
176
|
name: '--tool <toolName>',
|
|
@@ -1423,6 +1464,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1423
1464
|
'persist-profile': { type: 'boolean' },
|
|
1424
1465
|
'no-interactive': { type: 'boolean' },
|
|
1425
1466
|
'all-tools': { type: 'boolean' },
|
|
1467
|
+
'primary-tool': { type: 'boolean' },
|
|
1426
1468
|
tool: { type: 'string' },
|
|
1427
1469
|
provider: { type: 'string' },
|
|
1428
1470
|
model: { type: 'string' },
|
|
@@ -1464,8 +1506,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1464
1506
|
return 2;
|
|
1465
1507
|
}
|
|
1466
1508
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1509
|
+
const selection = resolveTeachSelectionMode({
|
|
1510
|
+
tool: values.tool,
|
|
1511
|
+
primaryTool: values['primary-tool'],
|
|
1512
|
+
allTools: values['all-tools'],
|
|
1513
|
+
noInteractive: values['no-interactive'],
|
|
1514
|
+
});
|
|
1515
|
+
if (selection.error) {
|
|
1516
|
+
console.error(selection.error);
|
|
1469
1517
|
return 2;
|
|
1470
1518
|
}
|
|
1471
1519
|
|
|
@@ -1500,6 +1548,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1500
1548
|
const ctrl = new AbortController();
|
|
1501
1549
|
const onSigint = (): void => ctrl.abort();
|
|
1502
1550
|
process.once('SIGINT', onSigint);
|
|
1551
|
+
const teachSelectionMode = selection.mode;
|
|
1503
1552
|
|
|
1504
1553
|
try {
|
|
1505
1554
|
const { teach } = await import('./imprint/teach.ts');
|
|
@@ -1513,7 +1562,10 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1513
1562
|
'imprint.provider': values.provider ?? 'auto',
|
|
1514
1563
|
'imprint.model': values.model ?? 'auto',
|
|
1515
1564
|
'imprint.timeout_ms': teachTimeoutMs ?? 'default',
|
|
1516
|
-
'imprint.all_tools':
|
|
1565
|
+
'imprint.all_tools':
|
|
1566
|
+
teachSelectionMode === 'all-explicit' || teachSelectionMode === 'all-default',
|
|
1567
|
+
'imprint.selection_mode': teachSelectionMode,
|
|
1568
|
+
'imprint.primary_tool': values['primary-tool'] ?? false,
|
|
1517
1569
|
'imprint.tool': values.tool,
|
|
1518
1570
|
'imprint.no_interactive': values['no-interactive'] ?? false,
|
|
1519
1571
|
'imprint.skip_replay': values['skip-replay'] ?? false,
|
|
@@ -1530,7 +1582,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1530
1582
|
model: values.model,
|
|
1531
1583
|
maxDurationMs: teachTimeoutMs,
|
|
1532
1584
|
keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
|
|
1533
|
-
|
|
1585
|
+
primaryTool: values['primary-tool'],
|
|
1534
1586
|
tool: values.tool,
|
|
1535
1587
|
skipReplay: values['skip-replay'],
|
|
1536
1588
|
fromStep: fromStepArg,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type SharedCompileContext,
|
|
34
34
|
SharedCompileContextSchema,
|
|
35
35
|
type ToolCandidate,
|
|
36
|
+
closeCandidateSelection,
|
|
36
37
|
} from './tool-candidates.ts';
|
|
37
38
|
|
|
38
39
|
export const TEACH_STEPS = [
|
|
@@ -55,6 +56,8 @@ export interface WorkflowState {
|
|
|
55
56
|
redactedPath?: string;
|
|
56
57
|
triagedPath?: string;
|
|
57
58
|
classificationsPath?: string;
|
|
59
|
+
/** Hash of the replay classification payload used for candidate/build planning. */
|
|
60
|
+
classificationsHash?: string;
|
|
58
61
|
completedSteps: TeachStep[];
|
|
59
62
|
error?: string;
|
|
60
63
|
startedAt: string;
|
|
@@ -90,6 +93,94 @@ export interface TeachState {
|
|
|
90
93
|
workflows: Record<string, WorkflowState>;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** Candidate-backed resumes must reconstruct the persisted tool set so a
|
|
97
|
+
* downstream tool is never resumed without its upstream dependencies. */
|
|
98
|
+
export function isCandidateBackedResume(
|
|
99
|
+
ws: WorkflowState | undefined,
|
|
100
|
+
): ws is WorkflowState & { candidate: ToolCandidate } {
|
|
101
|
+
return ws?.candidate !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Only a fresh detection run supersedes existing tools. A resume may
|
|
105
|
+
* intentionally exclude tools from another recording or an earlier phase. */
|
|
106
|
+
export function staleCompletedToolsForRun(
|
|
107
|
+
completedToolNames: readonly string[],
|
|
108
|
+
incomingToolNames: ReadonlySet<string>,
|
|
109
|
+
cleanupStaleTools: boolean,
|
|
110
|
+
): string[] {
|
|
111
|
+
return cleanupStaleTools ? completedToolNames.filter((name) => !incomingToolNames.has(name)) : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve behavior from the actual run chosen by the CLI/TUI, rather than from
|
|
115
|
+
* the presence of a CLI flag. Interactive Continue/Redo must follow the same
|
|
116
|
+
* resume semantics as an explicit `--from-step`. */
|
|
117
|
+
export function resolvedTeachRunPolicy(startFrom: TeachStep, resumesExistingRun: boolean) {
|
|
118
|
+
return {
|
|
119
|
+
cleanupStaleTools: !resumesExistingRun,
|
|
120
|
+
reuseExistingBuildPlan: startFrom !== 'plan-prereqs',
|
|
121
|
+
reviseExistingArtifacts: resumesExistingRun,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function candidatePlanningSignature(candidate: ToolCandidate): string {
|
|
126
|
+
return JSON.stringify(candidate);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether re-detection changed the candidate set or any planning-relevant
|
|
130
|
+
* candidate evidence for this recording. A changed graph invalidates prior
|
|
131
|
+
* plan-prereqs/compile progress even when candidate names stayed the same. */
|
|
132
|
+
export function candidateSelectionChanged(
|
|
133
|
+
state: TeachState,
|
|
134
|
+
sessionPath: string,
|
|
135
|
+
selected: ToolCandidate[],
|
|
136
|
+
sharedContext?: SharedCompileContext,
|
|
137
|
+
classificationsHash?: string,
|
|
138
|
+
): boolean {
|
|
139
|
+
const priorWorkflows = Object.values(state.workflows).filter(
|
|
140
|
+
(workflow) => workflow.sessionPath === sessionPath && workflow.candidate,
|
|
141
|
+
);
|
|
142
|
+
if (priorWorkflows.length === 0) return false;
|
|
143
|
+
const prior = priorWorkflows.map((workflow) => workflow.candidate as ToolCandidate);
|
|
144
|
+
const signatures = (candidates: ToolCandidate[]): string[] =>
|
|
145
|
+
candidates.map(candidatePlanningSignature).sort();
|
|
146
|
+
if (JSON.stringify(signatures(prior)) !== JSON.stringify(signatures(selected))) return true;
|
|
147
|
+
const priorContexts = new Set(
|
|
148
|
+
priorWorkflows.map((workflow) => JSON.stringify(workflow.sharedContext ?? null)),
|
|
149
|
+
);
|
|
150
|
+
if (priorContexts.size !== 1 || !priorContexts.has(JSON.stringify(sharedContext ?? null))) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
const priorClassificationHashes = new Set(
|
|
154
|
+
priorWorkflows.map((workflow) => workflow.classificationsHash ?? null),
|
|
155
|
+
);
|
|
156
|
+
return (
|
|
157
|
+
priorClassificationHashes.size !== 1 ||
|
|
158
|
+
!priorClassificationHashes.has(classificationsHash ?? null)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove obsolete candidate state for one recording after a new selection is
|
|
163
|
+
* finalized. Other recordings and pending placeholders remain untouched. */
|
|
164
|
+
export function pruneUnselectedCandidateWorkflows(
|
|
165
|
+
state: TeachState,
|
|
166
|
+
sessionPath: string,
|
|
167
|
+
selectedToolNames: ReadonlySet<string>,
|
|
168
|
+
): string[] {
|
|
169
|
+
const removed: string[] = [];
|
|
170
|
+
for (const [key, workflow] of Object.entries(state.workflows)) {
|
|
171
|
+
if (
|
|
172
|
+
workflow.sessionPath !== sessionPath ||
|
|
173
|
+
!workflow.candidate ||
|
|
174
|
+
selectedToolNames.has(workflow.candidate.toolName)
|
|
175
|
+
) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
delete state.workflows[key];
|
|
179
|
+
removed.push(key);
|
|
180
|
+
}
|
|
181
|
+
return removed;
|
|
182
|
+
}
|
|
183
|
+
|
|
93
184
|
export function teachStatePath(site: string): string {
|
|
94
185
|
return pathJoin(localSiteDir(site), '.teach-state.json');
|
|
95
186
|
}
|
|
@@ -106,9 +197,10 @@ export function loadTeachState(site: string): TeachState {
|
|
|
106
197
|
try {
|
|
107
198
|
const state = JSON.parse(readFileSync(loadPath, 'utf8')) as TeachState;
|
|
108
199
|
for (const workflow of Object.values(state.workflows)) {
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
200
|
+
if (workflow.sharedContext) {
|
|
201
|
+
const parsed = SharedCompileContextSchema.safeParse(workflow.sharedContext);
|
|
202
|
+
workflow.sharedContext = parsed.success ? parsed.data : undefined;
|
|
203
|
+
}
|
|
112
204
|
}
|
|
113
205
|
return isLegacy ? normalizeLegacyTeachState(site, state) : state;
|
|
114
206
|
} catch {
|
|
@@ -434,8 +526,8 @@ export function resolveStepStartTarget(
|
|
|
434
526
|
return { workflowKey, ws };
|
|
435
527
|
}
|
|
436
528
|
|
|
437
|
-
/** A
|
|
438
|
-
*
|
|
529
|
+
/** A candidate-backed resume reconstructs the prior run's tools from persisted
|
|
530
|
+
* state. Scope it to (a) tools from the SAME recording as the resume
|
|
439
531
|
* target — cross-recording tools have a different session and would otherwise be
|
|
440
532
|
* compiled against the wrong one — and (b) tools whose prior run actually reached
|
|
441
533
|
* `fromStep`'s prerequisites — a tool that failed earlier has no
|
|
@@ -445,13 +537,14 @@ export function resolveStepStartTarget(
|
|
|
445
537
|
interface MultiToolResumeSelection {
|
|
446
538
|
plans: {
|
|
447
539
|
workflowKey: string;
|
|
448
|
-
candidate:
|
|
540
|
+
candidate: ToolCandidate;
|
|
449
541
|
sharedContext: WorkflowState['sharedContext'];
|
|
450
542
|
}[];
|
|
451
543
|
skipped: {
|
|
452
544
|
workflowKey: string;
|
|
453
545
|
reason: 'different-recording' | 'not-resumable' | 'not-in-build-plan';
|
|
454
546
|
}[];
|
|
547
|
+
cycles: string[][];
|
|
455
548
|
}
|
|
456
549
|
|
|
457
550
|
export function selectMultiToolResumePlans(
|
|
@@ -459,13 +552,18 @@ export function selectMultiToolResumePlans(
|
|
|
459
552
|
targetWorkflowKey: string,
|
|
460
553
|
fromStep: TeachStep,
|
|
461
554
|
opts?: {
|
|
462
|
-
|
|
463
|
-
allTools?: boolean;
|
|
555
|
+
primaryTool?: boolean;
|
|
464
556
|
toolName?: string;
|
|
465
557
|
plannedToolNames?: ReadonlySet<string>;
|
|
466
558
|
},
|
|
467
559
|
): MultiToolResumeSelection {
|
|
468
560
|
const target = state.workflows[targetWorkflowKey];
|
|
561
|
+
const persistedPrimary = Object.entries(state.workflows).find(
|
|
562
|
+
([key, workflow]) =>
|
|
563
|
+
!key.startsWith('_pending_') &&
|
|
564
|
+
workflow.candidate?.primary &&
|
|
565
|
+
(!target || workflow.sessionPath === target.sessionPath),
|
|
566
|
+
);
|
|
469
567
|
const plans: MultiToolResumeSelection['plans'] = [];
|
|
470
568
|
const skipped: MultiToolResumeSelection['skipped'] = [];
|
|
471
569
|
for (const [key, ws] of Object.entries(state.workflows)) {
|
|
@@ -484,6 +582,34 @@ export function selectMultiToolResumePlans(
|
|
|
484
582
|
}
|
|
485
583
|
plans.push({ workflowKey: key, candidate: ws.candidate, sharedContext: ws.sharedContext });
|
|
486
584
|
}
|
|
585
|
+
if (target?.candidate && !plans.some((plan) => plan.workflowKey === targetWorkflowKey)) {
|
|
586
|
+
const reason = skipped.find((entry) => entry.workflowKey === targetWorkflowKey)?.reason;
|
|
587
|
+
throw new Error(
|
|
588
|
+
`Cannot resume tool "${target.candidate.toolName}" from "${fromStep}": the target is ${reason ?? 'unavailable'}. Resume an earlier step or re-run from "detect-candidates".`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const closePlans = (rootNames: string[]): Pick<MultiToolResumeSelection, 'plans' | 'cycles'> => {
|
|
592
|
+
const candidatePlans = plans;
|
|
593
|
+
const closure = closeCandidateSelection(
|
|
594
|
+
candidatePlans.map((plan) => plan.candidate),
|
|
595
|
+
rootNames,
|
|
596
|
+
);
|
|
597
|
+
const selectedNames = new Set(closure.selected.map((candidate) => candidate.toolName));
|
|
598
|
+
const eligibleNames = new Set(candidatePlans.map((plan) => plan.candidate.toolName));
|
|
599
|
+
for (const candidate of closure.selected) {
|
|
600
|
+
for (const dependency of candidate.dependsOnTools) {
|
|
601
|
+
if (eligibleNames.has(dependency)) continue;
|
|
602
|
+
throw new Error(
|
|
603
|
+
`Cannot resume tool "${candidate.toolName}" from "${fromStep}": required upstream tool "${dependency}" is unavailable. Re-run from "detect-candidates" to rebuild a complete tool set.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
plans: plans.filter((plan) => selectedNames.has(plan.candidate.toolName)),
|
|
609
|
+
cycles: closure.cycles,
|
|
610
|
+
};
|
|
611
|
+
};
|
|
612
|
+
|
|
487
613
|
if (opts?.toolName) {
|
|
488
614
|
const selected = plans.find(
|
|
489
615
|
(plan) => plan.workflowKey === opts.toolName || plan.candidate?.toolName === opts.toolName,
|
|
@@ -493,14 +619,30 @@ export function selectMultiToolResumePlans(
|
|
|
493
619
|
`Cannot resume tool "${opts.toolName}": it is not resumable from "${fromStep}" in the selected recording.`,
|
|
494
620
|
);
|
|
495
621
|
}
|
|
496
|
-
return {
|
|
622
|
+
return { ...closePlans([selected.candidate.toolName]), skipped };
|
|
497
623
|
}
|
|
498
|
-
if (opts?.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
624
|
+
if (opts?.primaryTool) {
|
|
625
|
+
if (!persistedPrimary) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`Cannot resume from "${fromStep}" with --primary-tool: the selected recording has no primary candidate. Re-run from "detect-candidates".`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const primary = plans.find((plan) => plan.workflowKey === persistedPrimary[0]);
|
|
631
|
+
if (!primary) {
|
|
632
|
+
const reason = skipped.find((entry) => entry.workflowKey === persistedPrimary[0])?.reason;
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Cannot resume primary tool "${persistedPrimary[1].candidate?.toolName ?? persistedPrimary[0]}" from "${fromStep}": it is ${reason ?? 'unavailable'}. Resume an earlier step or re-run from "detect-candidates".`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
...closePlans([primary.candidate.toolName]),
|
|
639
|
+
skipped,
|
|
640
|
+
};
|
|
502
641
|
}
|
|
503
|
-
return {
|
|
642
|
+
return {
|
|
643
|
+
...closePlans(plans.map((plan) => plan.candidate.toolName)),
|
|
644
|
+
skipped,
|
|
645
|
+
};
|
|
504
646
|
}
|
|
505
647
|
|
|
506
648
|
/** True when the `[startIdx, stopIdx]` phase window overlaps the atomic analysis
|
package/src/imprint/teach.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { authWorkflowHash } from './auth-compile-tools.ts';
|
|
|
23
23
|
import {
|
|
24
24
|
type SharedModuleManifestEntry,
|
|
25
25
|
buildPlanSidecarPath,
|
|
26
|
+
deriveTokenContractHints,
|
|
26
27
|
readBuildPlanFile,
|
|
27
28
|
topoLevelsForTools,
|
|
28
29
|
} from './build-plan.ts';
|
|
@@ -95,26 +96,34 @@ import {
|
|
|
95
96
|
type WorkflowState,
|
|
96
97
|
analysisBlockRunsForWindow,
|
|
97
98
|
buildTeachStateFromSession,
|
|
99
|
+
candidateSelectionChanged,
|
|
98
100
|
detectCandidatesCompletedSteps,
|
|
99
101
|
discoverCompletedWorkflows,
|
|
100
102
|
discoverOrphanSession,
|
|
101
103
|
friendlySessionTimestamp,
|
|
104
|
+
isCandidateBackedResume,
|
|
102
105
|
isExistingTeachFile as isExistingFile,
|
|
103
106
|
loadTeachState,
|
|
104
107
|
nextTeachStep as nextStep,
|
|
105
108
|
pruneStalePendingTeachWorkflows,
|
|
109
|
+
pruneUnselectedCandidateWorkflows,
|
|
106
110
|
resolveStepStartTarget,
|
|
107
111
|
resolveTeachStatePath,
|
|
108
112
|
resolveWorkflowTriagedPath,
|
|
113
|
+
resolvedTeachRunPolicy,
|
|
109
114
|
saveTeachState,
|
|
110
115
|
selectMultiToolResumePlans,
|
|
116
|
+
staleCompletedToolsForRun,
|
|
111
117
|
toRelativeTeachStatePath as toRelative,
|
|
112
118
|
} from './teach-state.ts';
|
|
113
119
|
import {
|
|
114
120
|
type SharedCompileContext,
|
|
115
121
|
type ToolCandidate,
|
|
116
122
|
buildSharedCompileContext as buildCandidateSharedCompileContext,
|
|
123
|
+
closeCandidateSelection,
|
|
124
|
+
deriveStructuralCandidateDependencies,
|
|
117
125
|
detectToolCandidates,
|
|
126
|
+
mergeCandidateDependencies,
|
|
118
127
|
primaryToolCandidate,
|
|
119
128
|
sharedContextHasAuth,
|
|
120
129
|
} from './tool-candidates.ts';
|
|
@@ -178,6 +187,40 @@ function fileSha256(path: string): string {
|
|
|
178
187
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
179
188
|
}
|
|
180
189
|
|
|
190
|
+
/** Read replay classifications only when the sidecar belongs to the exact
|
|
191
|
+
* recording being compiled. Request seq numbers are recording-local, so using
|
|
192
|
+
* a site-global cache from another recording could fabricate dependency edges. */
|
|
193
|
+
export function loadCachedClassificationsForSession(
|
|
194
|
+
classPath: string,
|
|
195
|
+
sessionPath: string,
|
|
196
|
+
): ClassifiedValue[] | undefined {
|
|
197
|
+
if (!existsSync(classPath) || !existsSync(sessionPath)) return undefined;
|
|
198
|
+
try {
|
|
199
|
+
const cached = JSON.parse(readFileSync(classPath, 'utf8')) as {
|
|
200
|
+
sourceSessionHash?: string;
|
|
201
|
+
classifications?: ClassifiedValue[];
|
|
202
|
+
};
|
|
203
|
+
if (
|
|
204
|
+
!cached.sourceSessionHash ||
|
|
205
|
+
cached.sourceSessionHash !== fileSha256(sessionPath) ||
|
|
206
|
+
!Array.isArray(cached.classifications)
|
|
207
|
+
) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return cached.classifications;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function classificationsPlanningHash(
|
|
217
|
+
classifications: ClassifiedValue[] | undefined,
|
|
218
|
+
): string | undefined {
|
|
219
|
+
return classifications
|
|
220
|
+
? createHash('sha256').update(JSON.stringify(classifications)).digest('hex')
|
|
221
|
+
: undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
181
224
|
export {
|
|
182
225
|
buildTeachStateFromSession,
|
|
183
226
|
resolveTeachStatePath,
|
|
@@ -227,8 +270,8 @@ interface TeachOptions {
|
|
|
227
270
|
fromSession?: string;
|
|
228
271
|
/** Retain parser.test.ts after successful compile-agent verification. */
|
|
229
272
|
keepTest?: boolean;
|
|
230
|
-
/**
|
|
231
|
-
|
|
273
|
+
/** Compile only the primary detected candidate and its upstream dependencies. */
|
|
274
|
+
primaryTool?: boolean;
|
|
232
275
|
/** Bounded resume: compile exactly one persisted candidate from the retained recording. */
|
|
233
276
|
tool?: string;
|
|
234
277
|
/** Skip the replay-and-diff stage entirely. */
|
|
@@ -518,6 +561,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
518
561
|
let redactedPath: string | null = null;
|
|
519
562
|
let resumedTriagedPath: string | null = null;
|
|
520
563
|
let usingFromSession = false;
|
|
564
|
+
let resumesExistingRun = false;
|
|
521
565
|
|
|
522
566
|
const hasExisting = completedWorkflows.length > 0 || incompleteWorkflows.length > 0;
|
|
523
567
|
|
|
@@ -534,6 +578,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
534
578
|
// run's persisted outputs. resolveStepStartTarget picks the most-recent
|
|
535
579
|
// workflow and THROWS if it didn't reach far enough (the dependency guard).
|
|
536
580
|
const target = resolveStepStartTarget(site, state, opts.fromStep, opts.tool);
|
|
581
|
+
resumesExistingRun = true;
|
|
537
582
|
workflowKey = target.workflowKey;
|
|
538
583
|
startFrom = opts.fromStep;
|
|
539
584
|
sessionPath = resolveTeachStatePath(site, target.ws.sessionPath);
|
|
@@ -571,6 +616,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
571
616
|
if (choice.action === 'new') {
|
|
572
617
|
startFrom = 'record';
|
|
573
618
|
} else if (choice.action === 'continue') {
|
|
619
|
+
resumesExistingRun = true;
|
|
574
620
|
workflowKey = choice.workflowKey;
|
|
575
621
|
const ws = state.workflows[workflowKey];
|
|
576
622
|
if (!ws) {
|
|
@@ -584,6 +630,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
584
630
|
} else if (choice.action === 'redo') {
|
|
585
631
|
workflowKey = choice.workflowKey;
|
|
586
632
|
startFrom = choice.fromStep;
|
|
633
|
+
resumesExistingRun = startFrom !== 'record';
|
|
587
634
|
const ws = state.workflows[workflowKey];
|
|
588
635
|
if (ws) {
|
|
589
636
|
sessionPath = resolveTeachStatePath(site, ws.sessionPath);
|
|
@@ -614,6 +661,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
614
661
|
}
|
|
615
662
|
|
|
616
663
|
const startIdx = STEPS.indexOf(startFrom);
|
|
664
|
+
const runPolicy = resolvedTeachRunPolicy(startFrom, resumesExistingRun);
|
|
617
665
|
// Upper bound of the phase window: `--to-step`/`--only` stop the chain after a
|
|
618
666
|
// given step (default = the last step, i.e. run to the end). A phase runs only
|
|
619
667
|
// when it falls within [startFrom, toStep].
|
|
@@ -912,6 +960,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
912
960
|
if (stopIdx < STEPS.indexOf('replay-and-diff')) await finishEarly();
|
|
913
961
|
|
|
914
962
|
let plans: CandidateCompilePlan[];
|
|
963
|
+
let candidateBackedResume = false;
|
|
915
964
|
|
|
916
965
|
// The replay→triage→detect-candidates analysis is one atomic block (the sub-
|
|
917
966
|
// steps share a parallel run + the triaged session), so the window can START
|
|
@@ -959,24 +1008,30 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
959
1008
|
const replayPromise = (async () => {
|
|
960
1009
|
if (!needsReplay) {
|
|
961
1010
|
const classPath = pathJoin(localSiteDir(site), '.classifications.json');
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
/* proceed without */
|
|
968
|
-
}
|
|
1011
|
+
const cached = loadCachedClassificationsForSession(classPath, replaySessionPath);
|
|
1012
|
+
if (!cached && existsSync(classPath)) {
|
|
1013
|
+
p.log.warn(
|
|
1014
|
+
'Ignoring cached replay classifications because they are untagged or belong to a different recording.',
|
|
1015
|
+
);
|
|
969
1016
|
}
|
|
970
|
-
return
|
|
1017
|
+
return cached;
|
|
971
1018
|
}
|
|
972
1019
|
return siteReplayAndDiff(site, replaySessionPath, mp);
|
|
973
1020
|
})();
|
|
974
1021
|
|
|
975
1022
|
// Branch B: triage → detect-candidates → user selection (fast, ~30s)
|
|
976
|
-
type CandidateChainResult =
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1023
|
+
type CandidateChainResult =
|
|
1024
|
+
| {
|
|
1025
|
+
triageResult?: TriageResult;
|
|
1026
|
+
plans: CandidateCompilePlan[];
|
|
1027
|
+
}
|
|
1028
|
+
| {
|
|
1029
|
+
triageResult?: TriageResult;
|
|
1030
|
+
detection: Awaited<ReturnType<typeof detectTeachCandidates>>;
|
|
1031
|
+
preliminarySelected: ToolCandidate[];
|
|
1032
|
+
baseState: WorkflowState;
|
|
1033
|
+
pendingKey: string | null;
|
|
1034
|
+
};
|
|
980
1035
|
const candidatePromise = (async (): Promise<CandidateChainResult> => {
|
|
981
1036
|
if (!needsCandidates) {
|
|
982
1037
|
const ws = state.workflows[workflowKey];
|
|
@@ -1065,12 +1120,16 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1065
1120
|
mp.pause();
|
|
1066
1121
|
mp.clear();
|
|
1067
1122
|
spinner.start('Detecting candidate tools');
|
|
1068
|
-
const
|
|
1123
|
+
const detected = await detectTeachCandidates({
|
|
1069
1124
|
sessionPath: compileSessionPath,
|
|
1070
1125
|
providerName,
|
|
1071
1126
|
model,
|
|
1072
1127
|
trustSessionScope: !!localTriagedPath,
|
|
1073
1128
|
});
|
|
1129
|
+
const detection = {
|
|
1130
|
+
...detected,
|
|
1131
|
+
candidates: deriveStructuralCandidateDependencies(detected.candidates),
|
|
1132
|
+
};
|
|
1074
1133
|
spinner.stop(
|
|
1075
1134
|
`Detected ${detection.candidates.length} candidate tool${detection.candidates.length === 1 ? '' : 's'}.`,
|
|
1076
1135
|
);
|
|
@@ -1079,8 +1138,6 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1079
1138
|
const selected = await selectTeachCandidates(detection, opts);
|
|
1080
1139
|
mp.resume();
|
|
1081
1140
|
|
|
1082
|
-
const sharedContext = buildCandidateSharedCompileContext(detection, selected);
|
|
1083
|
-
|
|
1084
1141
|
// ── Credential prompt (deferred until here so the LLM decides which login succeeded) ──
|
|
1085
1142
|
const llmLoginSeqs = new Set(detection.sharedContext?.loginRequestSeqs ?? []);
|
|
1086
1143
|
if (credentialFindings.length > 0 && llmLoginSeqs.size > 0) {
|
|
@@ -1147,48 +1204,17 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1147
1204
|
if (localTriagedPath) {
|
|
1148
1205
|
baseState.triagedPath = toRelative(site, localTriagedPath);
|
|
1149
1206
|
}
|
|
1150
|
-
const candidatePlans = selected.map((candidate) => {
|
|
1151
|
-
checkpoint(site, state, candidate.toolName, {
|
|
1152
|
-
...baseState,
|
|
1153
|
-
// Preserve prior progress only when re-detecting the SAME recording, so
|
|
1154
|
-
// a re-run of the analysis block (`--from-step`/`--only detect-candidates`,
|
|
1155
|
-
// or interactive redo) doesn't regress a tool that already reached
|
|
1156
|
-
// generate…register (which would break a later `--from-step register`).
|
|
1157
|
-
// A fresh / different recording producing the same toolName must NOT
|
|
1158
|
-
// inherit the old `plan-prereqs` marker, or the alreadyPlanned shortcut
|
|
1159
|
-
// below would skip re-planning and compile against the previous
|
|
1160
|
-
// recording's `_shared/` modules — detectCandidatesCompletedSteps gates
|
|
1161
|
-
// on the recording's sessionPath.
|
|
1162
|
-
completedSteps: detectCandidatesCompletedSteps(
|
|
1163
|
-
state.workflows[candidate.toolName],
|
|
1164
|
-
baseState.sessionPath,
|
|
1165
|
-
),
|
|
1166
|
-
candidate,
|
|
1167
|
-
sharedContext,
|
|
1168
|
-
});
|
|
1169
|
-
return {
|
|
1170
|
-
workflowKey: candidate.toolName,
|
|
1171
|
-
startFrom: 'generate' as Step,
|
|
1172
|
-
candidate,
|
|
1173
|
-
sharedContext,
|
|
1174
|
-
};
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
if (pendingKey && state.workflows[pendingKey]) {
|
|
1178
|
-
delete state.workflows[pendingKey];
|
|
1179
|
-
saveTeachState(site, state);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
1207
|
return {
|
|
1183
1208
|
triageResult: localTriageResult,
|
|
1184
|
-
|
|
1209
|
+
detection,
|
|
1210
|
+
preliminarySelected: selected,
|
|
1211
|
+
baseState,
|
|
1212
|
+
pendingKey,
|
|
1185
1213
|
};
|
|
1186
1214
|
})();
|
|
1187
1215
|
|
|
1188
1216
|
// Wait for candidate chain (includes user interaction)
|
|
1189
1217
|
const candidateResult = await candidatePromise;
|
|
1190
|
-
plans = candidateResult.plans;
|
|
1191
|
-
|
|
1192
1218
|
triageResult = candidateResult.triageResult;
|
|
1193
1219
|
|
|
1194
1220
|
// Wait for replay — may already be done, or show progress while waiting
|
|
@@ -1213,6 +1239,70 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1213
1239
|
|
|
1214
1240
|
mp.clear();
|
|
1215
1241
|
|
|
1242
|
+
if ('plans' in candidateResult) {
|
|
1243
|
+
plans = candidateResult.plans;
|
|
1244
|
+
} else {
|
|
1245
|
+
const finalizedSelection = finalizeTeachCandidateSelection(
|
|
1246
|
+
candidateResult.detection.candidates,
|
|
1247
|
+
candidateResult.preliminarySelected.map((candidate) => candidate.toolName),
|
|
1248
|
+
siteClassifications,
|
|
1249
|
+
);
|
|
1250
|
+
const candidates = finalizedSelection.candidates;
|
|
1251
|
+
const finalDetection = { ...candidateResult.detection, candidates };
|
|
1252
|
+
const notice = formatTeachCandidateAutoAddNotice(finalizedSelection.autoAdded, 'replay');
|
|
1253
|
+
if (notice) p.note(notice.body, notice.title);
|
|
1254
|
+
for (const cycle of finalizedSelection.cycles) {
|
|
1255
|
+
p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const sharedContext = buildCandidateSharedCompileContext(
|
|
1259
|
+
finalDetection,
|
|
1260
|
+
finalizedSelection.selected,
|
|
1261
|
+
);
|
|
1262
|
+
const classificationsHash = classificationsPlanningHash(siteClassifications);
|
|
1263
|
+
const selectedNames = new Set(
|
|
1264
|
+
finalizedSelection.selected.map((candidate) => candidate.toolName),
|
|
1265
|
+
);
|
|
1266
|
+
const selectionChanged = candidateSelectionChanged(
|
|
1267
|
+
state,
|
|
1268
|
+
candidateResult.baseState.sessionPath,
|
|
1269
|
+
finalizedSelection.selected,
|
|
1270
|
+
sharedContext,
|
|
1271
|
+
classificationsHash,
|
|
1272
|
+
);
|
|
1273
|
+
pruneUnselectedCandidateWorkflows(
|
|
1274
|
+
state,
|
|
1275
|
+
candidateResult.baseState.sessionPath,
|
|
1276
|
+
selectedNames,
|
|
1277
|
+
);
|
|
1278
|
+
plans = finalizedSelection.selected.map((candidate) => {
|
|
1279
|
+
checkpoint(site, state, candidate.toolName, {
|
|
1280
|
+
...candidateResult.baseState,
|
|
1281
|
+
// Preserve prior progress only when re-detecting the SAME recording, so
|
|
1282
|
+
// a re-run of the analysis block doesn't regress a tool that already
|
|
1283
|
+
// reached generate…register. A different recording must not inherit the
|
|
1284
|
+
// old plan-prereqs marker or shared-module build.
|
|
1285
|
+
completedSteps: detectCandidatesCompletedSteps(
|
|
1286
|
+
selectionChanged ? undefined : state.workflows[candidate.toolName],
|
|
1287
|
+
candidateResult.baseState.sessionPath,
|
|
1288
|
+
),
|
|
1289
|
+
candidate,
|
|
1290
|
+
sharedContext,
|
|
1291
|
+
classificationsHash,
|
|
1292
|
+
});
|
|
1293
|
+
return {
|
|
1294
|
+
workflowKey: candidate.toolName,
|
|
1295
|
+
startFrom: 'generate' as Step,
|
|
1296
|
+
candidate,
|
|
1297
|
+
sharedContext,
|
|
1298
|
+
};
|
|
1299
|
+
});
|
|
1300
|
+
if (candidateResult.pendingKey && state.workflows[candidateResult.pendingKey]) {
|
|
1301
|
+
delete state.workflows[candidateResult.pendingKey];
|
|
1302
|
+
saveTeachState(site, state);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1216
1306
|
// Checkpoints — write sequentially after both complete
|
|
1217
1307
|
updateCandidateStageCheckpoints({
|
|
1218
1308
|
site,
|
|
@@ -1233,12 +1323,8 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1233
1323
|
} else {
|
|
1234
1324
|
// Resuming from generate or later — load cached data
|
|
1235
1325
|
const classPath = pathJoin(localSiteDir(site), '.classifications.json');
|
|
1236
|
-
if (
|
|
1237
|
-
|
|
1238
|
-
siteClassifications = JSON.parse(readFileSync(classPath, 'utf8')).classifications;
|
|
1239
|
-
} catch {
|
|
1240
|
-
/* proceed without */
|
|
1241
|
-
}
|
|
1326
|
+
if (redactedPath) {
|
|
1327
|
+
siteClassifications = loadCachedClassificationsForSession(classPath, redactedPath);
|
|
1242
1328
|
}
|
|
1243
1329
|
const ws = state.workflows[workflowKey];
|
|
1244
1330
|
const resolvedTriagedPath = resolveWorkflowTriagedPath(site, ws);
|
|
@@ -1252,17 +1338,18 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1252
1338
|
triagedPath: resolvedArtifactCheckpointPath(site, ws.triagedPath, resolvedTriagedPath),
|
|
1253
1339
|
});
|
|
1254
1340
|
}
|
|
1255
|
-
// A
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1341
|
+
// A candidate-backed resume reconstructs the prior run's tools from persisted
|
|
1342
|
+
// state (shared-module planning may need ≥2 tools). This applies both to an
|
|
1343
|
+
// explicit `--from-step` and to the interactive Continue/Redo paths.
|
|
1344
|
+
// Resume defaults to the full persisted set. --primary-tool narrows to the
|
|
1345
|
+
// primary candidate plus its upstream dependency closure.
|
|
1259
1346
|
// selectMultiToolResumePlans scopes reconstruction to the same recording as the
|
|
1260
1347
|
// resume target and to tools that actually reached `startFrom`'s prerequisites,
|
|
1261
1348
|
// so a sibling from a different run can't be compiled against the wrong session
|
|
1262
|
-
// and one that failed earlier can't crash loading a missing artifact.
|
|
1263
|
-
|
|
1349
|
+
// and one that failed earlier can't crash loading a missing artifact.
|
|
1350
|
+
candidateBackedResume = isCandidateBackedResume(ws);
|
|
1264
1351
|
const allCandidatePlans: CandidateCompilePlan[] = [];
|
|
1265
|
-
if (
|
|
1352
|
+
if (candidateBackedResume) {
|
|
1266
1353
|
let plannedToolNames: Set<string> | undefined;
|
|
1267
1354
|
const persistedBuildPlanPath = resolveTeachStatePath(site, ws?.buildPlanPath);
|
|
1268
1355
|
if (persistedBuildPlanPath && existsSync(persistedBuildPlanPath)) {
|
|
@@ -1272,20 +1359,22 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1272
1359
|
}
|
|
1273
1360
|
}
|
|
1274
1361
|
const selection = selectMultiToolResumePlans(state, workflowKey, startFrom, {
|
|
1275
|
-
|
|
1276
|
-
allTools: opts.allTools,
|
|
1362
|
+
primaryTool: opts.primaryTool,
|
|
1277
1363
|
toolName: opts.tool,
|
|
1278
1364
|
plannedToolNames,
|
|
1279
1365
|
});
|
|
1280
1366
|
for (const { workflowKey: skippedKey, reason } of selection.skipped) {
|
|
1281
1367
|
p.log.warn(
|
|
1282
1368
|
reason === 'different-recording'
|
|
1283
|
-
? `Skipping tool "${skippedKey}"
|
|
1369
|
+
? `Skipping tool "${skippedKey}" while resuming from ${startFrom}: it belongs to a different recording than the resume target.`
|
|
1284
1370
|
: reason === 'not-in-build-plan'
|
|
1285
|
-
? `Skipping tool "${skippedKey}"
|
|
1286
|
-
: `Skipping tool "${skippedKey}"
|
|
1371
|
+
? `Skipping tool "${skippedKey}" while resuming from ${startFrom}: it is not in the current build plan.`
|
|
1372
|
+
: `Skipping tool "${skippedKey}" while resuming from ${startFrom}: its prior run didn't reach "${startFrom}" — resume an earlier step or re-run it.`,
|
|
1287
1373
|
);
|
|
1288
1374
|
}
|
|
1375
|
+
for (const cycle of selection.cycles) {
|
|
1376
|
+
p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
|
|
1377
|
+
}
|
|
1289
1378
|
for (const sel of selection.plans) {
|
|
1290
1379
|
allCandidatePlans.push({
|
|
1291
1380
|
workflowKey: sel.workflowKey,
|
|
@@ -1295,10 +1384,14 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1295
1384
|
});
|
|
1296
1385
|
}
|
|
1297
1386
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1387
|
+
if (candidateBackedResume && allCandidatePlans.length === 0) {
|
|
1388
|
+
throw new Error(
|
|
1389
|
+
`Cannot resume from "${startFrom}": the selected recording has no resumable candidates. Re-run from "detect-candidates".`,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
plans = candidateBackedResume
|
|
1393
|
+
? allCandidatePlans
|
|
1394
|
+
: [{ workflowKey, startFrom, candidate: ws?.candidate, sharedContext: ws?.sharedContext }];
|
|
1302
1395
|
}
|
|
1303
1396
|
|
|
1304
1397
|
// Early stop: `--to-step replay-and-diff|triage|detect-candidates` finishes
|
|
@@ -1367,16 +1460,18 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1367
1460
|
});
|
|
1368
1461
|
|
|
1369
1462
|
// ── Clean up stale tools from previous teach runs ──
|
|
1370
|
-
// Skipped
|
|
1463
|
+
// Skipped on every resume, including interactive Continue/Redo: a resume is scoped to a subset of
|
|
1371
1464
|
// the site's tools (selectMultiToolResumePlans intentionally leaves other
|
|
1372
1465
|
// recordings' tools — and same-recording tools that didn't reach the step —
|
|
1373
1466
|
// alone), so "not in the resume set" does NOT mean "stale". Treating it as stale
|
|
1374
1467
|
// here would silently rmSync a tool the resume just promised to preserve. Cleanup
|
|
1375
1468
|
// only applies to a fresh run that produces a superseding tool set.
|
|
1376
1469
|
const incomingToolNames = new Set(plans.map((pl) => pl.candidate?.toolName ?? pl.workflowKey));
|
|
1377
|
-
const staleTools =
|
|
1378
|
-
|
|
1379
|
-
|
|
1470
|
+
const staleTools = staleCompletedToolsForRun(
|
|
1471
|
+
discoverCompletedWorkflows(site),
|
|
1472
|
+
incomingToolNames,
|
|
1473
|
+
runPolicy.cleanupStaleTools,
|
|
1474
|
+
);
|
|
1380
1475
|
if (staleTools.length > 0) {
|
|
1381
1476
|
let shouldReplace = true;
|
|
1382
1477
|
if (!opts.noInteractive) {
|
|
@@ -1417,7 +1512,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1417
1512
|
// one tool; otherwise its assigned shared modules and producer-token contract
|
|
1418
1513
|
// silently disappear from the revision agent's context.
|
|
1419
1514
|
const alreadyPlanned =
|
|
1420
|
-
|
|
1515
|
+
runPolicy.reuseExistingBuildPlan &&
|
|
1421
1516
|
plans.every((pl) => state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs')) &&
|
|
1422
1517
|
existsSync(sidecar);
|
|
1423
1518
|
if (willGenerate && compileModel && alreadyPlanned && firstWs) {
|
|
@@ -1721,10 +1816,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1721
1816
|
sharedTriageResult: triageResult,
|
|
1722
1817
|
siteClassifications,
|
|
1723
1818
|
teachCredentials,
|
|
1724
|
-
allTools: opts.allTools,
|
|
1725
1819
|
buildPlanPath: buildPlanPath || undefined,
|
|
1726
1820
|
sharedModules: sharedModulesManifest.length > 0 ? sharedModulesManifest : undefined,
|
|
1727
|
-
resumeExistingArtifacts:
|
|
1821
|
+
resumeExistingArtifacts: runPolicy.reviseExistingArtifacts,
|
|
1728
1822
|
});
|
|
1729
1823
|
} finally {
|
|
1730
1824
|
unmuteLog();
|
|
@@ -1754,7 +1848,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1754
1848
|
}
|
|
1755
1849
|
}
|
|
1756
1850
|
|
|
1757
|
-
const primaryResult = results
|
|
1851
|
+
const primaryResult = selectPrimaryNamedResult(results, plans) as TeachToolResult;
|
|
1758
1852
|
|
|
1759
1853
|
// ── 6. Platform integration ────────────────────────────────────────
|
|
1760
1854
|
if (inWindow('register')) {
|
|
@@ -1913,47 +2007,133 @@ async function detectTeachCandidates(opts: {
|
|
|
1913
2007
|
);
|
|
1914
2008
|
}
|
|
1915
2009
|
|
|
1916
|
-
async function selectTeachCandidates(
|
|
2010
|
+
export async function selectTeachCandidates(
|
|
1917
2011
|
detection: Awaited<ReturnType<typeof detectToolCandidates>>,
|
|
1918
2012
|
opts: TeachOptions,
|
|
1919
2013
|
): Promise<ToolCandidate[]> {
|
|
1920
2014
|
if (detection.candidates.length === 1) return [detection.candidates[0] as ToolCandidate];
|
|
1921
2015
|
|
|
1922
|
-
if (opts.
|
|
1923
|
-
if (opts.allTools) return detection.candidates;
|
|
2016
|
+
if (opts.primaryTool) {
|
|
1924
2017
|
const primary = primaryToolCandidate(detection);
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2018
|
+
const closure = finalizeTeachCandidateSelection(detection.candidates, [primary.toolName]);
|
|
2019
|
+
for (const cycle of closure.cycles) {
|
|
2020
|
+
p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
|
|
2021
|
+
}
|
|
2022
|
+
return closure.selected;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (opts.noInteractive) {
|
|
2026
|
+
return detection.candidates;
|
|
1929
2027
|
}
|
|
1930
2028
|
|
|
2029
|
+
const picker = buildTeachCandidatePicker(detection.candidates);
|
|
1931
2030
|
const answer = await p.multiselect({
|
|
1932
2031
|
message:
|
|
1933
2032
|
'Which tools should Imprint compile from this recording?\n (press [space] to toggle, [enter] to submit)',
|
|
1934
2033
|
required: true,
|
|
1935
|
-
initialValues:
|
|
1936
|
-
|
|
1937
|
-
.map((c) => c.toolName),
|
|
1938
|
-
options: detection.candidates.map((candidate) => ({
|
|
1939
|
-
value: candidate.toolName,
|
|
1940
|
-
label: `${candidate.toolName}${candidate.primary ? ' (primary)' : ''}`,
|
|
1941
|
-
hint: `${Math.round(candidate.confidence * 100)}% — ${candidate.description}`,
|
|
1942
|
-
})),
|
|
2034
|
+
initialValues: picker.initialValues,
|
|
2035
|
+
options: picker.options,
|
|
1943
2036
|
});
|
|
1944
2037
|
if (p.isCancel(answer)) {
|
|
1945
2038
|
p.outro('Cancelled.');
|
|
1946
2039
|
process.exit(0);
|
|
1947
2040
|
}
|
|
1948
2041
|
|
|
1949
|
-
const
|
|
1950
|
-
|
|
1951
|
-
selectedNames.has(candidate.toolName),
|
|
1952
|
-
);
|
|
1953
|
-
if (selected.length === 0) {
|
|
2042
|
+
const closure = finalizeTeachCandidateSelection(detection.candidates, answer as string[]);
|
|
2043
|
+
if (closure.selected.length === 0) {
|
|
1954
2044
|
throw new Error('At least one tool candidate must be selected.');
|
|
1955
2045
|
}
|
|
1956
|
-
|
|
2046
|
+
const notice = formatTeachCandidateAutoAddNotice(closure.autoAdded, 'structural');
|
|
2047
|
+
if (notice) p.note(notice.body, notice.title);
|
|
2048
|
+
for (const cycle of closure.cycles) {
|
|
2049
|
+
p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
|
|
2050
|
+
}
|
|
2051
|
+
return closure.selected;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
export function buildTeachCandidatePicker(candidates: ToolCandidate[]): {
|
|
2055
|
+
initialValues: string[];
|
|
2056
|
+
options: Array<{ value: string; label: string; hint: string }>;
|
|
2057
|
+
} {
|
|
2058
|
+
return {
|
|
2059
|
+
initialValues: candidates.map((candidate) => candidate.toolName),
|
|
2060
|
+
options: candidates.map((candidate) => ({
|
|
2061
|
+
value: candidate.toolName,
|
|
2062
|
+
label: `${candidate.toolName}${candidate.primary ? ' (primary)' : ''}`,
|
|
2063
|
+
hint: [
|
|
2064
|
+
`${Math.round(candidate.confidence * 100)}% — ${candidate.description}`,
|
|
2065
|
+
candidate.dependsOnTools.length > 0
|
|
2066
|
+
? `requires ${candidate.dependsOnTools.join(', ')}`
|
|
2067
|
+
: undefined,
|
|
2068
|
+
]
|
|
2069
|
+
.filter(Boolean)
|
|
2070
|
+
.join(' — '),
|
|
2071
|
+
})),
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** Merge replay-grounded producer edges using the persisted ClassifiedValue
|
|
2076
|
+
* shape. The token deriver deliberately accepts a value-only projection so raw
|
|
2077
|
+
* recorded values stay out of planner payloads; adapt value1 at this boundary. */
|
|
2078
|
+
export function mergeReplayCandidateDependencies(
|
|
2079
|
+
candidates: ToolCandidate[],
|
|
2080
|
+
siteClassifications?: ClassifiedValue[],
|
|
2081
|
+
): ToolCandidate[] {
|
|
2082
|
+
const tokenHints = siteClassifications
|
|
2083
|
+
? deriveTokenContractHints({
|
|
2084
|
+
selectedTools: candidates,
|
|
2085
|
+
ephemeralValues: siteClassifications.map((classification) => ({
|
|
2086
|
+
classification: classification.classification,
|
|
2087
|
+
originalSeq: classification.originalSeq,
|
|
2088
|
+
location: classification.location,
|
|
2089
|
+
producerSeq: classification.producerSeq,
|
|
2090
|
+
producerPath: classification.producerPath,
|
|
2091
|
+
value: classification.value1,
|
|
2092
|
+
})),
|
|
2093
|
+
})
|
|
2094
|
+
: [];
|
|
2095
|
+
return mergeCandidateDependencies(
|
|
2096
|
+
candidates,
|
|
2097
|
+
tokenHints.map((hint) => ({
|
|
2098
|
+
consumerTool: hint.consumerTool,
|
|
2099
|
+
producerTool: hint.producerTool,
|
|
2100
|
+
})),
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/** Pure boundary between detected candidates, optional replay evidence, and
|
|
2105
|
+
* the exact dependency-closed set handed to checkpointing/build planning. */
|
|
2106
|
+
export function finalizeTeachCandidateSelection(
|
|
2107
|
+
candidates: ToolCandidate[],
|
|
2108
|
+
selectedToolNames: Iterable<string>,
|
|
2109
|
+
siteClassifications?: ClassifiedValue[],
|
|
2110
|
+
): ReturnType<typeof closeCandidateSelection> & { candidates: ToolCandidate[] } {
|
|
2111
|
+
const mergedCandidates = mergeReplayCandidateDependencies(candidates, siteClassifications);
|
|
2112
|
+
return {
|
|
2113
|
+
candidates: mergedCandidates,
|
|
2114
|
+
...closeCandidateSelection(mergedCandidates, selectedToolNames),
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
export function formatTeachCandidateAutoAddNotice(
|
|
2119
|
+
autoAdded: ToolCandidate[],
|
|
2120
|
+
source: 'structural' | 'replay',
|
|
2121
|
+
): { body: string; title: string } | undefined {
|
|
2122
|
+
if (autoAdded.length === 0) return undefined;
|
|
2123
|
+
return {
|
|
2124
|
+
body: autoAdded.map((candidate) => ` + ${candidate.toolName}`).join('\n'),
|
|
2125
|
+
title: source === 'replay' ? 'Added recorded prerequisites' : 'Added required upstream tools',
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** Select the compiled result corresponding to the detected primary candidate.
|
|
2130
|
+
* Dependency closure preserves detection order, which need not put primary first. */
|
|
2131
|
+
export function selectPrimaryNamedResult<T extends { workflow: { toolName: string } }>(
|
|
2132
|
+
results: readonly T[],
|
|
2133
|
+
plans: ReadonlyArray<{ candidate?: { toolName: string; primary: boolean } }>,
|
|
2134
|
+
): T | undefined {
|
|
2135
|
+
const primaryToolName = plans.find((plan) => plan.candidate?.primary)?.candidate?.toolName;
|
|
2136
|
+
return results.find((result) => result.workflow.toolName === primaryToolName) ?? results[0];
|
|
1957
2137
|
}
|
|
1958
2138
|
|
|
1959
2139
|
async function compileCandidatePlans(opts: {
|
|
@@ -1969,10 +2149,6 @@ async function compileCandidatePlans(opts: {
|
|
|
1969
2149
|
sharedTriageResult?: TriageResult;
|
|
1970
2150
|
siteClassifications?: ClassifiedValue[];
|
|
1971
2151
|
teachCredentials?: { site: string; values: Record<string, string> };
|
|
1972
|
-
/** Mirror of TeachOptions.allTools — when true, partial failures abort
|
|
1973
|
-
* the run with a non-zero exit so the user notices missing tools instead
|
|
1974
|
-
* of getting a silent warning. */
|
|
1975
|
-
allTools?: boolean;
|
|
1976
2152
|
/** Absolute path to the multi-tool build plan sidecar (.build-plan.json). */
|
|
1977
2153
|
buildPlanPath?: string;
|
|
1978
2154
|
/** Shared-module build manifest (verified flags) for this site. */
|
|
@@ -2142,18 +2318,20 @@ async function compileCandidatePlans(opts: {
|
|
|
2142
2318
|
if (first) p.log.warn(`${first.name}: ${first.firstLineError}`);
|
|
2143
2319
|
}
|
|
2144
2320
|
|
|
2145
|
-
|
|
2146
|
-
// partial compiles ship MCP servers with missing tools; the user only
|
|
2147
|
-
// notices later when an LLM tries to call one that doesn't exist.
|
|
2148
|
-
if (opts.allTools && summary.failures.length > 0) {
|
|
2149
|
-
throw new Error(
|
|
2150
|
-
`--all-tools requested but ${summary.failures.length} of ${opts.plans.length} tools failed to compile. See the summary above; re-run \`imprint teach\` after addressing the failures (or omit --all-tools to ship only what compiled).`,
|
|
2151
|
-
);
|
|
2152
|
-
}
|
|
2321
|
+
assertCompleteSelectedToolSet(summary.failures.length, opts.plans.length);
|
|
2153
2322
|
|
|
2154
2323
|
return summary.successes;
|
|
2155
2324
|
}
|
|
2156
2325
|
|
|
2326
|
+
/** Enforce that compilation never ships a partial selected set. A missing
|
|
2327
|
+
* producer can make every otherwise-successful consumer unusable. */
|
|
2328
|
+
export function assertCompleteSelectedToolSet(failureCount: number, selectedCount: number): void {
|
|
2329
|
+
if (failureCount === 0) return;
|
|
2330
|
+
throw new Error(
|
|
2331
|
+
`Selected tool set is incomplete: ${failureCount} of ${selectedCount} tools failed to compile. See the summary above and re-run \`imprint teach\` after addressing the failures.`,
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2157
2335
|
/** Pure summarizer — extracted so unit tests can drive arbitrary outcome
|
|
2158
2336
|
* shapes without spinning up real compile pipelines. */
|
|
2159
2337
|
interface CompileOutcomeSummary {
|
|
@@ -2488,7 +2666,10 @@ async function siteReplayAndDiff(
|
|
|
2488
2666
|
};
|
|
2489
2667
|
|
|
2490
2668
|
const classPath = pathJoin(localSiteDir(site), '.classifications.json');
|
|
2491
|
-
writeFileSync(
|
|
2669
|
+
writeFileSync(
|
|
2670
|
+
classPath,
|
|
2671
|
+
JSON.stringify({ ...diffResult, sourceSessionHash: fileSha256(sessionPath) }, null, 2),
|
|
2672
|
+
);
|
|
2492
2673
|
|
|
2493
2674
|
mp.clear();
|
|
2494
2675
|
mp.remove('replay');
|
|
@@ -133,6 +133,9 @@ export const ToolCandidateSchema = z.object({
|
|
|
133
133
|
expectedOutput: z.string().default(''),
|
|
134
134
|
likelyParams: z.array(CandidateParamSchema).default([]),
|
|
135
135
|
dependencySeqs: z.array(z.number().int().nonnegative()).default([]),
|
|
136
|
+
/** Direct user-visible tool prerequisites. A consumer may only be selected
|
|
137
|
+
* together with the transitive closure of these producer tools. */
|
|
138
|
+
dependsOnTools: z.array(z.string().regex(/^[a-z][a-z0-9_]*$/)).default([]),
|
|
136
139
|
});
|
|
137
140
|
export type ToolCandidate = z.infer<typeof ToolCandidateSchema>;
|
|
138
141
|
|
|
@@ -328,6 +331,124 @@ export function primaryToolCandidate(detection: ToolCandidateDetection): ToolCan
|
|
|
328
331
|
return primary;
|
|
329
332
|
}
|
|
330
333
|
|
|
334
|
+
/** Add deterministic candidate-level dependencies from request evidence.
|
|
335
|
+
* A dependency request creates an edge only when exactly one candidate owns
|
|
336
|
+
* that request seq. Shared/ambiguous ownership, unowned auth/bootstrap seqs,
|
|
337
|
+
* and self-owned seqs deliberately create no edge. */
|
|
338
|
+
export function deriveStructuralCandidateDependencies(
|
|
339
|
+
candidates: ToolCandidate[],
|
|
340
|
+
): ToolCandidate[] {
|
|
341
|
+
const ownersBySeq = new Map<number, Set<string>>();
|
|
342
|
+
for (const candidate of candidates) {
|
|
343
|
+
for (const seq of candidate.requestSeqs) {
|
|
344
|
+
const owners = ownersBySeq.get(seq) ?? new Set<string>();
|
|
345
|
+
owners.add(candidate.toolName);
|
|
346
|
+
ownersBySeq.set(seq, owners);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const edges: Array<{ consumerTool: string; producerTool: string }> = [];
|
|
351
|
+
for (const candidate of candidates) {
|
|
352
|
+
for (const seq of candidate.dependencySeqs) {
|
|
353
|
+
const owners = ownersBySeq.get(seq);
|
|
354
|
+
if (!owners || owners.size !== 1) continue;
|
|
355
|
+
const producerTool = owners.values().next().value as string | undefined;
|
|
356
|
+
if (!producerTool || producerTool === candidate.toolName) continue;
|
|
357
|
+
edges.push({ consumerTool: candidate.toolName, producerTool });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return mergeCandidateDependencies(candidates, edges);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Merge grounded producer edges into candidate metadata. Unknown tools and
|
|
364
|
+
* self-edges are ignored; dependency names are de-duplicated and normalized to
|
|
365
|
+
* candidate detection order for stable persistence and display. */
|
|
366
|
+
export function mergeCandidateDependencies(
|
|
367
|
+
candidates: ToolCandidate[],
|
|
368
|
+
edges: ReadonlyArray<{ consumerTool: string; producerTool: string }>,
|
|
369
|
+
): ToolCandidate[] {
|
|
370
|
+
const names = new Set(candidates.map((candidate) => candidate.toolName));
|
|
371
|
+
const dependencySets = new Map<string, Set<string>>();
|
|
372
|
+
for (const candidate of candidates) {
|
|
373
|
+
dependencySets.set(
|
|
374
|
+
candidate.toolName,
|
|
375
|
+
new Set(
|
|
376
|
+
candidate.dependsOnTools.filter((name) => names.has(name) && name !== candidate.toolName),
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
for (const edge of edges) {
|
|
381
|
+
if (
|
|
382
|
+
edge.consumerTool === edge.producerTool ||
|
|
383
|
+
!names.has(edge.consumerTool) ||
|
|
384
|
+
!names.has(edge.producerTool)
|
|
385
|
+
) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
dependencySets.get(edge.consumerTool)?.add(edge.producerTool);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const order = new Map(candidates.map((candidate, index) => [candidate.toolName, index]));
|
|
392
|
+
return candidates.map((candidate) => ({
|
|
393
|
+
...candidate,
|
|
394
|
+
dependsOnTools: [...(dependencySets.get(candidate.toolName) ?? [])].sort(
|
|
395
|
+
(a, b) =>
|
|
396
|
+
(order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER),
|
|
397
|
+
),
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface CandidateSelectionClosure {
|
|
402
|
+
selected: ToolCandidate[];
|
|
403
|
+
autoAdded: ToolCandidate[];
|
|
404
|
+
/** Cycles encountered while walking selected dependencies. Each path repeats
|
|
405
|
+
* its first name at the end, e.g. ["a", "b", "a"]. */
|
|
406
|
+
cycles: string[][];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Return the transitive dependency closure in original detection order.
|
|
410
|
+
* Cycles are included exactly once and reported, never treated as recursion
|
|
411
|
+
* errors. Unknown selected/dependency names are ignored. */
|
|
412
|
+
export function closeCandidateSelection(
|
|
413
|
+
candidates: ToolCandidate[],
|
|
414
|
+
selectedToolNames: Iterable<string>,
|
|
415
|
+
): CandidateSelectionClosure {
|
|
416
|
+
const byName = new Map(candidates.map((candidate) => [candidate.toolName, candidate]));
|
|
417
|
+
const initiallySelected = new Set([...selectedToolNames].filter((name) => byName.has(name)));
|
|
418
|
+
const included = new Set<string>();
|
|
419
|
+
const visiting: string[] = [];
|
|
420
|
+
const cycles: string[][] = [];
|
|
421
|
+
const cycleKeys = new Set<string>();
|
|
422
|
+
|
|
423
|
+
const include = (name: string): void => {
|
|
424
|
+
if (included.has(name)) return;
|
|
425
|
+
const candidate = byName.get(name);
|
|
426
|
+
if (!candidate) return;
|
|
427
|
+
const cycleStart = visiting.indexOf(name);
|
|
428
|
+
if (cycleStart >= 0) {
|
|
429
|
+
const cycle = [...visiting.slice(cycleStart), name];
|
|
430
|
+
const key = [...new Set(cycle.slice(0, -1))].sort().join('\u0000');
|
|
431
|
+
if (!cycleKeys.has(key)) {
|
|
432
|
+
cycleKeys.add(key);
|
|
433
|
+
cycles.push(cycle);
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
visiting.push(name);
|
|
438
|
+
for (const dependency of candidate.dependsOnTools) include(dependency);
|
|
439
|
+
visiting.pop();
|
|
440
|
+
included.add(name);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
for (const name of initiallySelected) include(name);
|
|
444
|
+
const selected = candidates.filter((candidate) => included.has(candidate.toolName));
|
|
445
|
+
return {
|
|
446
|
+
selected,
|
|
447
|
+
autoAdded: selected.filter((candidate) => !initiallySelected.has(candidate.toolName)),
|
|
448
|
+
cycles,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
331
452
|
export function buildSharedCompileContext(
|
|
332
453
|
detection: ToolCandidateDetection,
|
|
333
454
|
_selected: ToolCandidate[],
|